From afc3b0d51c8267f807ab01158e97a4dea2c6c1fe Mon Sep 17 00:00:00 2001 From: nebula-sdk-bot Date: Fri, 28 Aug 2026 23:05:09 +0000 Subject: [PATCH] fix: regenerate from nebula@67d2b6e --- MIGRATION.md | 22 +- README.md | 24 +- pyproject.toml | 14 +- src/nebula/__init__.py | 6 + src/nebula/_client.py | 8 +- src/nebula/_dx.py | 152 +- src/nebula/_inputs.py | 46 + src/nebula/_models.py | 2754 ++++++++++++++++++----- src/nebula/_runtime/__init__.py | 21 +- src/nebula/_runtime/client.py | 369 ++- src/nebula/_runtime/errors.py | 3 + src/nebula/_runtime/public_api_error.py | 19 + src/nebula/_runtime/retry.py | 14 +- src/nebula/_runtime/validation.py | 5 + src/nebula/resources/client.py | 23 +- src/nebula/resources/collections.py | 133 +- src/nebula/resources/connectors.py | 335 ++- src/nebula/resources/enterprise.py | 51 + src/nebula/resources/marketplace.py | 125 + src/nebula/resources/memories.py | 380 ---- src/nebula/resources/memory.py | 590 +++++ src/nebula/resources/snapshots.py | 30 +- src/nebula/resources/workspaces.py | 352 ++- tests/e2e/smoke.py | 41 +- tests/test_dx.py | 293 ++- tests/test_smoke.py | 1004 ++++++++- 26 files changed, 5488 insertions(+), 1326 deletions(-) create mode 100644 src/nebula/_inputs.py create mode 100644 src/nebula/_runtime/public_api_error.py create mode 100644 src/nebula/resources/enterprise.py create mode 100644 src/nebula/resources/marketplace.py delete mode 100644 src/nebula/resources/memories.py create mode 100644 src/nebula/resources/memory.py diff --git a/MIGRATION.md b/MIGRATION.md index f4d366e..d78d13f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,13 +1,18 @@ -# Migrating to nebula-sdk 1.4.x +# Migration guide + +When upgrading across multiple release lines, review every section newer than +your installed version and apply the applicable changes from oldest to newest. + +## Migrating to nebula-sdk 1.4.x The 1.4.x line replaces the Stainless-generated SDK with an in-house generator. Public method names and call signatures are preserved; the **wire shapes** for errors and list responses changed — see the breaking-change section below. -## Breaking changes +### Breaking changes -### 1. Error envelope +#### Error envelope Every 4xx / 5xx now returns the canonical envelope: @@ -45,7 +50,7 @@ If you wrote error-class checks against the body's `error_type` field, those no longer match — `error_type` was renamed to `type`. Switch the check to `err.type` or the typed-class hierarchy. -### 2. Cursor pagination +#### Cursor pagination List endpoints (`list_memories`, `list_collections`) moved from offset to opaque cursors: @@ -71,14 +76,7 @@ while page.has_more: either drop it or query a dedicated count endpoint (none exists today — file an issue if you need one). -### 3. `applied_wal_seq` on `list_memories` - -For read-your-writes scenarios, `list_memories` now returns an -`applied_wal_seq` field. Pair it with `min_applied_wal_seq` on the request -to assert that a prior write is visible. Default behavior is unchanged for -callers that don't pass `min_applied_wal_seq`. - -## Non-breaking improvements +### Non-breaking improvements - `store_memories` accepts a `max_concurrency` argument (default 8) — bounded worker pool, no more accidental unbounded fan-out under the hood. diff --git a/README.md b/README.md index b32ddb2..a271df8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # nebula-sdk -Official Nebula API SDK for Python. Provides typed async access to the public -Nebula REST API: collections, memories, connectors, snapshots, and system -health. +Official Python SDK for Nebula by Zeroset. Provides typed async access to the +public Nebula REST API: collections, memories, connectors, snapshots, and +system health. ## Install @@ -25,15 +25,20 @@ pip install --pre nebula-sdk ```python import asyncio +import uuid from nebula import Nebula, ClientOptions async def main() -> None: async with Nebula(ClientOptions(api_key="...")) as client: - memory_id = await client.store_memory( - collection_id="01234567-...", - raw_text="hello, world", - ) - results = await client.memories.search(body={"query": "hello"}) + stored = await client.memory.store(body={ + "collection_id": "01234567-...", + "raw_text": "hello, world", + }) + results = await client.memory.search(body={ + "query": "hello", + "retrieval_operation_id": str(uuid.uuid4()), + }) + print(stored) print(results) asyncio.run(main()) @@ -69,7 +74,8 @@ All HTTP errors map to a typed exception hierarchy: ## Docs -- API reference: https://docs.zeroset.com +- Product documentation: https://docs.zeroset.com +- OpenAPI contract: https://api.zeroset.com/openapi.json - Migration notes: see `MIGRATION.md` in the source repo ## License diff --git a/pyproject.toml b/pyproject.toml index e2a0986..9dc9faf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "nebula-sdk" -version = "1.6.2" -description = "Nebula Python SDK (autogenerated)" +version = "0.0.0+from-source" +description = "Official Python SDK for Nebula by Zeroset, the state and memory layer for AI agents." readme = "README.md" license = { text = "MIT" } -authors = [{ name = "Nebula", email = "support@zeroset.com" }] -keywords = ["nebula", "memory", "ai", "sdk", "openapi"] +authors = [{ name = "Zeroset", email = "support@zeroset.com" }] +keywords = ["nebula", "zeroset", "memory", "ai", "agents", "knowledge-graph", "retrieval", "sdk", "openapi"] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -16,11 +16,12 @@ classifiers = [ requires-python = ">=3.11" dependencies = [ "httpx>=0.27", - "pydantic>=2.7", + "pydantic[email]>=2.12", ] [project.urls] -Homepage = "https://github.com/zeroset-inc/nebula-python" +Homepage = "https://docs.zeroset.com/clients/python" +Documentation = "https://docs.zeroset.com" Repository = "https://github.com/zeroset-inc/nebula-python" Issues = "https://github.com/zeroset-inc/nebula-python/issues" @@ -37,7 +38,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/nebula"] -include = ["src/nebula/py.typed"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/nebula/__init__.py b/src/nebula/__init__.py index 873b123..e6e7b55 100644 --- a/src/nebula/__init__.py +++ b/src/nebula/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations from . import _models as models +from . import _inputs as inputs from ._client import NebulaClient from ._runtime import ( ClientOptions, + RequestOptions, RetryPolicy, DEFAULT_RETRY, NebulaError, @@ -20,6 +22,7 @@ NebulaValidationError, NebulaRateLimitError, NebulaServerError, + PublicApiError, ) from ._dx import Nebula @@ -28,9 +31,11 @@ "Nebula", "NebulaClient", "ClientOptions", + "RequestOptions", "RetryPolicy", "DEFAULT_RETRY", "models", + "inputs", "NebulaError", "NebulaAPIError", "NebulaConnectionError", @@ -43,4 +48,5 @@ "NebulaValidationError", "NebulaRateLimitError", "NebulaServerError", + "PublicApiError", ] \ No newline at end of file diff --git a/src/nebula/_client.py b/src/nebula/_client.py index fabb219..e3f5033 100644 --- a/src/nebula/_client.py +++ b/src/nebula/_client.py @@ -8,7 +8,9 @@ from .resources.client import ClientResource from .resources.collections import CollectionsResource from .resources.connectors import ConnectorsResource -from .resources.memories import MemoriesResource +from .resources.enterprise import EnterpriseResource +from .resources.marketplace import MarketplaceResource +from .resources.memory import MemoryResource from .resources.snapshots import SnapshotsResource from .resources.workspaces import WorkspacesResource @@ -19,7 +21,9 @@ def __init__(self, options: Optional[ClientOptions] = None) -> None: self.client: ClientResource = ClientResource(self._core) self.collections: CollectionsResource = CollectionsResource(self._core) self.connectors: ConnectorsResource = ConnectorsResource(self._core) - self.memories: MemoriesResource = MemoriesResource(self._core) + self.enterprise: EnterpriseResource = EnterpriseResource(self._core) + self.marketplace: MarketplaceResource = MarketplaceResource(self._core) + self.memory: MemoryResource = MemoryResource(self._core) self.snapshots: SnapshotsResource = SnapshotsResource(self._core) self.workspaces: WorkspacesResource = WorkspacesResource(self._core) diff --git a/src/nebula/_dx.py b/src/nebula/_dx.py index 982c439..dbd6360 100644 --- a/src/nebula/_dx.py +++ b/src/nebula/_dx.py @@ -1,9 +1,7 @@ # Handwritten Nebula DX layer. # -# Carries only the methods that need real dispatch logic: store_memory's -# create-vs-append branch, bulk store_memories with a concurrency cap, -# positional connector helpers (connect_provider, disconnect), and auth -# normalization. +# Carries only the methods that need real dispatch logic: positional +# connector helpers and auth normalization. # # For everything else, use the resource methods directly. Resource methods # now return unwrapped values natively (the generator peels the @@ -16,7 +14,6 @@ from __future__ import annotations -import asyncio import json from collections.abc import Mapping, Sequence from typing import Any, Optional, cast @@ -53,52 +50,6 @@ def __init__(self, options: Optional[ClientOptions] = None, **compat: Any) -> No # ---- memories ---- - async def store_memory( - self, - memory: Optional[Mapping[str, Any]] = None, - **params: Any, - ) -> Any: - """ - Polymorphic memory creator: dispatches to memories.create or memories.append - based on whether `memory_id` is set on the input. Returns the new memory id - (string), or the updated snapshot envelope when `snapshot` is set. - """ - body = _memory_params(memory, params) - memory_id = _memory_id(body) - - # `memories.create` now returns the unwrapped inner type directly - # (the generator peels the wire `{results: X}` envelope). - if body.get("snapshot") is not None: - result = await self.memories.create(body=cast(Any, _snapshot_create_params(body))) - return _snapshot_result(result) - - if memory_id: - await self.memories.append( - id=str(memory_id), - body=cast(Any, _memory_append_params(body)), - ) - return str(memory_id) - - result = await self.memories.create(body=cast(Any, _memory_create_params(body))) - return _extract_id(result) - - async def store_memories( - self, - memories: Sequence[Mapping[str, Any]], - *, - max_concurrency: int = 8, - **options: Any, - ) -> list[Any]: - semaphore = asyncio.Semaphore(max_concurrency) - - async def worker(memory: Mapping[str, Any]) -> Any: - async with semaphore: - return await self.store_memory(memory, **options) - - return await asyncio.gather(*(worker(memory) for memory in memories)) - - # ---- memories ---- - async def list_memories( self, collection_ids: Optional[str | Sequence[str]] = None, @@ -112,7 +63,7 @@ async def list_memories( params["collection_ids"] = _listify(collection_ids) if isinstance(params.get("metadata_filters"), Mapping): params["metadata_filters"] = json.dumps(params["metadata_filters"]) - return await self.memories.list(**params) + return await self.memory.list(**params) # ---- connectors ---- @@ -121,12 +72,21 @@ async def connect_provider( provider: str, collection_id: str, config: Optional[Mapping[str, Any]] = None, + oauth_client_mode: Optional[str] = None, + oauth_client_id: Optional[str] = None, + oauth_client_secret: Optional[str] = None, ) -> Any: """Custom signature: positional provider + collection_id + optional - config dict, packed into the wire body shape.""" + config/OAuth fields, packed into the wire body shape.""" body: dict[str, Any] = {"collection_id": collection_id} if config is not None: body["config"] = dict(config) + if oauth_client_mode is not None: + body["oauth_client_mode"] = oauth_client_mode + if oauth_client_id is not None: + body["oauth_client_id"] = oauth_client_id + if oauth_client_secret is not None: + body["oauth_client_secret"] = oauth_client_secret return await self.connectors.connect(provider=provider, body=cast(Any, body)) async def disconnect( @@ -149,91 +109,5 @@ def _first_defined(*values: Optional[Any]) -> Optional[Any]: return None -def _memory_params( - memory: Optional[Mapping[str, Any]], params: Mapping[str, Any] -) -> dict[str, Any]: - body: dict[str, Any] = dict(memory or {}) - body.update(params) - if "collectionId" in body and "collection_id" not in body: - body["collection_id"] = body.pop("collectionId") - else: - body.pop("collectionId", None) - if "memoryId" in body and "memory_id" not in body: - body["memory_id"] = body.pop("memoryId") - else: - body.pop("memoryId", None) - content = body.pop("content", None) - if content is not None: - if isinstance(content, str): - body.setdefault("raw_text", content) - else: - body.setdefault("content_parts", content) - if body.get("messages") and not body.get("kind"): - body["kind"] = "conversation" - return body - - -def _memory_id(memory: Mapping[str, Any]) -> Any: - return memory.get("memory_id") or memory.get("memoryId") - - -def _memory_create_params(body: Mapping[str, Any]) -> dict[str, Any]: - params = dict(body) - params.pop("memory_id", None) - return params - - -def _snapshot_create_params(body: Mapping[str, Any]) -> dict[str, Any]: - params: dict[str, Any] = {"snapshot": body["snapshot"]} - if isinstance(body.get("raw_text"), str): - params["raw_text"] = body["raw_text"] - if isinstance(body.get("contents"), list): - params["contents"] = body["contents"] - return params - - -def _memory_append_params(body: Mapping[str, Any]) -> dict[str, Any]: - collection_id = body.get("collection_id") - if not collection_id: - raise ValueError( - "collection_id is required when appending to an existing memory" - ) - params: dict[str, Any] = {"collection_id": collection_id} - for key in ( - "metadata", - "ingestion_config", - "ingestion_mode", - "raw_text", - "chunks", - "messages", - ): - if body.get(key) is not None: - params[key] = body[key] - return params - - -def _extract_value(obj: Any, name: str, default: Any = None) -> Any: - if isinstance(obj, Mapping): - return obj.get(name, default) - return getattr(obj, name, default) - - -def _snapshot_result(value: Any) -> Any: - return _extract_value(value, "snapshot", value) - - -def _extract_id(value: Any) -> str: - mapping = value if isinstance(value, Mapping) else None - for key in ("id", "memory_id", "engram_id", "ephemeral_collection_id"): - attr = getattr(value, key, None) - if isinstance(attr, str): - return attr - if mapping is not None: - mapped = mapping.get(key) - if isinstance(mapped, str): - return mapped - raise RuntimeError("Nebula memory create response did not include an id") - - def _listify(value: str | Sequence[str]) -> list[str]: return [value] if isinstance(value, str) else list(value) diff --git a/src/nebula/_inputs.py b/src/nebula/_inputs.py new file mode 100644 index 0000000..24632c6 --- /dev/null +++ b/src/nebula/_inputs.py @@ -0,0 +1,46 @@ +# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. +# Source: nebula-sdks/openapi/openapi.json + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from pydantic import Field + +from . import _models as models + + +class CopyCollectionInput(models.CopyCollectionRequest): + collection_id: UUID = Field(default_factory=uuid4) + + +class CreateCollectionInput(models.CreateCollectionRequest): + collection_id: UUID = Field(default_factory=uuid4) + + +class EnterpriseProvisioningInput(models.EnterpriseProvisioningRequest): + request_id: UUID = Field(default_factory=uuid4) + + +class ForkMarketplaceCollectionInput(models.ForkMarketplaceCollectionRequest): + collection_id: UUID = Field(default_factory=uuid4) + + +class AppendMemoryInput(models.AppendMemoryRequest): + idempotency_key: UUID = Field(default_factory=uuid4) + + +class DeleteMemoriesInput(models.DeleteMemoriesRequest): + operation_id: UUID = Field(default_factory=uuid4) + + +class StoreMemoryInput(models.StoreMemoryRequest): + memory_id: UUID = Field(default_factory=uuid4) + + +class StoreMemoryBatchItemInput(models.StoreMemoryBatchItem): + memory_id: UUID = Field(default_factory=uuid4) + + +class StoreMemoriesInput(models.StoreMemoriesRequest): + memories: list[StoreMemoryBatchItemInput] diff --git a/src/nebula/_models.py b/src/nebula/_models.py index e6cb41a..d818545 100644 --- a/src/nebula/_models.py +++ b/src/nebula/_models.py @@ -1,29 +1,14 @@ # generated by datamodel-codegen: -# filename: openapi.json +# filename: .python-models-openapi.json from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Any, Literal +from typing import Annotated, Any, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, RootModel - - -class ActivatedEntity(BaseModel): - """ - An entity activated during memory traversal with its profile. - - Represents a conceptual node in memory that was activated by the query. - Contains the full EntityProfile (gestalt) filtered by relevance. - """ - - activation_score: float | None = Field(0.0, title='Activation Score') - category: str | None = Field(None, title='Category') - id: UUID = Field(..., title='Id') - name: str = Field(..., title='Name') - profile: Any = Field(None, title='Profile') +from pydantic import AnyUrl, BaseModel, ConfigDict, EmailStr, Field, RootModel class ActivatedEpisode(BaseModel): @@ -34,16 +19,16 @@ class ActivatedEpisode(BaseModel): during graph traversal. """ - activation_score: float | None = Field(0.0, title='Activation Score') - category: str | None = Field('episodic', title='Category') + activation_score: float = Field(0, title='Activation Score') + category: str = Field('episodic', title='Category') description: str | None = Field(None, title='Description') entity_names: list[str] | None = Field(None, title='Entity Names') evidence_ids: list[UUID] | None = Field(None, title='Evidence Ids') id: UUID = Field(..., title='Id') - is_obligation: bool | None = Field(False, title='Is Obligation') + is_obligation: bool = Field(False, title='Is Obligation') member_semantic_ids: list[UUID] | None = Field(None, title='Member Semantic Ids') modality: str | None = Field(None, title='Modality') - n_facts: int | None = Field(0, title='N Facts') + n_facts: int = Field(0, title='N Facts') name: str = Field(..., title='Name') resolved_at: datetime | None = Field(None, title='Resolved At') status: str | None = Field(None, title='Status') @@ -59,14 +44,14 @@ class ActivatedProcedure(BaseModel): which are descriptive assertions. """ - activation_score: float | None = Field(0.0, title='Activation Score') - belief_kind: str | None = Field(None, title='Belief Kind') - confidence: float | None = Field(0.0, title='Confidence') - derivation_type: str | None = Field('trivial', title='Derivation Type') + activation_score: float = Field(0, title='Activation Score') + confidence: float = Field(0, title='Confidence') + derivation_type: str = Field('trivial', title='Derivation Type') entity_id: UUID | None = Field(None, title='Entity Id') entity_name: str | None = Field(None, title='Entity Name') id: UUID = Field(..., title='Id') - is_negated: bool | None = Field(False, title='Is Negated') + is_negated: bool = Field(False, title='Is Negated') + memory_class: str | None = Field(None, title='Memory Class') metadata: dict[str, Any] | None = Field(None, title='Metadata') stability_confidence: float | None = Field(None, title='Stability Confidence') statement: str = Field(..., title='Statement') @@ -93,7 +78,7 @@ class ActivatedWorkflowStep(BaseModel): how_this_works: str | None = Field(None, title='How This Works') index: int = Field(..., title='Index') object_type: str = Field(..., title='Object Type') - optional: bool | None = Field(False, title='Optional') + optional: bool = Field(False, title='Optional') typical_entities: list[str] | None = Field(None, title='Typical Entities') typical_tools: list[str] | None = Field(None, title='Typical Tools') variable_slots: list[str] | None = Field(None, title='Variable Slots') @@ -149,15 +134,18 @@ class RawText(RootModel[str]): ) -class AppendedMessageResponse(BaseModel): - chunk_ids: list[str] | None = Field(None, title='Chunk Ids') - message_id: str = Field(..., title='Message Id') - - -class BatchDeleteResult(BaseModel): - failed: list[dict[str, Any]] = Field(..., title='Failed') - successful: list[str] = Field(..., title='Successful') - summary: dict[str, Any] = Field(..., title='Summary') +class AwsKmsMemoryEventSignature(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + algorithm: Literal['ECDSA_SHA_256'] = Field(..., title='Algorithm') + canonicalization: Literal['json-sort-keys-v1'] = Field( + ..., title='Canonicalization' + ) + key_id: str = Field(..., min_length=1, title='Key Id') + scheme: Literal['aws-kms-detached'] = Field(..., title='Scheme') + signed_fields: list[str] = Field(..., title='Signed Fields') + value: str = Field(..., min_length=1, title='Value') class BootstrapRecallRequest(BaseModel): @@ -177,40 +165,201 @@ class BootstrapRecallRequest(BaseModel): title='Collection Id', ) intent: Literal['bootstrap'] = Field('bootstrap', title='Intent') - top_k: int | None = Field( + top_k: int = Field( 10, description='Maximum number of pattern hits to return.', title='Top K' ) -class ChunkEnrichmentSettings(BaseModel): - """ - Settings for chunk enrichment. +class CollectionActiveResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + id: UUID = Field(..., title='Id') + state: Literal['active'] = Field(..., title='State') + + +class CollectionDeletedResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + id: UUID = Field(..., title='Id') + state: Literal['deleted'] = Field(..., title='State') - Model selection for the enrichment LLM call lives in - ``app.task_llms.chunk_enrichment``; the legacy ``generation_config`` - field was removed in the per-task LLM cleanup pass. - """ - chunk_enrichment_prompt: str | None = Field( - 'chunk_enrichment', - description='The prompt to use for chunk enrichment', - title='Chunk Enrichment Prompt', +class CollectionDeletingDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - enable_chunk_enrichment: bool | None = Field( - False, - description='Whether to enable chunk enrichment or not', - title='Enable Chunk Enrichment', + collection_id: UUID = Field(..., title='Collection Id') + state: Literal['deleting'] = Field('deleting', title='State') + + +class CollectionDeletingPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['collection_deleting'] = Field(..., title='Code') + details: CollectionDeletingDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class CollectionDeletingResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + id: UUID = Field(..., title='Id') + state: Literal['deleting'] = Field(..., title='State') + + +class CollectionFailedDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + state: Literal['failed'] = Field('failed', title='State') + + +class CollectionFailedPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['collection_failed'] = Field(..., title='Code') + details: CollectionFailedDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class CollectionIdentityConflictDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + + +class CollectionIdentityConflictPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['collection_identity_conflict'] = Field(..., title='Code') + details: CollectionIdentityConflictDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class CollectionMutationFailureCode(Enum): + collection_quota_exceeded = 'collection_quota_exceeded' + collection_name_conflict = 'collection_name_conflict' + storage_target_unavailable = 'storage_target_unavailable' + processing_failed = 'processing_failed' + + +class CollectionNameConflictDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + name: str = Field(..., title='Name') + + +class CollectionNameConflictPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['collection_name_conflict'] = Field(..., title='Code') + details: CollectionNameConflictDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class CollectionProvisioningDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + state: Literal['provisioning'] = Field(..., title='State') + + +class CollectionProvisioningPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['collection_provisioning'] = Field(..., title='Code') + details: CollectionProvisioningDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class CollectionProvisioningResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + id: UUID = Field(..., title='Id') + state: Literal['provisioning'] = Field(..., title='State') + + +class CollectionQuotaExceededDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - n_chunks: int | None = Field( - 2, - description='The number of preceding and succeeding chunks to include. Defaults to 2.', - title='N Chunks', + collection_id: UUID = Field(..., title='Collection Id') + quota_limit: int = Field(..., title='Quota Limit') + + +class CollectionQuotaExceededPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + code: Literal['collection_quota_exceeded'] = Field(..., title='Code') + details: CollectionQuotaExceededDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') class CollectionResponse(BaseModel): access_tier: str | None = Field('private', title='Access Tier') cache_policy: str | None = Field('lazy', title='Cache Policy') + can_add_document: bool | None = Field( + None, + description='Whether the authenticated caller can add collection content.', + title='Can Add Document', + ) + can_delete: bool | None = Field( + None, + description='Whether the authenticated caller can delete the collection.', + title='Can Delete', + ) + can_edit: bool | None = Field( + None, + description='Whether the authenticated caller can edit collection settings.', + title='Can Edit', + ) + can_fork: bool = Field(False, title='Can Fork') + can_manage_access: bool | None = Field( + None, + description='Whether the authenticated caller can manage collection grants.', + title='Can Manage Access', + ) + can_publish: bool | None = Field( + None, + description='Whether the authenticated caller can publish the collection beyond private access.', + title='Can Publish', + ) + can_remove_document: bool | None = Field( + None, + description='Whether the authenticated caller can remove collection content.', + title='Can Remove Document', + ) chain_type: str | None = Field(None, title='Chain Type') contract_address: str | None = Field(None, title='Contract Address') created_at: datetime = Field(..., title='Created At') @@ -219,9 +368,15 @@ class CollectionResponse(BaseModel): engram_count: int = Field(..., title='Engram Count') graph_collection_status: str = Field(..., title='Graph Collection Status') graph_sync_status: str = Field(..., title='Graph Sync Status') + group_id: UUID | None = Field( + None, + description='Team workspace group that owns this collection.', + title='Group Id', + ) has_preview_access: bool | None = Field(None, title='Has Preview Access') id: UUID = Field(..., title='Id') is_forked: bool | None = Field(None, title='Is Forked') + is_marketplace_listed: bool = Field(False, title='Is Marketplace Listed') marketplace_metadata: dict[str, Any] | None = Field( None, title='Marketplace Metadata' ) @@ -237,7 +392,7 @@ class CollectionResponse(BaseModel): storage_target_id: UUID | None = Field(None, title='Storage Target Id') updated_at: datetime = Field(..., title='Updated At') user_count: int = Field(..., title='User Count') - workflows_enabled: bool | None = Field(False, title='Workflows Enabled') + workflows_enabled: bool = Field(False, title='Workflows Enabled') workspace_id: UUID | None = Field(None, title='Workspace Id') @@ -249,14 +404,48 @@ class CompactMemoryRecallResponse(BaseModel): episodic: list[dict[str, Any]] | None = Field(None, title='Episodic') procedural: list[dict[str, Any]] | None = Field(None, title='Procedural') query: str = Field(..., title='Query') + retrieval_id: UUID | None = Field( + None, + description='Stable identifier for retrieval audit and source resolution.', + title='Retrieval Id', + ) semantic: list[dict[str, Any]] | None = Field(None, title='Semantic') - sources: list[dict[str, Any]] | None = Field(None, title='Sources') - token_count: int | None = Field(0, title='Token Count') + sources: list[dict[str, Any]] | None = Field( + None, + description='Present only when include_sources is true; an empty list means sources were requested but none were found.', + title='Sources', + ) + token_count: int = Field(0, title='Token Count') + + +class CompleteMultipartUploadResponse(BaseModel): + byte_size: int = Field(..., title='Byte Size') + content_type: str = Field(..., title='Content Type') + raw_sha256: str = Field(..., title='Raw Sha256') + upload_session_id: UUID = Field(..., title='Upload Session Id') + + +class CompletedMultipartUploadPart(BaseModel): + checksum_sha256: str = Field( + ..., + description='Base64-encoded SHA-256 checksum returned for the part.', + title='Checksum Sha256', + ) + etag: str = Field(..., min_length=1, title='Etag') + part_number: int = Field(..., ge=1, le=10000, title='Part Number') class ConnectRequest(BaseModel): collection_id: UUID = Field(..., title='Collection Id') config: dict[str, Any] | None = Field(None, title='Config') + oauth_client_id: str | None = Field(None, title='Oauth Client Id') + oauth_client_mode: str | None = Field(None, title='Oauth Client Mode') + oauth_client_secret: str | None = Field(None, title='Oauth Client Secret') + + +class ConnectorConfigUpdateResponse(BaseModel): + message: str = Field(..., title='Message') + status: Literal['queued'] = Field(..., title='Status') class ConnectorConnectResponse(BaseModel): @@ -269,6 +458,13 @@ class Health(Enum): error = 'error' +class OauthCredentialSource(Enum): + default = 'default' + workspace_app = 'workspace_app' + hosted_m365 = 'hosted_m365' + unbound = 'unbound' + + class Status(Enum): active = 'active' pending = 'pending' @@ -280,6 +476,56 @@ class ConnectorErrorDetail(BaseModel): retryable: bool = Field(..., title='Retryable') +class CredentialSource(Enum): + workspace_app = 'workspace_app' + hosted_m365 = 'hosted_m365' + + +class ProviderFamily(Enum): + google = 'google' + m365 = 'm365' + + +class ConnectorOAuthAppResponse(BaseModel): + can_manage: bool = Field(False, title='Can Manage') + configured: bool = Field(..., title='Configured') + credential_source: CredentialSource | None = Field(None, title='Credential Source') + gmail_pubsub_audience: str | None = Field(None, title='Gmail Pubsub Audience') + gmail_pubsub_push_service_account: str | None = Field( + None, title='Gmail Pubsub Push Service Account' + ) + gmail_pubsub_topic: str | None = Field(None, title='Gmail Pubsub Topic') + gmail_webhook_url: str | None = Field(None, title='Gmail Webhook Url') + oauth_client_id: str | None = Field(None, title='Oauth Client Id') + provider_family: ProviderFamily = Field(..., title='Provider Family') + updated_at: datetime | None = Field(None, title='Updated At') + + +class GmailPubsubAudience(RootModel[str]): + root: str = Field(..., max_length=512, title='Gmail Pubsub Audience') + + +class GmailPubsubPushServiceAccount(RootModel[str]): + root: str = Field(..., max_length=320, title='Gmail Pubsub Push Service Account') + + +class GmailPubsubTopic(RootModel[str]): + root: str = Field(..., max_length=512, title='Gmail Pubsub Topic') + + +class ConnectorOAuthAppUpdateRequest(BaseModel): + collection_id: UUID = Field(..., title='Collection Id') + gmail_pubsub_audience: GmailPubsubAudience | None = Field( + None, title='Gmail Pubsub Audience' + ) + gmail_pubsub_push_service_account: GmailPubsubPushServiceAccount | None = Field( + None, title='Gmail Pubsub Push Service Account' + ) + gmail_pubsub_topic: GmailPubsubTopic | None = Field( + None, title='Gmail Pubsub Topic' + ) + + class ConnectorSyncResponse(BaseModel): message: str = Field(..., title='Message') @@ -289,79 +535,122 @@ class ConnectorWarning(BaseModel): message: str = Field(..., title='Message') -class ConversationFields(BaseModel): - """ - Conversation-specific typed fields. +class State(Enum): + blocked = 'blocked' + approved = 'approved' - Present iff ``Engram.kind == CONVERSATION``. Holds the platform-written - fields that previously lived on ``metadata`` (``conversation_id``, - ``episode_type``) so they have a typed home and are not co-mingled with - user-supplied metadata. - """ - conversation_id: UUID | None = Field(None, title='Conversation Id') - episode_type: str | None = Field(None, title='Episode Type') +class ConnectorWithdrawalPlanResponse(BaseModel): + active_item_count: int = Field(..., title='Active Item Count') + approved_at: datetime | None = Field(None, title='Approved At') + approved_by: UUID | None = Field(None, title='Approved By') + automatic_allowance: int = Field(..., title='Automatic Allowance') + candidate_count: int = Field(..., title='Candidate Count') + candidate_digest: str = Field(..., title='Candidate Digest') + created_at: datetime = Field(..., title='Created At') + evidence_counts: dict[str, int] = Field(..., title='Evidence Counts') + sample_external_ids: list[str] = Field(..., title='Sample External Ids') + state: State = Field(..., title='State') + updated_at: datetime = Field(..., title='Updated At') -class CreateCollectionRequest(BaseModel): - description: str | None = Field(None, title='Description') - name: str = Field(..., min_length=1, pattern='[\\s\\S]*\\S[\\s\\S]*', title='Name') - storage_target_id: UUID | None = Field( - None, - description="BYOC storage target to host this collection's graph data. Requires a team workspace and an active target provisioned through the dashboard or workspace storage-target APIs.", - title='Storage Target Id', +class ContextKind(Enum): + collection_default = 'collection_default' + portable_hash = 'portable_hash' + + +class ContributionCanonicalKey(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - workflows_enabled: bool | None = Field(False, title='Workflows Enabled') - workspace_id: UUID | None = Field( - None, - description='Workspace this collection belongs to. Provisioned via the dashboard / management API, not minted through the public SDK.', - title='Workspace Id', + category: str | None = Field(None, title='Category') + context_hash: str = Field(..., title='Context Hash') + context_kind: ContextKind = Field(..., title='Context Kind') + normalized_name: str = Field(..., title='Normalized Name') + + +class ContributionEmbeddingValue(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + present: bool = Field(..., title='Present') + raw_f32_le_b64: str = Field(..., title='Raw F32 Le B64') -class Chunks1Item(Chunk): - pass +class ContributionEntityPayload(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + category: str | None = Field(None, title='Category') + chunk_ids: list[str] | None = Field(None, title='Chunk Ids') + collection_id: str = Field(..., title='Collection Id') + created_at: str | None = Field(None, title='Created At') + description: str | None = Field(None, title='Description') + engram_id: str | None = Field(None, title='Engram Id') + fts_terms: dict[str, int] | None = Field(None, title='Fts Terms') + generation: int = Field(..., title='Generation') + id: str = Field(..., title='Id') + metadata: dict[str, Any] | None = Field(None, title='Metadata') + name: str = Field(..., title='Name') + relationship_count: int = Field(..., title='Relationship Count') + updated_at: str | None = Field(None, title='Updated At') -class Chunks1(RootModel[list[Chunks1Item]]): - root: list[Chunks1Item] = Field( - ..., - description='Pre-chunked text for document kind', - min_length=1, - title='Chunks', +class ContributionRelationshipPayload(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + category: str | None = Field(None, title='Category') + chunk_ids: list[str] | None = Field(None, title='Chunk Ids') + collection_id: str = Field(..., title='Collection Id') + created_at: str | None = Field(None, title='Created At') + description: str | None = Field(None, title='Description') + description_embedding_hash_b64: str | None = Field( + None, title='Description Embedding Hash B64' + ) + engram_id: str | None = Field(None, title='Engram Id') + generation: int = Field(..., title='Generation') + id: str = Field(..., title='Id') + id_discriminator: str | None = Field(None, title='Id Discriminator') + inference_metadata: dict[str, Any] | None = Field(None, title='Inference Metadata') + is_negated: bool = Field(..., title='Is Negated') + metadata: dict[str, Any] | None = Field(None, title='Metadata') + object: str | None = Field(None, title='Object') + object_id: str = Field(..., title='Object Id') + predicate: str = Field(..., title='Predicate') + relation_embedding_hash_b64: str | None = Field( + None, title='Relation Embedding Hash B64' ) + subject: str | None = Field(None, title='Subject') + subject_id: str = Field(..., title='Subject Id') + updated_at: str | None = Field(None, title='Updated At') + valid_span: dict[str, Any] | None = Field(None, title='Valid Span') + weight: float | None = Field(None, title='Weight') -class Content4(Chunk): - pass +class ConversationFields(BaseModel): + """ + Conversation-specific typed fields. + Present iff ``Engram.kind == CONVERSATION``. Holds the platform-written + fields that previously lived on ``metadata`` (``conversation_id``, + ``episode_type``) so they have a typed home and are not co-mingled with + user-supplied metadata. + """ -class Contents(RootModel[list[Content4]]): - root: list[Content4] = Field( - ..., - description='Batch content strings for snapshot mode', - min_length=1, - title='Contents', - ) + conversation_id: UUID | None = Field(None, title='Conversation Id') + episode_type: str | None = Field(None, title='Episode Type') -class RawText1(RootModel[str]): +class Name(RootModel[str]): root: str = Field( - ..., - description='Raw text content for document kind', - min_length=1, - pattern='[\\s\\S]*\\S[\\s\\S]*', - title='Raw Text', + ..., max_length=256, min_length=1, pattern='[\\s\\S]*\\S[\\s\\S]*', title='Name' ) -class CursorRecallRequest(BaseModel): +class CopyCollectionRequest(BaseModel): """ - Locate where the caller is in known workflows. - - Matches the caller's current state against pattern canonical - instances' entry states; returns ranked pattern hits with - pattern-side position metadata. + Target-owned request for a durable collection copy. """ model_config = ConfigDict( @@ -369,16 +658,90 @@ class CursorRecallRequest(BaseModel): ) collection_id: UUID = Field( ..., - description='Collection containing the patterns to recall.', + description='Stable collection identity generated automatically by Nebula SDKs.', title='Collection Id', ) - intent: Literal['cursor'] = Field('cursor', title='Intent') - state_id: UUID | None = Field( + description: str | None = Field(None, title='Description') + name: Name | None = Field(None, title='Name') + + +class CreateCollectionRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field( + ..., + description='Stable collection identity generated automatically by Nebula SDKs.', + title='Collection Id', + ) + description: str | None = Field(None, title='Description') + group_id: UUID | None = Field( + None, + description="Team workspace group that owns this collection. Omit to use the workspace's General group. Personal collections cannot set this field.", + title='Group Id', + ) + name: str = Field( + ..., max_length=256, min_length=1, pattern='[\\s\\S]*\\S[\\s\\S]*', title='Name' + ) + storage_target_id: UUID | None = Field( + None, + description="BYOC storage target to host this collection's graph data. Requires a team workspace and an active target provisioned through the dashboard or workspace storage-target APIs.", + title='Storage Target Id', + ) + workflows_enabled: bool = Field(False, title='Workflows Enabled') + workspace_id: UUID | None = Field( + None, + description='Workspace this collection belongs to. Provisioned via the dashboard / management API, not minted through the public SDK.', + title='Workspace Id', + ) + + +class CrossRegionBatchDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collections_by_region: dict[str, list[str]] = Field( + ..., title='Collections By Region' + ) + home_regions: list[str] = Field(..., title='Home Regions') + subject: str = Field(..., title='Subject') + + +class CrossRegionBatchPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['cross_region_batch'] = Field(..., title='Code') + details: CrossRegionBatchDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['validation_error'] = Field(..., title='Type') + + +class CursorRecallRequest(BaseModel): + """ + Locate where the caller is in known workflows. + + Matches the caller's current state against pattern canonical + instances' entry states; returns ranked pattern hits with + pattern-side position metadata. + """ + + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field( + ..., + description='Collection containing the patterns to recall.', + title='Collection Id', + ) + intent: Literal['cursor'] = Field('cursor', title='Intent') + state_id: UUID | None = Field( None, description="Anchor: the user's current state nodegroup id.", title='State Id', ) - top_k: int | None = Field( + top_k: int = Field( 10, description='Maximum number of pattern hits to return.', title='Top K' ) trace_id: UUID | None = Field( @@ -388,8 +751,41 @@ class CursorRecallRequest(BaseModel): ) -class DeleteMemoriesRequest(RootModel[UUID | list[UUID]]): - root: UUID | list[UUID] = Field(..., title='DeleteMemoriesRequest') +class DeleteMemoriesRequest(BaseModel): + collection_id: UUID = Field( + ..., + description='Collection context for the memory deletion.', + title='Collection Id', + ) + ids: UUID | list[UUID] = Field( + ..., + description='Single engram ID or list of engram IDs to delete.', + title='Ids', + ) + operation_id: UUID = Field( + ..., + description='Stable identifier for retrying this deletion.', + title='Operation Id', + ) + + +class DerivationEdgeRecord(BaseModel): + """ + One active source-scoped derivation edge in portable graph authority. + """ + + collection_id: str = Field(..., title='Collection Id') + created_at: str = Field(..., title='Created At') + derivation_type: str = Field(..., title='Derivation Type') + edge_confidence: float = Field(..., title='Edge Confidence') + engram_id: UUID | None = Field(None, title='Engram Id') + generation: int = Field(..., title='Generation') + id: str = Field(..., title='Id') + metadata: dict[str, Any] | None = Field(None, title='Metadata') + source_id: str = Field(..., title='Source Id') + source_kind: str = Field(..., title='Source Kind') + target_id: str = Field(..., title='Target Id') + target_kind: str = Field(..., title='Target Kind') class DocumentType(Enum): @@ -434,15 +830,39 @@ class DocumentType(Enum): css = 'css' +class Ed25519MemoryEventSignature(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + algorithm: Literal['Ed25519'] = Field(..., title='Algorithm') + canonicalization: Literal['json-sort-keys-v1'] = Field( + ..., title='Canonicalization' + ) + key_id: str = Field(..., min_length=1, title='Key Id') + scheme: Literal['eddsa-detached'] = Field(..., title='Scheme') + signed_fields: list[str] = Field(..., title='Signed Fields') + value: str = Field(..., min_length=1, title='Value') + + class EmbeddingBlock(BaseModel): """ A positionally-aligned masked embedding matrix. """ - dim: int | None = Field(0, title='Dim') - encoding: Literal['npy-base64'] = Field('npy-base64', title='Encoding') - mask_b64: str | None = Field('', title='Mask B64') - values_b64: str | None = Field('', title='Values B64') + dim: int = Field(0, title='Dim') + encoding: Literal['raw-f32-base64'] = Field('raw-f32-base64', title='Encoding') + mask_b64: str = Field('', title='Mask B64') + values_b64: str = Field('', title='Values B64') + + +class EngramContributionAttribution(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + engram_id: str = Field(..., title='Engram Id') + kind: Literal['engram'] = Field(..., title='Kind') + source_id: str = Field(..., title='Source Id') + source_kind: str = Field(..., title='Source Kind') class EngramKind(Enum): @@ -460,6 +880,58 @@ class EngramKind(Enum): conversation = 'conversation' +class State1(Enum): + provisioning = 'provisioning' + active = 'active' + failed = 'failed' + + +class EnterpriseProvisioningCollection(BaseModel): + failure: dict[str, Any] | None = Field(None, title='Failure') + id: UUID = Field(..., title='Id') + state: State1 = Field(..., title='State') + + +class EnterpriseProvisioningRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + api_key_name: str = Field( + 'enterprise-onboarding', max_length=128, min_length=1, title='Api Key Name' + ) + collection_description: str | None = Field(None, title='Collection Description') + collection_name: str = Field( + ..., + max_length=256, + min_length=1, + pattern='[\\s\\S]*\\S[\\s\\S]*', + title='Collection Name', + ) + email: EmailStr = Field(..., title='Email') + name: str | None = Field(None, title='Name') + request_id: UUID = Field( + ..., + description='Stable idempotency identity for this provisioning request.', + title='Request Id', + ) + + +class EnterpriseProvisioningResult(BaseModel): + api_key: str | None = Field( + ..., + description='The API key secret. Returned only during the short idempotency replay window; store it when first received.', + title='Api Key', + ) + api_key_available: bool = Field(..., title='Api Key Available') + api_key_expires_at: datetime = Field(..., title='Api Key Expires At') + api_key_id: UUID = Field(..., title='Api Key Id') + collection: EnterpriseProvisioningCollection + email: EmailStr = Field(..., title='Email') + request_id: UUID = Field(..., title='Request Id') + state: State1 = Field(..., title='State') + user_id: UUID = Field(..., title='User Id') + + class EntityRecord(BaseModel): """ Canonical entity record used in snapshots, WAL ops, and segments. @@ -467,16 +939,17 @@ class EntityRecord(BaseModel): category: str | None = Field(None, title='Category') chunk_ids: list[str] | None = Field(None, title='Chunk Ids') - collection_id: str | None = Field('', title='Collection Id') - created_at: datetime = Field(..., title='Created At') + collection_id: str = Field(..., title='Collection Id') + created_at: str | None = Field(None, title='Created At') description: str | None = Field(None, title='Description') - engram_id: str = Field(..., title='Engram Id') + engram_id: UUID | None = Field(None, title='Engram Id') fts_terms: dict[str, int] | None = Field(None, title='Fts Terms') + generation: int = Field(..., title='Generation') id: str = Field(..., title='Id') metadata: dict[str, Any] | None = Field(None, title='Metadata') name: str = Field(..., title='Name') - relationship_count: int | None = Field(0, title='Relationship Count') - updated_at: datetime = Field(..., title='Updated At') + relationship_count: int = Field(0, title='Relationship Count') + updated_at: str | None = Field(None, title='Updated At') class Error(BaseModel): @@ -518,7 +991,7 @@ class EvidenceRecallRequest(BaseModel): pattern_id: UUID = Field( ..., description='The pattern entity to hydrate.', title='Pattern Id' ) - top_k: int | None = Field( + top_k: int = Field( 10, description='Maximum number of pattern hits to return.', title='Top K' ) @@ -551,7 +1024,7 @@ class FileContentRequest(BaseModel): filename: str | None = Field( None, description='Original filename', title='Filename' ) - media_type: str | None = Field( + media_type: str = Field( 'application/octet-stream', description='MIME type', title='Media Type' ) type: Literal['audio', 'document', 'file', 'image'] = Field( @@ -559,8 +1032,46 @@ class FileContentRequest(BaseModel): ) -class GenericBooleanResponse(BaseModel): - success: bool = Field(..., title='Success') +class FileReferenceRequest(BaseModel): + """ + Reference to a file uploaded through an upload session. + """ + + model_config = ConfigDict( + extra='forbid', + ) + type: Literal['file_ref'] = Field('file_ref', title='Type') + upload_session_id: UUID = Field( + ..., + description='Upload session returned by memory.createUpload.', + title='Upload Session Id', + ) + + +class ForbiddenPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['forbidden'] = Field(..., title='Code') + details: None = Field(..., title='Details') + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['forbidden'] = Field(..., title='Type') + + +class ForkMarketplaceCollectionRequest(BaseModel): + """ + Target-owned request for a durable marketplace fork. + """ + + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field( + ..., + description='Stable collection identity generated automatically by Nebula SDKs.', + title='Collection Id', + ) class GenericMessageResponse(BaseModel): @@ -569,15 +1080,65 @@ class GenericMessageResponse(BaseModel): message: str = Field(..., title='Message') -class GraphExtractionStatus(Enum): - """ - Status of graph creation per document. - """ +class GroupCreateRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: str = Field(..., title='Name') + parent_group_id: UUID = Field(..., title='Parent Group Id') - pending = 'pending' - processing = 'processing' - success = 'success' - failed = 'failed' + +class GroupMembershipDeltaRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + add_user_ids: list[UUID] | None = Field(None, max_length=1000, title='Add User Ids') + remove_user_ids: list[UUID] | None = Field( + None, max_length=1000, title='Remove User Ids' + ) + + +class GroupResponse(BaseModel): + created_at: datetime = Field(..., title='Created At') + external_group_id: str | None = Field(None, title='External Group Id') + id: UUID = Field(..., title='Id') + name: str = Field(..., title='Name') + parent_group_id: UUID | None = Field(None, title='Parent Group Id') + updated_at: datetime = Field(..., title='Updated At') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class GroupResult(BaseModel): + group: GroupResponse + + +class GroupUpdateRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + name: str = Field(..., title='Name') + + +class HostedM365ConsentStartRequest(BaseModel): + tenant_id: UUID = Field(..., title='Tenant Id') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class HostedM365ConsentStartResponse(BaseModel): + admin_consent_url: str = Field(..., title='Admin Consent Url') + + +class HostedM365ConsentStatusResponse(BaseModel): + configured: bool = Field(..., title='Configured') + consented_at: datetime | None = Field(None, title='Consented At') + enabled: bool = Field(..., title='Enabled') + tenant_id: UUID | None = Field(None, title='Tenant Id') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class HostedM365TenantDisconnectResponse(BaseModel): + disconnected: bool = Field(..., title='Disconnected') + workspace_id: UUID = Field(..., title='Workspace Id') class InferenceHint(BaseModel): @@ -590,7 +1151,7 @@ class InferenceHint(BaseModel): confidence: float | None = Field(None, title='Confidence') inference_metadata: dict[str, Any] | None = Field(None, title='Inference Metadata') - inferred: bool | None = Field(False, title='Inferred') + inferred: bool = Field(False, title='Inferred') ledger_p_stable: float | None = Field(None, title='Ledger P Stable') ledger_p_true: float | None = Field(None, title='Ledger P True') ledger_p_use: float | None = Field(None, title='Ledger P Use') @@ -601,8 +1162,8 @@ class InferenceHint(BaseModel): relationship_id: UUID | None = Field(None, title='Relationship Id') subject_id: UUID | None = Field(None, title='Subject Id') term: str = Field(..., title='Term') - usable_for_rewrite: bool | None = Field(False, title='Usable For Rewrite') - used_for_rewrite: bool | None = Field(False, title='Used For Rewrite') + usable_for_rewrite: bool = Field(False, title='Usable For Rewrite') + used_for_rewrite: bool = Field(False, title='Used For Rewrite') class IngestionConfig(BaseModel): @@ -617,134 +1178,304 @@ class IngestionConfig(BaseModel): audio_transcription_model: str | None = Field( None, title='Audio Transcription Model' ) - automatic_extraction: bool | None = Field(False, title='Automatic Extraction') - chunk_enrichment_settings: ChunkEnrichmentSettings | None = None - chunk_overlap: int | None = Field(512, title='Chunk Overlap') - chunk_size: int | None = Field(1024, title='Chunk Size') - chunking_strategy: str | None = Field('recursive', title='Chunking Strategy') + automatic_extraction: bool = Field(False, title='Automatic Extraction') + chunk_overlap: int = Field(512, title='Chunk Overlap') + chunk_size: int = Field(1024, title='Chunk Size') + chunking_strategy: str = Field('recursive', title='Chunking Strategy') excluded_parsers: list[str] | None = Field(None, title='Excluded Parsers') extra_parsers: dict[str, Any] | None = Field(None, title='Extra Parsers') - max_concurrent_vlm_tasks: int | None = Field(5, title='Max Concurrent Vlm Tasks') + max_concurrent_vlm_tasks: int = Field(5, title='Max Concurrent Vlm Tasks') parser_overrides: dict[str, str] | None = Field(None, title='Parser Overrides') - provider: str | None = Field('nebula', title='Provider') + provider: str = Field('nebula', title='Provider') vlm: str | None = Field(None, title='Vlm') - vlm_batch_size: int | None = Field(5, title='Vlm Batch Size') - vlm_max_tokens_to_sample: int | None = Field(1024, title='Vlm Max Tokens To Sample') - vlm_ocr_one_page_per_chunk: bool | None = Field( - True, title='Vlm Ocr One Page Per Chunk' - ) + vlm_batch_size: int = Field(5, title='Vlm Batch Size') + vlm_max_tokens_to_sample: int = Field(1024, title='Vlm Max Tokens To Sample') + vlm_ocr_one_page_per_chunk: bool = Field(True, title='Vlm Ocr One Page Per Chunk') + +class MemoryDeletingResult(CollectionDeletingResult): + pass -class IngestionMode(Enum): - hi_res = 'hi-res' - ocr = 'ocr' - fast = 'fast' - custom = 'custom' +class MemoryDeletionFailureCode(Enum): + not_found_or_forbidden = 'not_found_or_forbidden' -class IngestionResponse(BaseModel): - engram_id: UUID = Field( - ..., description='The ID of the engram that was ingested.', title='Engram Id' - ) - message: str = Field( - ..., - description='A message describing the result of the ingestion request.', - title='Message', - ) - task_id: UUID | None = Field( - None, description='The task ID of the ingestion request.', title='Task Id' + +class MemoryEventCursor(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + seq: int = Field(..., ge=0, title='Seq') + stream_id: UUID = Field(..., title='Stream Id') -class IngestionStatus(Enum): - """ - Status of document processing. - """ +class MemoryEventSignature( + RootModel[Ed25519MemoryEventSignature | AwsKmsMemoryEventSignature] +): + root: Ed25519MemoryEventSignature | AwsKmsMemoryEventSignature = Field( + ..., + description='Strict detached-signature union kept as a named OpenAPI component.', + title='MemoryEventSignature', + ) - pending = 'pending' - parsing = 'parsing' - extracting = 'extracting' - chunking = 'chunking' - embedding = 'embedding' - augmenting = 'augmenting' - storing = 'storing' - failed = 'failed' - success = 'success' +class MemoryEventTicketResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + cursor: MemoryEventCursor + expires_at: datetime = Field(..., title='Expires At') + ticket: str = Field(..., title='Ticket') + websocket_url: AnyUrl = Field(..., title='Websocket Url') -class Status1(Enum): - parsing = 'parsing' - processing = 'processing' - queued = 'queued' +class MemoryEventTicketWrite(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: UUID = Field(..., description='Memory ID.', title='Id') + memory_write_id: UUID = Field( + ..., + description='Opaque identity of the admitted memory write.', + title='Memory Write Id', + ) -class MemoryCreateAcceptedResponse(BaseModel): - """ - Accepted-response envelope for async memory ingestion. - """ - applied_wal_seq: int | None = Field( - 0, - description="WAL committed sequence number from this placeholder write, for read-your-writes assertions on the next collection-scoped list call. A non-zero value indicates the request appended a WAL entry; pass it back as `min_applied_wal_seq` on GET /v1/memories to wait for the entry's visibility before serving. Zero on idempotent observe-existing replays and on multi-shard collections (per-shard scalars are not comparable across shards) — clients should treat zero as 'no assertion to make.' Single-shard collections only.", - title='Applied Wal Seq', +class MemoryIdentityConflictPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - engram_id: UUID | None = Field(None, title='Engram Id') - id: UUID = Field(..., title='Id') - memory_id: UUID | None = Field(None, title='Memory Id') + code: Literal['memory_identity_conflict'] = Field(..., title='Code') + details: None = Field(..., title='Details') message: str = Field(..., title='Message') - status: Status1 | None = Field(None, title='Status') - task_id: str | None = Field(None, title='Task Id') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') -class Query(RootModel[str]): - root: str = Field( - ..., - description='Natural-language search query. Mutually exclusive with ``nql``.', - max_length=4096, - title='Query', +class MemoryLifecycleAckFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + action: Literal['ack'] = Field(..., title='Action') + seq: int = Field(..., ge=0, title='Seq') -class MessageType(Enum): - system = 'system' - user = 'user' - assistant = 'assistant' - function = 'function' - tool = 'tool' +class MemoryLifecycleAckedFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + action: Literal['acked'] = Field(..., title='Action') + seq: int = Field(..., ge=0, title='Seq') -class PaginatedCollectionResponse(BaseModel): - """ - Cursor-paginated list of CollectionResponse entries. The wire envelope is ``{data, next_cursor, has_more}``. - """ +class Reason(Enum): + access_revoked = 'access_revoked' + scope_unavailable = 'scope_unavailable' + internal_error = 'internal_error' + invalid_json = 'invalid_json' + invalid_message = 'invalid_message' + invalid_ack = 'invalid_ack' + unknown_action = 'unknown_action' - data: list[CollectionResponse] - has_more: bool = Field( - ..., description='Whether another page is available after this one.' - ) - next_cursor: str | None = Field( - None, - description='Opaque cursor for the next page. ``null`` when the caller has reached the end.', + +class MemoryLifecycleErrorFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + action: Literal['error'] = Field(..., title='Action') + reason: Reason = Field(..., title='Reason') -class PredictRecallRequest(BaseModel): - """ - Cursor-style match plus the predicted next trace per pattern. +class MemoryLifecycleHeartbeatAckFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + action: Literal['heartbeat_ack'] = Field(..., title='Action') - The "predicted next" is the canonical instance's first trace, - surfaced verbatim. This is correct for the matched-at-entry case - -- recall matches when the user's state aligns with a pattern's - entry state, so the canonical instance's first trace IS the next - step. Mid-instance prediction (advance past offset 1) is NOT - modelled in this version; callers that need it should follow up - on the returned pattern with a dedicated trace-chain walk. - """ +class MemoryLifecycleHeartbeatFrame(BaseModel): model_config = ConfigDict( extra='forbid', ) - collection_id: UUID = Field( + action: Literal['heartbeat'] = Field(..., title='Action') + + +class MemoryLifecycleReadyFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + action: Literal['ready'] = Field(..., title='Action') + seq: int = Field(..., ge=0, title='Seq') + + +class Reason1(Enum): + retention_gap = 'retention_gap' + cursor_ahead = 'cursor_ahead' + region_rehomed = 'region_rehomed' + + +class Seq(RootModel[int]): + root: int = Field(..., ge=0, title='Seq') + + +class WebsocketUrl(RootModel[AnyUrl]): + root: AnyUrl = Field(..., title='Websocket Url') + + +class MemoryLifecycleResetFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + action: Literal['reset'] = Field(..., title='Action') + reason: Reason1 = Field(..., title='Reason') + seq: Seq | None = Field(None, title='Seq') + websocket_url: WebsocketUrl | None = Field(None, title='Websocket Url') + + +class Query(RootModel[str]): + root: str = Field( + ..., + description='Natural-language search query. Mutually exclusive with ``nql``.', + max_length=4096, + title='Query', + ) + + +class MemoryStatusWaitFor(Enum): + searchable = 'searchable' + fully_processed = 'fully_processed' + + +class MemoryStatusWaitResult(Enum): + settled = 'settled' + pending = 'pending' + timed_out = 'timed_out' + + +class MemoryStatusWrite(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: UUID = Field(..., title='Id') + memory_write_id: UUID = Field(..., title='Memory Write Id') + + +class MemoryStoreFailureCode(Enum): + invalid_memory = 'invalid_memory' + processing_failed = 'processing_failed' + collection_deleting = 'collection_deleting' + + +class MemoryWriteConflictPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['memory_write_conflict'] = Field(..., title='Code') + details: None = Field(..., title='Details') + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class MemoryWriteMismatchPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['memory_write_mismatch'] = Field(..., title='Code') + details: None = Field(..., title='Details') + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class MemoryWriteOutcome(Enum): + processing = 'processing' + succeeded = 'succeeded' + failed = 'failed' + cancelled = 'cancelled' + replaced = 'replaced' + + +class MetadataProjectionOverride(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + clear: bool = Field(False, title='Clear') + merge: dict[str, Any] | None = Field(None, title='Merge') + remove_keys: list[str] | None = Field(None, title='Remove Keys') + + +class MultipartUploadPartResponse(BaseModel): + expires_in: int = Field(..., title='Expires In') + part_number: int = Field(..., title='Part Number') + upload_headers: dict[str, str] = Field(..., title='Upload Headers') + upload_url: str = Field(..., title='Upload Url') + + +class MultipartUploadSessionResponse(BaseModel): + expires_in: int = Field(..., title='Expires In') + max_size: int = Field(..., title='Max Size') + part_size: int = Field( + ..., description='Recommended upload part size in bytes.', title='Part Size' + ) + upload_session_id: UUID = Field( + ..., + description='Upload session ID to reference in memory creation.', + title='Upload Session Id', + ) + + +class PaginatedCollectionResponse(BaseModel): + """ + Cursor-paginated list of CollectionResponse entries. The wire envelope is ``{data, next_cursor, has_more}``. + """ + + data: list[CollectionResponse] + has_more: bool = Field( + ..., + description='Whether another page is available after this one.', + examples=[True], + ) + next_cursor: str | None = Field( + ..., + description='Opaque cursor for the next page. ``null`` when the caller has reached the end.', + ) + + +class PaginatedGroupResponse(BaseModel): + """ + Cursor-paginated list of GroupResponse entries. The wire envelope is ``{data, next_cursor, has_more}``. + """ + + data: list[GroupResponse] + has_more: bool = Field( + ..., + description='Whether another page is available after this one.', + examples=[True], + ) + next_cursor: str | None = Field( + ..., + description='Opaque cursor for the next page. ``null`` when the caller has reached the end.', + ) + + +class PredictRecallRequest(BaseModel): + """ + Cursor-style match plus the predicted next trace per pattern. + + The "predicted next" is the canonical instance's first trace, + surfaced verbatim. This is correct for the matched-at-entry case + -- recall matches when the user's state aligns with a pattern's + entry state, so the canonical instance's first trace IS the next + step. Mid-instance prediction (advance past offset 1) is NOT + modelled in this version; callers that need it should follow up + on the returned pattern with a dedicated trace-chain walk. + """ + + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field( ..., description='Collection containing the patterns to recall.', title='Collection Id', @@ -755,7 +1486,7 @@ class PredictRecallRequest(BaseModel): description="Anchor: the user's current state nodegroup id.", title='State Id', ) - top_k: int | None = Field( + top_k: int = Field( 10, description='Maximum number of pattern hits to return.', title='Top K' ) trace_id: UUID | None = Field( @@ -765,14 +1496,64 @@ class PredictRecallRequest(BaseModel): ) -class PresignedUploadResponse(BaseModel): - bucket: str = Field(..., title='Bucket') - download_url: str = Field(..., title='Download Url') - expires_in: int = Field(..., title='Expires In') - max_size: int = Field(..., title='Max Size') - s3_key: str = Field(..., title='S3 Key') - upload_headers: dict[str, str] = Field(..., title='Upload Headers') - upload_url: str = Field(..., title='Upload Url') +class Op(Enum): + clear = 'clear' + set = 'set' + + +class ProjectionDoubleValue(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + op: Op = Field(..., title='Op') + value: float | None = Field(None, title='Value') + + +class ProjectionJsonValue(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + op: Op = Field(..., title='Op') + value: Any = Field(None, title='Value') + + +class ProjectionStringValue(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + op: Op = Field(..., title='Op') + value: str | None = Field(None, title='Value') + + +class RecalledTableColumn(BaseModel): + """ + One authoritative column exposed with a recalled table artifact. + """ + + inferred_type: str = Field(..., title='Inferred Type') + name: str = Field(..., title='Name') + + +class Op3(Enum): + eq = 'eq' + ne = 'ne' + lt = 'lt' + le = 'le' + gt = 'gt' + ge = 'ge' + in_ = 'in' + is_null = 'is_null' + not_null = 'not_null' + + +class RecalledTableQueryFilter(BaseModel): + """ + One validated predicate selected from the user's retrieval query. + """ + + column: str = Field(..., title='Column') + op: Op3 = Field(..., title='Op') + value: Any | None = Field(None, title='Value') class RelationshipRecord(BaseModel): @@ -782,25 +1563,45 @@ class RelationshipRecord(BaseModel): category: str | None = Field(None, title='Category') chunk_ids: list[str] | None = Field(None, title='Chunk Ids') - collection_id: str | None = Field('', title='Collection Id') - created_at: datetime = Field(..., title='Created At') + collection_id: str = Field(..., title='Collection Id') + created_at: str | None = Field(None, title='Created At') description: str | None = Field(None, title='Description') - engram_id: str | None = Field(None, title='Engram Id') + description_embedding_hash_b64: str | None = Field( + None, title='Description Embedding Hash B64' + ) + engram_id: UUID | None = Field(None, title='Engram Id') + generation: int = Field(..., title='Generation') id: str = Field(..., title='Id') + id_discriminator: str | None = Field(None, title='Id Discriminator') inference_metadata: dict[str, Any] | None = Field(None, title='Inference Metadata') + is_negated: bool = Field(False, title='Is Negated') metadata: dict[str, Any] | None = Field(None, title='Metadata') object: str | None = Field(None, title='Object') object_id: str = Field(..., title='Object Id') - predicate: str | None = Field('', title='Predicate') + predicate: str = Field('', title='Predicate') + relation_embedding_hash_b64: str | None = Field( + None, title='Relation Embedding Hash B64' + ) relationship_type: str | None = Field(None, title='Relationship Type') subject: str | None = Field(None, title='Subject') subject_id: str = Field(..., title='Subject Id') temporal_precision: str | None = Field(None, title='Temporal Precision') - updated_at: datetime = Field(..., title='Updated At') + updated_at: str | None = Field(None, title='Updated At') valid_span: dict[str, Any] | None = Field(None, title='Valid Span') weight: float | None = Field(None, title='Weight') +class ResourceNotFoundPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['resource_not_found'] = Field(..., title='Code') + details: None = Field(..., title='Details') + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['not_found'] = Field(..., title='Type') + + class ResumeRecallRequest(BaseModel): """ Cursor-style match biased toward deeper / further-along instances. @@ -820,7 +1621,7 @@ class ResumeRecallRequest(BaseModel): description="Anchor: the user's current state nodegroup id.", title='State Id', ) - top_k: int | None = Field( + top_k: int = Field( 10, description='Maximum number of pattern hits to return.', title='Top K' ) trace_id: UUID | None = Field( @@ -830,25 +1631,29 @@ class ResumeRecallRequest(BaseModel): ) -class S3FileReferenceRequest(BaseModel): - """ - Reference to a file uploaded to S3 (for large files). - """ +class MemoryKind(Enum): + semantic = 'semantic' + episodic = 'episodic' + procedural = 'procedural' - bucket: str | None = Field( - None, description='S3 bucket (uses default if not specified)', title='Bucket' - ) - filename: str | None = Field( - None, description='Original filename', title='Filename' - ) - media_type: str | None = Field( - 'application/octet-stream', description='MIME type', title='Media Type' + +class Status1(Enum): + complete = 'complete' + partial = 'partial' + + +class RetryAfterDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - s3_key: str = Field(..., description='S3 object key', title='S3 Key') - size_bytes: int | None = Field( - None, description='File size in bytes', title='Size Bytes' + retry_after_seconds: int = Field(..., title='Retry After Seconds') + + +class RootingSourceMode(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - type: Literal['s3_ref'] = Field('s3_ref', title='Type') + kind: Literal['rooting_source'] = Field(..., title='Kind') class SearchEffort(Enum): @@ -875,11 +1680,11 @@ class SearchSettings(BaseModel): Memory search uses `effort` (auto/low/medium/high) to control compute. """ - effort: SearchEffort | None = Field( - 'auto', + effort: SearchEffort = Field( + SearchEffort.auto, description='Compute effort budget (auto/low/medium/high). Controls traversal compute for memory search, not MemoryRecall size.', ) - enable_conceptual_expansion: bool | None = Field( + enable_conceptual_expansion: bool = Field( False, description='Enable conceptual expansion for cross-domain discovery through overlapping concepts', title='Enable Conceptual Expansion', @@ -889,7 +1694,7 @@ class SearchSettings(BaseModel): description='Internal: Filters populated by the API router', title='Filters', ) - fulltext_weight: float | None = Field( + fulltext_weight: float = Field( 0.2, description='Weight for fulltext search in hybrid mode (0-1). Set to 0 for pure semantic search.', ge=0.0, @@ -901,30 +1706,41 @@ class SearchSettings(BaseModel): description='Internal: Graph traversal settings (bfs_max_depth, semantic_threshold, etc.)', title='Graph Settings', ) - has_pruning_gate: bool | None = Field( + has_pruning_gate: bool = Field( False, description='Internal: Set by select_search_filters when an owner_id $in partition-pruning wrapper has been added around the filter tree. Used by the in-memory graph read engine to strip the Postgres-only wrapper before evaluating delegation.', title='Has Pruning Gate', ) - include_scores: bool | None = Field( + include_scores: bool = Field( True, description='Whether to include search score values in the search results', title='Include Scores', ) - semantic_weight: float | None = Field( + semantic_weight: float = Field( 0.8, description='Weight for semantic search in hybrid mode (0-1). Set to 0 for pure fulltext search.', ge=0.0, le=1.0, title='Semantic Weight', ) - verbose: bool | None = Field( + verbose: bool = Field( False, description='Include full internal metadata, UUIDs, and confidence fields in MemoryRecall responses. When False, returns compact LLM-optimized format.', title='Verbose', ) +class ServiceUnavailablePublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['service_unavailable'] = Field(..., title='Code') + details: RetryAfterDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['service_unavailable'] = Field(..., title='Type') + + class SnapshotImportResult(BaseModel): """ Ephemeral collection handle returned after importing a snapshot. @@ -986,6 +1802,56 @@ class SnapshotSearchResult(BaseModel): ) +class SourceContributionAttribution(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + kind: Literal['source'] = Field(..., title='Kind') + source_id: str = Field(..., title='Source Id') + source_kind: str = Field(..., title='Source Kind') + + +class SourceProvenance(BaseModel): + name: str | None = Field( + None, + description='Human-readable source title, such as a filename or subject.', + title='Name', + ) + provider: str | None = Field( + None, + description="Connector/provider identifier, such as 'gmail' or 'google_drive'.", + title='Provider', + ) + provider_object_id: str | None = Field( + None, + description='Provider-native id for the source item.', + title='Provider Object Id', + ) + provider_parent_id: str | None = Field( + None, + description='Provider-native id of the parent source item.', + title='Provider Parent Id', + ) + provider_thread_id: str | None = Field( + None, + description='Provider-native id for the thread/conversation.', + title='Provider Thread Id', + ) + type: str | None = Field( + None, + description="Human-scale source class, such as 'file', 'email', or 'chat'.", + title='Type', + ) + uri: str | None = Field( + None, description='Stable non-URL source identifier.', title='Uri' + ) + url: str | None = Field( + None, + description='Provider URL for the source object when available.', + title='Url', + ) + + class StorageTargetCreateRequest(BaseModel): """ Hosted SaaS storage target registration. Use AWS S3 and omit custom endpoint configuration. @@ -994,7 +1860,7 @@ class StorageTargetCreateRequest(BaseModel): bucket: str = Field(..., max_length=255, min_length=1, title='Bucket') kms_key_id: str | None = Field(None, max_length=2048, title='Kms Key Id') name: str = Field(..., max_length=128, min_length=1, title='Name') - prefix: str | None = Field('', max_length=1024, title='Prefix') + prefix: str = Field('', max_length=1024, title='Prefix') region: str = Field(..., max_length=64, title='Region') role_arn: str = Field( ..., @@ -1004,6 +1870,25 @@ class StorageTargetCreateRequest(BaseModel): ) +class StorageTargetPlanRequiredDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class StorageTargetPlanRequiredPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['storage_target_plan_required'] = Field(..., title='Code') + details: StorageTargetPlanRequiredDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['forbidden'] = Field(..., title='Type') + + class Status2(Enum): pending = 'pending' active = 'active' @@ -1029,44 +1914,129 @@ class StorageTargetResponse(BaseModel): workspace_id: UUID = Field(..., title='Workspace Id') -class TextContentRequest(BaseModel): - """ - Text content block. - """ - - text: str = Field( - ..., - description='Text content', - min_length=1, - pattern='[\\s\\S]*\\S[\\s\\S]*', - title='Text', +class StorageTargetUnavailableDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - type: Literal['text'] = Field('text', title='Type') + collection_id: UUID = Field(..., title='Collection Id') + storage_target_id: UUID = Field(..., title='Storage Target Id') -class Name(RootModel[str]): - root: str = Field(..., min_length=1, pattern='[\\s\\S]*\\S[\\s\\S]*', title='Name') +class StorageTargetUnavailablePublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['storage_target_unavailable'] = Field(..., title='Code') + details: StorageTargetUnavailableDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') -class UpdateCollectionRequest(BaseModel): - access_tier: str | None = Field(None, title='Access Tier') - description: str | None = Field(None, title='Description') - generate_description: bool | None = Field(False, title='Generate Description') - name: Name | None = Field(None, title='Name') - workflows_enabled: bool | None = Field(None, title='Workflows Enabled') +class Chunks1Item(Chunk): + pass -class UpdateMemoryRequest(BaseModel): - collection_ids: list[UUID] | None = Field( - None, description='New collection associations', title='Collection Ids' +class Chunks1(RootModel[list[Chunks1Item]]): + root: list[Chunks1Item] = Field( + ..., + description='Pre-chunked text for a document memory.', + min_length=1, + title='Chunks', ) - merge_metadata: bool | None = Field( + + +class RawText1(RootModel[str]): + root: str = Field( + ..., + description='Raw text for a document memory.', + min_length=1, + pattern='[\\s\\S]*\\S[\\s\\S]*', + title='Raw Text', + ) + + +class Chunks2Item(Chunk): + pass + + +class Chunks2(RootModel[list[Chunks2Item]]): + root: list[Chunks2Item] = Field( + ..., + description='Pre-chunked text for a document memory.', + min_length=1, + title='Chunks', + ) + + +class StringListProjectionOverride(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + add: list[str] | None = Field(None, title='Add') + remove: list[str] | None = Field(None, title='Remove') + replace: list[str] | None = Field(None, title='Replace') + + +class TextContentRequest(BaseModel): + """ + Text content block. + """ + + text: str = Field( + ..., + description='Text content', + min_length=1, + pattern='[\\s\\S]*\\S[\\s\\S]*', + title='Text', + ) + type: Literal['text'] = Field('text', title='Type') + + +class AccessTier(Enum): + private = 'private' + public_preview = 'public_preview' + marketplace = 'marketplace' + + +class UpdateCollectionRequest(BaseModel): + access_tier: AccessTier | None = Field(None, title='Access Tier') + description: str | None = Field(None, title='Description') + generate_description: bool = Field(False, title='Generate Description') + name: Name | None = Field(None, title='Name') + workflows_enabled: bool | None = Field(None, title='Workflows Enabled') + + +class UpdateConfigRequest(BaseModel): + apply: Literal['full_resync'] = Field('full_resync', title='Apply') + config: dict[str, Any] = Field(..., title='Config') + + +class UpdateMemoryRequest(BaseModel): + collection_ids: list[UUID] | None = Field( + None, description='New collection associations', title='Collection Ids' + ) + merge_metadata: bool = Field( False, description='Merge with existing metadata', title='Merge Metadata' ) metadata: dict[str, Any] | None = Field( None, description='Metadata to update', title='Metadata' ) name: str | None = Field(None, description='New name for the memory', title='Name') + operation_id: UUID = Field( + ..., + description='Stable identifier for this update. Reuse it when retrying the same operation.', + title='Operation Id', + ) + + +class ValidationIssue(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: str = Field(..., title='Code') + message: str = Field(..., title='Message') + path: list[str | int] = Field(..., title='Path') class WorkflowRecallResponse(BaseModel): @@ -1095,6 +2065,53 @@ class WorkflowRecallResponse(BaseModel): ) +class WorkspaceEncryptionStatus(Enum): + pending = 'pending' + provisioning = 'provisioning' + activating = 'activating' + activation_failed = 'activation_failed' + active = 'active' + disabling = 'disabling' + disable_failed = 'disable_failed' + validation_failed = 'validation_failed' + disabled = 'disabled' + + +class WorkspaceMutationCompletedResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + member_id: UUID | None = Field(..., title='Member Id') + state: Literal['completed'] = Field(..., title='State') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class WorkspaceMutationConflictPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['workspace_mutation_conflict'] = Field(..., title='Code') + details: None = Field(..., title='Details') + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['conflict'] = Field(..., title='Type') + + +class WorkspaceMutationFailureCode(Enum): + processing_failed = 'processing_failed' + + +class WorkspaceMutationProcessingResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: None = Field(..., title='Failure') + member_id: UUID | None = Field(..., title='Member Id') + state: Literal['processing'] = Field(..., title='State') + workspace_id: UUID = Field(..., title='Workspace Id') + + class WrappedCollectionResponse(BaseModel): results: CollectionResponse @@ -1103,24 +2120,56 @@ class WrappedCompactMemoryRecallResponse(BaseModel): results: CompactMemoryRecallResponse +class WrappedCompleteMultipartUploadResponse(BaseModel): + results: CompleteMultipartUploadResponse + + +class WrappedConnectorConfigUpdateResponse(BaseModel): + results: ConnectorConfigUpdateResponse + + class WrappedConnectorConnectResponse(BaseModel): results: ConnectorConnectResponse +class WrappedConnectorOAuthAppResponse(BaseModel): + results: ConnectorOAuthAppResponse + + class WrappedConnectorSyncResponse(BaseModel): results: ConnectorSyncResponse -class WrappedGenericBooleanResponse(BaseModel): - results: GenericBooleanResponse +class WrappedConnectorWithdrawalPlanResponseOrNoneType(BaseModel): + results: ConnectorWithdrawalPlanResponse | None = None + + +class WrappedEnterpriseProvisioningResult(BaseModel): + results: EnterpriseProvisioningResult class WrappedGenericMessageResponse(BaseModel): results: GenericMessageResponse -class WrappedIngestionResponse(BaseModel): - results: IngestionResponse +class WrappedGroupResult(BaseModel): + results: GroupResult + + +class WrappedHostedM365ConsentStartResponse(BaseModel): + results: HostedM365ConsentStartResponse + + +class WrappedHostedM365ConsentStatusResponse(BaseModel): + results: HostedM365ConsentStatusResponse + + +class WrappedHostedM365TenantDisconnectResponse(BaseModel): + results: HostedM365TenantDisconnectResponse + + +class WrappedListOfConnectorOAuthAppResponse(BaseModel): + results: list[ConnectorOAuthAppResponse] = Field(..., title='Results') class WrappedListOfStorageTargetResponse(BaseModel): @@ -1131,12 +2180,19 @@ class WrappedListOfStr(BaseModel): results: list[str] = Field(..., title='Results') -class WrappedMemoryCreateAcceptedResponse(BaseModel): - results: MemoryCreateAcceptedResponse +class WrappedMemoryEventTicketResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + results: MemoryEventTicketResult + + +class WrappedMultipartUploadPartResponse(BaseModel): + results: MultipartUploadPartResponse -class WrappedPresignedUploadResponse(BaseModel): - results: PresignedUploadResponse +class WrappedMultipartUploadSessionResponse(BaseModel): + results: MultipartUploadSessionResponse class WrappedSnapshotImportResult(BaseModel): @@ -1151,6 +2207,29 @@ class WrappedStorageTargetResponse(BaseModel): results: StorageTargetResponse +class WrongWriteRegionDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + error_type: Literal['wrong_write_region'] = Field(..., title='Error Type') + home_region: str = Field(..., title='Home Region') + local_region: str = Field(..., title='Local Region') + region_owner_epoch: int = Field(..., title='Region Owner Epoch') + subject: str = Field(..., title='Subject') + subject_id: UUID = Field(..., title='Subject Id') + + +class WrongWriteRegionPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['wrong_write_region'] = Field(..., title='Code') + details: WrongWriteRegionDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['misdirected_request'] = Field(..., title='Type') + + class ActivatedWorkflow(BaseModel): """ A workflow template activated during memory traversal. @@ -1163,28 +2242,28 @@ class ActivatedWorkflow(BaseModel): which answers "what are my preferences about X". """ - activation_score: float | None = Field(0.0, title='Activation Score') - active_instance_count: int | None = Field(0, title='Active Instance Count') + activation_score: float = Field(0, title='Activation Score') + active_instance_count: int = Field(0, title='Active Instance Count') backbone_signature_hash: str | None = Field(None, title='Backbone Signature Hash') branches: list[dict[str, Any]] | None = Field(None, title='Branches') - confidence: float | None = Field(0.0, title='Confidence') + confidence: float = Field(0, title='Confidence') current_step_index: int | None = Field(None, title='Current Step Index') goal: str = Field(..., title='Goal') id: UUID = Field(..., title='Id') - instance_count: int | None = Field(0, title='Instance Count') + instance_count: int = Field(0, title='Instance Count') last_observed_at: str | None = Field(None, title='Last Observed At') metadata: dict[str, Any] | None = Field(None, title='Metadata') name: str = Field(..., title='Name') predicted_next_step: ActivatedWorkflowStep | None = None steps: list[ActivatedWorkflowStep] | None = Field(None, title='Steps') - taxonomy_version: int | None = Field(1, title='Taxonomy Version') + taxonomy_version: int = Field(1, title='Taxonomy Version') variable_slots: dict[str, Any] | None = Field(None, title='Variable Slots') class Content11( - RootModel[TextContentRequest | FileContentRequest | S3FileReferenceRequest] + RootModel[TextContentRequest | FileContentRequest | FileReferenceRequest] ): - root: TextContentRequest | FileContentRequest | S3FileReferenceRequest = Field( + root: TextContentRequest | FileContentRequest | FileReferenceRequest = Field( ..., discriminator='type' ) @@ -1246,12 +2325,14 @@ class AppendMemoryRequest(BaseModel): description='Target collection ID for the appended content.', title='Collection Id', ) + idempotency_key: UUID = Field( + ..., + description='Stable identifier for this append request. Reuse it when retrying the same request; use a new UUID to intentionally append the same content again.', + title='Idempotency Key', + ) ingestion_config: IngestionConfig | None = Field( None, description='Optional ingestion configuration override.' ) - ingestion_mode: IngestionMode | None = Field( - 'custom', description='Ingestion mode for document content.' - ) messages: Messages | None = Field( None, description='Messages to append for conversation memories. Each message has content, role, and optional metadata.', @@ -1269,9 +2350,31 @@ class AppendMemoryRequest(BaseModel): ) -class BatchDeleteResponse(BaseModel): +class CanonicalKeyListProjectionOverride(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + add: list[ContributionCanonicalKey] | None = Field(None, title='Add') + remove: list[ContributionCanonicalKey] | None = Field(None, title='Remove') + replace: list[ContributionCanonicalKey] | None = Field(None, title='Replace') + + +class CollectionMutationFailure(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: CollectionMutationFailureCode message: str = Field(..., title='Message') - results: BatchDeleteResult + + +class CompleteMultipartUploadRequest(BaseModel): + expected_sha256: str = Field( + ..., + description='Full-object SHA-256 hex digest.', + pattern='^[0-9a-f]{64}$', + title='Expected Sha256', + ) + parts: list[CompletedMultipartUploadPart] = Field(..., min_length=1, title='Parts') class ConnectorConnectionResponse(BaseModel): @@ -1282,16 +2385,26 @@ class ConnectorConnectionResponse(BaseModel): external_account_id: str | None = Field(None, title='External Account Id') health: Health | None = Field(None, title='Health') id: UUID = Field(..., title='Id') + is_syncing: bool = Field(False, title='Is Syncing') items_synced: int | None = Field(None, title='Items Synced') last_error: str | None = Field(None, title='Last Error') last_synced_at: datetime | None = Field(None, title='Last Synced At') next_sync_at: datetime | None = Field(None, title='Next Sync At') + oauth_credential_source: OauthCredentialSource = Field( + OauthCredentialSource.default, title='Oauth Credential Source' + ) provider: str = Field(..., title='Provider') status: Status = Field(..., title='Status') sync_cursor: dict[str, Any] | None = Field(None, title='Sync Cursor') token_expires_at: datetime | None = Field(None, title='Token Expires At') updated_at: datetime = Field(..., title='Updated At') user_id: UUID = Field(..., title='User Id') + uses_workspace_oauth_app: bool = Field( + False, + description='Compatibility projection of whether credentials are workspace-bound. Use oauth_credential_source for new integrations.', + deprecated='Compatibility projection of whether credentials are workspace-bound. Use oauth_credential_source for new integrations.', + title='Uses Workspace Oauth App', + ) class ConnectorDisconnectResponse(BaseModel): @@ -1299,6 +2412,14 @@ class ConnectorDisconnectResponse(BaseModel): warnings: list[ConnectorWarning] | None = Field(None, title='Warnings') +class ContributionEmbedding(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + revision: str = Field(..., title='Revision') + value: ContributionEmbeddingValue + + class Content31(Content11): pass @@ -1338,28 +2459,6 @@ class ConversationMessage(BaseModel): ) -class ContentParts1(Content11): - pass - - -class ContentParts(RootModel[list[ContentParts1]]): - root: list[ContentParts1] = Field( - ..., - description='Multimodal content parts (text, images, audio, documents) for document kind.', - min_length=1, - title='Content Parts', - ) - - -class Messages1(RootModel[list[ConversationMessage]]): - root: list[ConversationMessage] = Field( - ..., - description='Messages for conversation kind', - min_length=1, - title='Messages', - ) - - class DocumentFields(BaseModel): """ Document-specific typed fields. @@ -1375,42 +2474,28 @@ class DocumentFields(BaseModel): original_extension: str | None = Field(None, title='Original Extension') -class Engram(BaseModel): - """ - The unified engram model: typed kind + per-kind substructure. - - ``kind`` is the canonical discriminator. The per-kind ``conversation`` - and ``document`` substructures hold typed fields known to the - platform; ``metadata`` is reserved for user-supplied annotations and - must never carry platform-written discriminators or routing markers. +class EntityProjectionOverride(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + canonical_keys: CanonicalKeyListProjectionOverride | None = None + category: ProjectionStringValue | None = None + chunk_ids: StringListProjectionOverride | None = None + created_at: ProjectionStringValue | None = None + description: ProjectionStringValue | None = None + engram_id: ProjectionStringValue | None = None + fts_terms: ProjectionJsonValue | None = None + metadata: MetadataProjectionOverride | None = None + name: ProjectionStringValue | None = None + updated_at: ProjectionStringValue | None = None - Construction enforces shape consistency via a model validator: - ``kind=conversation`` must not carry document fields, ``kind=document`` - must carry a ``DocumentFields`` substructure (``document_type`` is - required), and vice versa. - """ - chunks: list | None = Field(None, title='Chunks') - collection_ids: list[UUID] | None = Field(None, title='Collection Ids') - conversation: ConversationFields | None = None - created_at: datetime | None = Field(None, title='Created At') - document: DocumentFields | None = None - extraction_status: GraphExtractionStatus | None = 'pending' - id: UUID | None = Field(None, title='Id') - ingestion_attempt_number: int | None = Field(None, title='Ingestion Attempt Number') - ingestion_status: IngestionStatus | None = 'pending' - kind: EngramKind - merkle_root: str | None = Field(None, title='Merkle Root') - metadata: dict[str, Any] | None = Field(None, title='Metadata') - owner_id: UUID = Field(..., title='Owner Id') - search_ready_seq: int | None = Field(None, title='Search Ready Seq') - size_in_bytes: int | None = Field(None, title='Size In Bytes') - text: str | None = Field(None, title='Text') - title: str | None = Field(None, title='Title') - total_tokens: int | None = Field(None, title='Total Tokens') - updated_at: datetime | None = Field(None, title='Updated At') - version: str | None = Field(None, title='Version') - workflow_run_id: str | None = Field(None, title='Workflow Run Id') +class EntityProjectionOverrideMode(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + kind: Literal['projection_override'] = Field(..., title='Kind') + projection_override: EntityProjectionOverride class EvidenceRef(BaseModel): @@ -1427,18 +2512,6 @@ class EvidenceRef(BaseModel): table_name: str | None = Field(None, title='Table Name') -class GraphPayload(BaseModel): - """ - A complete graph payload or a context subgraph payload. - """ - - entities: list[EntityRecord] | None = Field(None, title='Entities') - entity_description_embeddings: EmbeddingBlock | None = None - relationship_description_embeddings: EmbeddingBlock | None = None - relationship_relation_embeddings: EmbeddingBlock | None = None - relationships: list[RelationshipRecord] | None = Field(None, title='Relationships') - - class GroundedSource(BaseModel): """ A source that grounds facts in episodic memory. @@ -1451,16 +2524,32 @@ class GroundedSource(BaseModel): modalities) without parsing opaque metadata. """ - activation_score: float | None = Field(0.0, title='Activation Score') + activation_score: float = Field(0, title='Activation Score') display_name: str | None = Field(None, title='Display Name') engram_id: UUID | None = Field(None, title='Engram Id') evidence_ref: EvidenceRef | None = None + filename: str | None = Field( + None, + description='Original source filename when the provider exposes one', + title='Filename', + ) id: UUID = Field(..., title='Id') metadata: dict[str, Any] | None = Field(None, title='Metadata') owner_id: UUID | None = Field(None, title='Owner Id') page_number: int | None = Field(None, title='Page Number') section_path: list[str] | None = Field(None, title='Section Path') + source: SourceProvenance | None = None + source_path: str | None = Field( + None, + description='Provider-relative source path when the provider exposes one', + title='Source Path', + ) source_role: str | None = Field(None, title='Source Role') + source_url: str | None = Field( + None, + description='Provider URL for opening the original source', + title='Source Url', + ) speaker: str | None = Field(None, title='Speaker') speaker_id: UUID | None = Field(None, title='Speaker Id') structure_label: str | None = Field(None, title='Structure Label') @@ -1469,9 +2558,9 @@ class GroundedSource(BaseModel): timestamp: datetime | None = Field(None, title='Timestamp') -class ListedEngram(BaseModel): +class ListedMemoryResponse(BaseModel): """ - Engram as it appears in list responses, with per-row chunk-page metadata appended. + Memory as it appears in list responses, with per-row chunk-page metadata appended. """ chunks: list | None = Field(None, title='Chunks') @@ -1479,19 +2568,15 @@ class ListedEngram(BaseModel): None, description="True when ``chunks`` carries a prefix of the engram's full chunk set; fetch ``/v1/memories/{id}/chunks`` for the rest.", ) + chunks_url: str | None = Field(None, title='Chunks Url') collection_ids: list[UUID] | None = Field(None, title='Collection Ids') conversation: ConversationFields | None = None created_at: datetime | None = Field(None, title='Created At') document: DocumentFields | None = None - extraction_status: GraphExtractionStatus | None = 'pending' - id: UUID | None = Field(None, title='Id') - ingestion_attempt_number: int | None = Field(None, title='Ingestion Attempt Number') - ingestion_status: IngestionStatus | None = 'pending' + id: UUID = Field(..., title='Id') kind: EngramKind - merkle_root: str | None = Field(None, title='Merkle Root') metadata: dict[str, Any] | None = Field(None, title='Metadata') owner_id: UUID = Field(..., title='Owner Id') - search_ready_seq: int | None = Field(None, title='Search Ready Seq') size_in_bytes: int | None = Field(None, title='Size In Bytes') text: str | None = Field(None, title='Text') title: str | None = Field(None, title='Title') @@ -1500,61 +2585,178 @@ class ListedEngram(BaseModel): ) total_tokens: int | None = Field(None, title='Total Tokens') updated_at: datetime | None = Field(None, title='Updated At') - version: str | None = Field(None, title='Version') - workflow_run_id: str | None = Field(None, title='Workflow Run Id') -class Message(BaseModel): - content: Any = Field(None, title='Content') - function_call: dict[str, Any] | None = Field(None, title='Function Call') - image_data: dict[str, str] | None = Field(None, title='Image Data') - image_url: str | None = Field(None, title='Image Url') - metadata: dict[str, Any] | None = Field(None, title='Metadata') - name: str | None = Field(None, title='Name') - role: MessageType | str = Field(..., title='Role') - structured_content: list[dict[str, Any]] | None = Field( - None, title='Structured Content' +class MemoryDeletionFailure(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - tool_call_id: str | None = Field(None, title='Tool Call Id') - tool_calls: list[dict[str, Any]] | None = Field(None, title='Tool Calls') - + code: MemoryDeletionFailureCode + message: str = Field(..., title='Message') -class PaginatedListedEngram(BaseModel): - """ - Cursor-paginated list of ListedEngram entries. The wire envelope is ``{data, next_cursor, has_more}``. - """ - applied_wal_seq: int | None = Field( - 0, - description='Highest WAL committed sequence number reflected in this response. Non-zero only when the request was served via the WAL-tail fast path. Pair with ``min_applied_wal_seq`` on the request for read-your-writes assertions.', +class MemoryEventInboundFrame( + RootModel[MemoryLifecycleAckFrame | MemoryLifecycleHeartbeatFrame] +): + root: MemoryLifecycleAckFrame | MemoryLifecycleHeartbeatFrame = Field( + ..., + description='Strict client-to-server lifecycle WebSocket frame union.', + discriminator='action', + title='MemoryEventInboundFrame', ) - data: list[ListedEngram] - has_more: bool = Field( - ..., description='Whether another page is available after this one.' + + +class MemoryEventTicketRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - next_cursor: str | None = Field( - None, - description='Opaque cursor for the next page. ``null`` when the caller has reached the end.', + collection_id: UUID = Field(..., title='Collection Id') + cursor: MemoryEventCursor | None = None + writes: list[MemoryEventTicketWrite] = Field( + ..., max_length=40, min_length=1, title='Writes' ) -class SnapshotEnvelopeInput(BaseModel): +class MemoryResponse(BaseModel): """ - Portable full snapshot owned by the client. + Stable public representation of a memory. """ - collection_id: UUID = Field(..., title='Collection Id') + model_config = ConfigDict( + extra='forbid', + ) + chunks: list | None = Field(None, title='Chunks') + chunks_truncated: bool | None = Field(None, title='Chunks Truncated') + chunks_url: str | None = Field(None, title='Chunks Url') + collection_ids: list[UUID] | None = Field(None, title='Collection Ids') + conversation: ConversationFields | None = None created_at: datetime | None = Field(None, title='Created At') - format_version: int | None = Field(3, title='Format Version') - generation: int | None = Field(0, title='Generation') - graph: GraphPayload | None = None - root_hash: str = Field(..., title='Root Hash') + document: DocumentFields | None = None + id: UUID = Field(..., title='Id') + kind: EngramKind + metadata: dict[str, Any] | None = Field(None, title='Metadata') + owner_id: UUID = Field(..., title='Owner Id') + size_in_bytes: int | None = Field(None, title='Size In Bytes') + text: str | None = Field(None, title='Text') + title: str | None = Field(None, title='Title') + total_chunks: int | None = Field(None, title='Total Chunks') + total_tokens: int | None = Field(None, title='Total Tokens') + updated_at: datetime | None = Field(None, title='Updated At') -class SnapshotEnvelopeOutput(SnapshotEnvelopeInput): - """ - Portable full snapshot owned by the client. - """ +class MemoryStatusRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + collection_id: UUID = Field(..., title='Collection Id') + wait_for: MemoryStatusWaitFor + wait_seconds: float = Field(0, ge=0.0, le=25.0, title='Wait Seconds') + writes: list[MemoryStatusWrite] = Field( + ..., max_length=40, min_length=1, title='Writes' + ) + + +class MemoryStoreFailure(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: MemoryStoreFailureCode + message: str = Field(..., title='Message') + retryable: bool = Field(..., title='Retryable') + + +class MemoryWriteProjection(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: MemoryStoreFailure | None = None + fully_processed: bool = Field(..., title='Fully Processed') + id: UUID = Field(..., title='Id') + memory_write_id: UUID = Field(..., title='Memory Write Id') + outcome: MemoryWriteOutcome + projection_version: int = Field(..., ge=1, title='Projection Version') + searchable: bool = Field(..., title='Searchable') + + +class PaginatedListedMemoryResponse(BaseModel): + """ + Cursor-paginated list of ListedMemoryResponse entries. The wire envelope is ``{data, next_cursor, has_more}``. + """ + + data: list[ListedMemoryResponse] + has_more: bool = Field( + ..., + description='Whether another page is available after this one.', + examples=[True], + ) + next_cursor: str | None = Field( + ..., + description='Opaque cursor for the next page. ``null`` when the caller has reached the end.', + ) + + +class PublicValidationErrorDetails(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + issues: list[ValidationIssue] = Field(..., title='Issues') + + +class RateLimitedPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['rate_limited'] = Field(..., title='Code') + details: RetryAfterDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['rate_limited'] = Field(..., title='Type') + + +class RecalledTableQuery(BaseModel): + """ + The bounded typed query Nebula executed for a recalled table. + """ + + columns: list[str] | None = Field(None, title='Columns') + filters: list[RecalledTableQueryFilter] | None = Field(None, title='Filters') + limit: int = Field(..., ge=1, le=100, title='Limit') + + +class RecalledTableQueryRow(BaseModel): + """ + One exact table row plus its immutable evidence reference. + """ + + evidence_ref: EvidenceRef + row_index: int = Field(..., ge=0, title='Row Index') + values: dict[str, Any] = Field(..., title='Values') + + +class RelationshipProjectionOverride(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + category: ProjectionStringValue | None = None + chunk_ids: StringListProjectionOverride | None = None + created_at: ProjectionStringValue | None = None + description: ProjectionStringValue | None = None + engram_id: ProjectionStringValue | None = None + inference_metadata: ProjectionJsonValue | None = None + metadata: MetadataProjectionOverride | None = None + object: ProjectionStringValue | None = None + subject: ProjectionStringValue | None = None + updated_at: ProjectionStringValue | None = None + valid_span: ProjectionJsonValue | None = None + weight: ProjectionDoubleValue | None = None + + +class RelationshipProjectionOverrideMode(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + kind: Literal['projection_override'] = Field(..., title='Kind') + projection_override: RelationshipProjectionOverride class SnapshotExportRequest(BaseModel): @@ -1562,18 +2764,179 @@ class SnapshotExportRequest(BaseModel): destination: SnapshotObjectReference | None = None -class SnapshotImportRequest(BaseModel): - snapshot: SnapshotEnvelopeInput | None = None - snapshot_ref: SnapshotObjectReference | None = None +class StoreMemoriesResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + memories: list[MemoryWriteProjection] = Field(..., title='Memories') + + +class ContentParts1(Content11): + pass + + +class ContentParts(RootModel[list[ContentParts1]]): + root: list[ContentParts1] = Field( + ..., + description='Multimodal content for a document memory.', + min_length=1, + title='Content Parts', + ) + + +class Messages1(RootModel[list[ConversationMessage]]): + root: list[ConversationMessage] = Field( + ..., + description='Messages for a conversation memory.', + min_length=1, + title='Messages', + ) -class SnapshotMutationResult(BaseModel): +class StoreMemoryBatchItem(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + chunks: Chunks1 | None = Field( + None, description='Pre-chunked text for a document memory.', title='Chunks' + ) + content_parts: ContentParts | None = Field( + None, + description='Multimodal content for a document memory.', + title='Content Parts', + ) + ingestion_config: IngestionConfig | None = Field( + None, description='Custom document ingestion configuration.' + ) + kind: EngramKind = Field( + EngramKind.document, + description='Memory kind. When omitted, conversation is inferred when messages are present; otherwise document is inferred.', + ) + memory_id: UUID = Field( + ..., + description='Stable memory identity generated automatically by Nebula SDKs.', + title='Memory Id', + ) + messages: Messages1 | None = Field( + None, description='Messages for a conversation memory.', title='Messages' + ) + metadata: dict[str, Any] | None = Field( + None, description='User-supplied memory metadata.', title='Metadata' + ) + name: str | None = Field(None, title='Name') + raw_text: RawText1 | None = Field( + None, description='Raw text for a document memory.', title='Raw Text' + ) + speaker_id: UUID | None = Field(None, title='Speaker Id') + speaker_name: str | None = Field(None, title='Speaker Name') + + +class ContentParts21(Content11): + pass + + +class ContentParts2(RootModel[list[ContentParts21]]): + root: list[ContentParts21] = Field( + ..., + description='Multimodal content for a document memory.', + min_length=1, + title='Content Parts', + ) + + +class StoreMemoryRequest(BaseModel): """ - Updated snapshot returned by snapshot-mode memory writes. + Store one collection-backed memory. + + The memory identity is generated and frozen by Nebula SDKs before the + request is serialized, so transport retries recover the same durable write. """ - snapshot: SnapshotEnvelopeOutput | None = None - snapshot_ref: SnapshotObjectReference | None = None + model_config = ConfigDict( + extra='forbid', + ) + chunks: Chunks2 | None = Field( + None, description='Pre-chunked text for a document memory.', title='Chunks' + ) + collection_id: UUID = Field( + ..., description='Collection that owns the memory.', title='Collection Id' + ) + content_parts: ContentParts2 | None = Field( + None, + description='Multimodal content for a document memory.', + title='Content Parts', + ) + ingestion_config: IngestionConfig | None = Field( + None, description='Custom document ingestion configuration.' + ) + kind: EngramKind = Field( + EngramKind.document, + description='Memory kind. When omitted, conversation is inferred when messages are present; otherwise document is inferred.', + ) + memory_id: UUID = Field( + ..., + description='Stable memory identity generated automatically by Nebula SDKs.', + title='Memory Id', + ) + messages: Messages1 | None = Field( + None, description='Messages for a conversation memory.', title='Messages' + ) + metadata: dict[str, Any] | None = Field( + None, description='User-supplied memory metadata.', title='Metadata' + ) + name: str | None = Field(None, title='Name') + raw_text: RawText1 | None = Field( + None, description='Raw text for a document memory.', title='Raw Text' + ) + speaker_id: UUID | None = Field(None, title='Speaker Id') + speaker_name: str | None = Field(None, title='Speaker Name') + + +class UnavailableRetrievalAuditSource(BaseModel): + """ + Retained evidence reference whose source can no longer be resolved. + """ + + evidence_ref: EvidenceRef + status: Literal['unavailable'] = Field(..., title='Status') + + +class ValidationPublicApiError(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: Literal['validation_error'] = Field(..., title='Code') + details: PublicValidationErrorDetails + message: str = Field(..., title='Message') + request_id: str | None = Field(..., title='Request Id') + type: Literal['validation_error'] = Field(..., title='Type') + + +class WorkspaceEncryptionResponse(BaseModel): + active_propagation_id: UUID | None = Field(None, title='Active Propagation Id') + created_at: datetime = Field(..., title='Created At') + kms_key_id: str | None = Field(None, title='Kms Key Id') + last_validated_at: datetime | None = Field(None, title='Last Validated At') + propagation_completed_items: int | None = Field( + None, title='Propagation Completed Items' + ) + propagation_failed_items: int | None = Field(None, title='Propagation Failed Items') + propagation_last_error: str | None = Field(None, title='Propagation Last Error') + propagation_operation: str | None = Field(None, title='Propagation Operation') + propagation_status: str | None = Field(None, title='Propagation Status') + propagation_total_items: int | None = Field(None, title='Propagation Total Items') + status: WorkspaceEncryptionStatus + updated_at: datetime = Field(..., title='Updated At') + validation_error: str | None = Field(None, title='Validation Error') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class WorkspaceMutationFailure(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + code: WorkspaceMutationFailureCode + message: str = Field(..., title='Message') class WrappedConnectorConnectionResponse(BaseModel): @@ -1584,22 +2947,24 @@ class WrappedConnectorDisconnectResponse(BaseModel): results: ConnectorDisconnectResponse -class WrappedEngram(BaseModel): - results: Engram - - class WrappedListOfConnectorConnectionResponse(BaseModel): results: list[ConnectorConnectionResponse] = Field(..., title='Results') -class WrappedSnapshotEnvelopeOrSnapshotObjectReference(BaseModel): - results: SnapshotEnvelopeOutput | SnapshotObjectReference = Field( - ..., title='Results' - ) +class WrappedMemoryResponse(BaseModel): + results: MemoryResponse + + +class WrappedMemoryWriteProjection(BaseModel): + results: MemoryWriteProjection -class WrappedSnapshotMutationResult(BaseModel): - results: SnapshotMutationResult +class WrappedStoreMemoriesResult(BaseModel): + results: StoreMemoriesResult + + +class WrappedWorkspaceEncryptionResponse(BaseModel): + results: WorkspaceEncryptionResponse class ActivatedSemantic(BaseModel): @@ -1607,22 +2972,23 @@ class ActivatedSemantic(BaseModel): A semantic item activated during memory traversal. Represents a structured assertion (fact, inference, or episodic) that was - found relevant to the query. Links back to its entity and source - utterances for provenance. + found relevant to the query. Identifies the subject graph node the + assertion is about, and links back to its source utterances for + provenance. """ - activation_score: float | None = Field(0.0, title='Activation Score') - belief_kind: str | None = Field(None, title='Belief Kind') - category: str | None = Field('fact', title='Category') - corroboration_count: int | None = Field(1, title='Corroboration Count') + activation_score: float = Field(0, title='Activation Score') + category: str = Field('fact', title='Category') + corroboration_count: int = Field(1, title='Corroboration Count') description: str | None = Field(None, title='Description') entity_id: UUID | None = Field(None, title='Entity Id') entity_name: str | None = Field(None, title='Entity Name') evidence_ids: list[UUID] | None = Field(None, title='Evidence Ids') evidence_refs: list[EvidenceRef] | None = Field(None, title='Evidence Refs') - extraction_confidence: float | None = Field(0.0, title='Extraction Confidence') + extraction_confidence: float = Field(0, title='Extraction Confidence') id: UUID = Field(..., title='Id') is_current: bool | None = Field(None, title='Is Current') + memory_class: str | None = Field(None, title='Memory Class') predicate: str = Field(..., title='Predicate') reasoning: str | None = Field(None, title='Reasoning') resolved_at: datetime | None = Field(None, title='Resolved At') @@ -1636,101 +3002,392 @@ class ActivatedSemantic(BaseModel): value: str = Field(..., title='Value') -class AppendMemoryResponse(BaseModel): - appended_messages: list[AppendedMessageResponse] | None = Field( - None, title='Appended Messages' +class AvailableRetrievalAuditSource(BaseModel): + """ + Hydrated source that remains available to the authorized caller. + """ + + activation_score: float = Field(..., title='Activation Score') + display_name: str | None = Field(None, title='Display Name') + evidence_ref: EvidenceRef + id: UUID = Field(..., title='Id') + speaker: str | None = Field(None, title='Speaker') + status: Literal['available'] = Field(..., title='Status') + supporting_fact_ids: list[UUID] = Field(..., title='Supporting Fact Ids') + text: str = Field(..., title='Text') + timestamp: datetime | None = Field(None, title='Timestamp') + + +class CollectionFailedResult(BaseModel): + model_config = ConfigDict( + extra='forbid', ) + failure: CollectionMutationFailure id: UUID = Field(..., title='Id') - message: Message - metadata: dict[str, Any] | None = Field({}, title='Metadata') + state: Literal['failed'] = Field(..., title='State') -class CreateMemoryRequest(BaseModel): - """ - Request model for creating memories (conversations or documents). +class CollectionMutationResult( + RootModel[ + CollectionProvisioningResult + | CollectionActiveResult + | CollectionDeletingResult + | CollectionDeletedResult + | CollectionFailedResult + ] +): + root: ( + CollectionProvisioningResult + | CollectionActiveResult + | CollectionDeletingResult + | CollectionDeletedResult + | CollectionFailedResult + ) = Field(..., discriminator='state', title='CollectionMutationResult') - The canonical discriminator is ``kind`` (an :class:`EngramKind`). - Typed substructures (``conversation`` / ``document``) live on the - response :class:`Engram` and are populated server-side from inferred - values (``conversation_id`` is derived deterministically; - ``document_type`` and ``original_extension`` are inferred from the - file's content or extension). The request shape carries only - user-controllable inputs; ``metadata`` is reserved for user-supplied - annotations and must not carry platform discriminators or routing - markers. Unknown fields are rejected so a caller cannot accidentally - smuggle server-owned substructure into the request. - """ +class EntityContributionRecord(BaseModel): model_config = ConfigDict( extra='forbid', ) - chunks: Chunks1 | None = Field( - None, description='Pre-chunked text for document kind', title='Chunks' + attribution: EngramContributionAttribution | SourceContributionAttribution = Field( + ..., discriminator='kind', title='Attribution' ) - collection_id: UUID | None = Field( - None, - description='Collection UUID (mutually exclusive with snapshot)', - title='Collection Id', + canonical_keys: list[ContributionCanonicalKey] | None = Field( + None, title='Canonical Keys' ) - content_parts: ContentParts | None = Field( - None, - description='Multimodal content parts (text, images, audio, documents) for document kind.', - title='Content Parts', + collection_id: str = Field(..., title='Collection Id') + description_embedding: ContributionEmbedding | None = None + entity_id: str = Field(..., title='Entity Id') + id: str = Field(..., title='Id') + mode: RootingSourceMode | EntityProjectionOverrideMode = Field( + ..., discriminator='kind', title='Mode' ) - contents: Contents | None = Field( - None, description='Batch content strings for snapshot mode', title='Contents' + payload: ContributionEntityPayload + revision: str = Field(..., title='Revision') + + +class MemoryDeletionFailedResult(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - ingestion_config: IngestionConfig | None = Field( - None, description='Custom ingestion config for documents' + failure: MemoryDeletionFailure + id: UUID = Field(..., title='Id') + state: Literal['failed'] = Field(..., title='State') + + +class MemoryDeletionResult( + RootModel[MemoryDeletingResult | MemoryDeletionFailedResult] +): + root: MemoryDeletingResult | MemoryDeletionFailedResult = Field( + ..., discriminator='state', title='MemoryDeletionResult' ) - ingestion_mode: IngestionMode | None = Field( - 'fast', description='Ingestion mode for documents' + + +class MemoryLifecycleEventData(BaseModel): + """ + The authoritative write projection at the time of publication. + """ + + model_config = ConfigDict( + extra='forbid', ) - kind: EngramKind | None = Field( - 'document', - description='Engram discriminator: ``document`` or ``conversation``. When omitted, ``conversation`` is inferred if ``messages`` is present; otherwise defaults to ``document``.', + failure: MemoryStoreFailure | None = None + fully_processed: bool = Field(..., title='Fully Processed') + id: UUID = Field(..., title='Id') + memory_write_id: UUID = Field(..., title='Memory Write Id') + occurred_at: datetime = Field(..., title='Occurred At') + outcome: MemoryWriteOutcome + projection_version: int = Field(..., ge=1, title='Projection Version') + searchable: bool = Field(..., title='Searchable') + + +class MemoryLifecycleEventFrame(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - messages: Messages1 | None = Field( - None, description='Messages for conversation kind', title='Messages' + action: Literal['event'] = Field(..., title='Action') + data: MemoryLifecycleEventData + event_id: UUID = Field(..., title='Event Id') + seq: int = Field(..., ge=0, title='Seq') + signature: MemoryEventSignature | None = None + type: Literal['memory.lifecycle'] = Field(..., title='Type') + + +class MemoryStatusResponse(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - metadata: dict[str, Any] | None = Field( - None, - description='User-supplied metadata for the memory. Must not carry platform discriminators or routing markers — use the ``kind`` / ``conversation`` / ``document`` fields instead.', - title='Metadata', + memories: list[MemoryWriteProjection] = Field(..., title='Memories') + pending_memory_write_ids: list[UUID] = Field(..., title='Pending Memory Write Ids') + wait_for: MemoryStatusWaitFor + wait_result: MemoryStatusWaitResult + + +class PublicApiError( + RootModel[ + ForbiddenPublicApiError + | ResourceNotFoundPublicApiError + | CollectionProvisioningPublicApiError + | CollectionDeletingPublicApiError + | CollectionFailedPublicApiError + | CollectionIdentityConflictPublicApiError + | MemoryIdentityConflictPublicApiError + | MemoryWriteMismatchPublicApiError + | MemoryWriteConflictPublicApiError + | WorkspaceMutationConflictPublicApiError + | CollectionQuotaExceededPublicApiError + | CollectionNameConflictPublicApiError + | StorageTargetUnavailablePublicApiError + | StorageTargetPlanRequiredPublicApiError + | WrongWriteRegionPublicApiError + | CrossRegionBatchPublicApiError + | ValidationPublicApiError + | RateLimitedPublicApiError + | ServiceUnavailablePublicApiError + ] +): + root: ( + ForbiddenPublicApiError + | ResourceNotFoundPublicApiError + | CollectionProvisioningPublicApiError + | CollectionDeletingPublicApiError + | CollectionFailedPublicApiError + | CollectionIdentityConflictPublicApiError + | MemoryIdentityConflictPublicApiError + | MemoryWriteMismatchPublicApiError + | MemoryWriteConflictPublicApiError + | WorkspaceMutationConflictPublicApiError + | CollectionQuotaExceededPublicApiError + | CollectionNameConflictPublicApiError + | StorageTargetUnavailablePublicApiError + | StorageTargetPlanRequiredPublicApiError + | WrongWriteRegionPublicApiError + | CrossRegionBatchPublicApiError + | ValidationPublicApiError + | RateLimitedPublicApiError + | ServiceUnavailablePublicApiError + ) = Field(..., discriminator='code', title='PublicApiError') + + +class RecalledTableQueryResult(BaseModel): + """ + Exact rows produced by a recalled table's automatically planned query. + """ + + artifact_id: UUID = Field(..., title='Artifact Id') + collection_id: UUID = Field(..., title='Collection Id') + columns: list[str] | None = Field(None, title='Columns') + row_count: int = Field(..., ge=0, title='Row Count') + rows: list[RecalledTableQueryRow] | None = Field(None, title='Rows') + table_name: str = Field(..., title='Table Name') + truncated: bool = Field(False, title='Truncated') + + +class RelationshipContributionRecord(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - name: str | None = Field( - None, description='Optional name for the memory', title='Name' + attribution: EngramContributionAttribution | SourceContributionAttribution = Field( + ..., discriminator='kind', title='Attribution' ) - raw_text: RawText1 | None = Field( - None, description='Raw text content for document kind', title='Raw Text' + collection_id: str = Field(..., title='Collection Id') + description_embedding: ContributionEmbedding | None = None + id: str = Field(..., title='Id') + mode: RootingSourceMode | RelationshipProjectionOverrideMode = Field( + ..., discriminator='kind', title='Mode' ) - snapshot: SnapshotEnvelopeInput | None = Field( - None, - description='Device-memory snapshot (mutually exclusive with collection_id).', + payload: ContributionRelationshipPayload + relation_embedding: ContributionEmbedding | None = None + relationship_id: str = Field(..., title='Relationship Id') + revision: str = Field(..., title='Revision') + + +class RetrievalAuditSourceGroup(BaseModel): + """ + Sources supporting one result returned by the original search. + """ + + activation_score: float = Field(..., title='Activation Score') + memory_id: UUID = Field(..., title='Memory Id') + memory_kind: MemoryKind = Field(..., title='Memory Kind') + rank: int = Field(..., ge=0, title='Rank') + sources: list[Annotated[AvailableRetrievalAuditSource | UnavailableRetrievalAuditSource, Field(discriminator='status')]] = Field(..., title='Sources') + truncated: bool = Field(False, title='Truncated') + + +class RetrievalAuditSourcesResponse(BaseModel): + """ + Authorization-checked sources for a previously audited retrieval. + """ + + groups: list[RetrievalAuditSourceGroup] = Field(..., title='Groups') + retrieval_id: UUID = Field(..., title='Retrieval Id') + status: Status1 = Field(..., title='Status') + + +class StoreMemoriesRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', ) - snapshot_ref: SnapshotObjectReference | None = Field( - None, - description='Device-memory snapshot reference for customer-owned object storage. Mutually exclusive with collection_id and snapshot.', + collection_id: UUID = Field( + ..., description='Collection that owns the memories.', title='Collection Id' ) - speaker_id: UUID | None = Field( - None, - description='UUID of the SourceRole entity creating this memory', - title='Speaker Id', + memories: list[StoreMemoryBatchItem] = Field( + ..., max_length=40, min_length=1, title='Memories' ) - speaker_name: str | None = Field( - None, - description='Display name of the speaker/agent creating this memory', - title='Speaker Name', + + +class WorkspaceMutationFailedResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + failure: WorkspaceMutationFailure + member_id: UUID | None = Field(..., title='Member Id') + state: Literal['failed'] = Field(..., title='State') + workspace_id: UUID = Field(..., title='Workspace Id') + + +class WorkspaceMutationResult( + RootModel[ + WorkspaceMutationProcessingResult + | WorkspaceMutationCompletedResult + | WorkspaceMutationFailedResult + ] +): + root: ( + WorkspaceMutationProcessingResult + | WorkspaceMutationCompletedResult + | WorkspaceMutationFailedResult + ) = Field(..., discriminator='state', title='WorkspaceMutationResult') + + +class WrappedCollectionMutationResult(BaseModel): + results: CollectionMutationResult + + +class WrappedMemoryDeletionResult(BaseModel): + results: MemoryDeletionResult + + +class WrappedMemoryStatusResponse(BaseModel): + results: MemoryStatusResponse + + +class WrappedRetrievalAuditSourcesResponse(BaseModel): + results: RetrievalAuditSourcesResponse + + +class WrappedWorkspaceMutationResult(BaseModel): + results: WorkspaceMutationResult + + +class ContributionPlanes(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + entities: list[EntityContributionRecord] | None = Field(None, title='Entities') + relationships: list[RelationshipContributionRecord] | None = Field( + None, title='Relationships' + ) + + +class DeleteMemoriesResult(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + memories: list[Annotated[MemoryDeletingResult | MemoryDeletionFailedResult, Field(discriminator='state')]] = Field(..., title='Memories') + + +class GraphPayload(BaseModel): + """ + A complete graph payload or a context subgraph payload. + """ + + contributions: ContributionPlanes + derivation_edges: list[DerivationEdgeRecord] | None = Field( + None, title='Derivation Edges' ) + embedding_contract_hash: str = Field(..., title='Embedding Contract Hash') + entities: list[EntityRecord] | None = Field(None, title='Entities') + entity_description_embeddings: EmbeddingBlock | None = None + relationship_description_embeddings: EmbeddingBlock | None = None + relationship_relation_embeddings: EmbeddingBlock | None = None + relationships: list[RelationshipRecord] | None = Field(None, title='Relationships') -class MemoryCreateResponse( - RootModel[WrappedMemoryCreateAcceptedResponse | WrappedSnapshotMutationResult] +class MemoryEventOutboundFrame( + RootModel[ + MemoryLifecycleEventFrame + | MemoryLifecycleReadyFrame + | MemoryLifecycleAckedFrame + | MemoryLifecycleResetFrame + | MemoryLifecycleErrorFrame + | MemoryLifecycleHeartbeatAckFrame + ] ): - root: WrappedMemoryCreateAcceptedResponse | WrappedSnapshotMutationResult = Field( + root: ( + MemoryLifecycleEventFrame + | MemoryLifecycleReadyFrame + | MemoryLifecycleAckedFrame + | MemoryLifecycleResetFrame + | MemoryLifecycleErrorFrame + | MemoryLifecycleHeartbeatAckFrame + ) = Field( ..., - description='Create-memory success response. Standard memory ingestion returns an accepted async-ingestion envelope; snapshot mode returns the updated snapshot synchronously.', - title='MemoryCreateResponse', + description='Strict server-to-client lifecycle WebSocket frame union.', + discriminator='action', + title='MemoryEventOutboundFrame', + ) + + +class RecalledTableArtifact(BaseModel): + """ + A returned table that is immediately usable without another tool call. + """ + + artifact_id: UUID = Field(..., title='Artifact Id') + collection_id: UUID = Field(..., title='Collection Id') + columns: list[RecalledTableColumn] | None = Field(None, title='Columns') + columns_truncated: bool = Field(False, title='Columns Truncated') + preview: str = Field('', title='Preview') + query: RecalledTableQuery + query_result: RecalledTableQueryResult + row_count: int = Field(..., ge=0, title='Row Count') + schema_column_count: int = Field(..., ge=0, title='Schema Column Count') + table_name: str = Field(..., title='Table Name') + + +class SnapshotEnvelopeInput(BaseModel): + """ + Portable full snapshot owned by the client. + """ + + collection_id: UUID = Field(..., title='Collection Id') + created_at: datetime | None = Field(None, title='Created At') + format_version: Literal[7] = Field(7, title='Format Version') + generation: int = Field(0, title='Generation') + graph: GraphPayload + root_hash: str = Field(..., title='Root Hash') + + +class SnapshotEnvelopeOutput(SnapshotEnvelopeInput): + """ + Portable full snapshot owned by the client. + """ + + +class SnapshotImportRequest(BaseModel): + snapshot: SnapshotEnvelopeInput | None = None + snapshot_ref: SnapshotObjectReference | None = None + + +class WrappedDeleteMemoriesResult(BaseModel): + results: DeleteMemoriesResult + + +class WrappedSnapshotEnvelopeOrSnapshotObjectReference(BaseModel): + results: SnapshotEnvelopeOutput | SnapshotObjectReference = Field( + ..., title='Results' ) @@ -1741,27 +3398,36 @@ class MemoryRecall(BaseModel): This is the primary response type for conceptual memory retrieval. It contains all layers of the memory hierarchy: - 1. **Entities (gestalt/schema layer)**: EntityProfiles that represent - the conceptual understanding of activated entities. + 1. **Semantics (semantic layer)**: Structured assertions (facts, + inferences, tasks) scored by relevance and confidence. Each item + identifies the subject graph node it is about. - 2. **Semantics (semantic layer)**: Structured assertions (facts, - inferences, tasks) scored by relevance and confidence. - - 3. **Episodes (temporal clusters)**: Episodic nodegroups that cluster + 2. **Episodes (temporal clusters)**: Episodic nodegroups that cluster temporally related facts and events. - 4. **Sources (episodic layer)**: The raw source material that + 3. **Sources (episodic layer)**: The raw source material that grounds the structured knowledge in actual moments/quotes. """ - entities: list[ActivatedEntity] | None = Field(None, title='Entities') episodic: list[ActivatedEpisode] | None = Field(None, title='Episodic') inference_hints: list[InferenceHint] | None = Field(None, title='Inference Hints') procedural: list[ActivatedProcedure] | None = Field(None, title='Procedural') query: str = Field(..., title='Query') + retrieval_id: UUID | None = Field( + None, + description='Stable identifier for retrieval audit and follow-up source resolution.', + title='Retrieval Id', + ) search_timing_ms: dict[str, float] | None = Field(None, title='Search Timing Ms') semantic: list[ActivatedSemantic] | None = Field(None, title='Semantic') sources: list[GroundedSource] | None = Field(None, title='Sources') + table_artifact_candidate_count: int = Field( + 0, ge=0, title='Table Artifact Candidate Count' + ) + table_artifacts: list[RecalledTableArtifact] | None = Field( + None, title='Table Artifacts' + ) + table_artifacts_truncated: bool = Field(False, title='Table Artifacts Truncated') total_traversal_time_ms: float | None = Field(None, title='Total Traversal Time Ms') workflows: list[ActivatedWorkflow] | None = Field(None, title='Workflows') @@ -1775,6 +3441,9 @@ class MemorySearchRequest(BaseModel): directly for deterministic test/tooling use cases. """ + model_config = ConfigDict( + extra='forbid', + ) collection_ids: list[str] | None = Field( None, description='Optional list of collection UUIDs or names to scope the search.', @@ -1787,6 +3456,11 @@ class MemorySearchRequest(BaseModel): filters: dict[str, Any] | None = Field( None, description='Optional filters to apply to the search.', title='Filters' ) + include_sources: bool = Field( + False, + description='Include the raw source material that grounds the retrieved memory. Sources are omitted by default to keep responses compact.', + title='Include Sources', + ) nql: str | None = Field( None, description='Pre-written NQL script. Executes directly without planner compilation. Mutually exclusive with ``query``.', @@ -1797,6 +3471,11 @@ class MemorySearchRequest(BaseModel): description='Natural-language search query. Mutually exclusive with ``nql``.', title='Query', ) + retrieval_operation_id: UUID | None = Field( + None, + description='Stable identifier for one logical retrieval. Reuse it when retrying the same request so retrieval evidence remains idempotent.', + title='Retrieval Operation Id', + ) search_settings: SearchSettings | None = Field( None, description='Advanced search settings.' ) @@ -1806,10 +3485,11 @@ class MemorySearchRequest(BaseModel): snapshot_ref: SnapshotObjectReference | None = Field( None, description='Device-memory snapshot reference for stateless search.' ) - - -class WrappedAppendMemoryResponse(BaseModel): - results: AppendMemoryResponse + workspace_id: UUID | None = Field( + None, + description="Workspace that owns this search audit and request log. When set, the search is constrained to collections in that workspace. Unspecified searches are attributed to the caller's personal workspace.", + title='Workspace Id', + ) class WrappedMemoryRecall(BaseModel): diff --git a/src/nebula/_runtime/__init__.py b/src/nebula/_runtime/__init__.py index 8ea13f3..abecf2b 100644 --- a/src/nebula/_runtime/__init__.py +++ b/src/nebula/_runtime/__init__.py @@ -1,4 +1,13 @@ -from .client import NebulaCore, ClientOptions, RequestArgs +from .client import ( + NebulaCore, + ClientOptions, + GeneratedBodyField, + MutationReplayIdentity, + RequestArgs, + RequestOptions, + Utf8ByteLimit, + prepare_generated_body, +) from .errors import ( NebulaError, NebulaAPIError, @@ -15,12 +24,19 @@ error_from_response, ) from .retry import RetryPolicy, DEFAULT_RETRY, is_retryable_status, backoff_seconds -from .validation import validate_response +from .public_api_error import PublicApiError +from .validation import validate_request_body, validate_response __all__ = [ "NebulaCore", "ClientOptions", + "GeneratedBodyField", "RequestArgs", + "RequestOptions", + "MutationReplayIdentity", + "Utf8ByteLimit", + "prepare_generated_body", + "validate_request_body", "validate_response", "NebulaError", "NebulaAPIError", @@ -35,6 +51,7 @@ "NebulaRateLimitError", "NebulaServerError", "error_from_response", + "PublicApiError", "RetryPolicy", "DEFAULT_RETRY", "is_retryable_status", diff --git a/src/nebula/_runtime/client.py b/src/nebula/_runtime/client.py index 5e47472..8a783c4 100644 --- a/src/nebula/_runtime/client.py +++ b/src/nebula/_runtime/client.py @@ -1,14 +1,17 @@ from __future__ import annotations import asyncio +import json +import math +import time from dataclasses import dataclass, field -from typing import Any, Mapping, Optional, TypedDict +from typing import Any, Literal, Mapping, NotRequired, Optional, TypedDict +from uuid import uuid4 import httpx from pydantic import BaseModel from .errors import ( - NebulaAPIError, NebulaConnectionError, NebulaTimeoutError, error_from_response, @@ -23,22 +26,24 @@ def _serialize_body(body: Any) -> Any: to serialize `BaseModel` instances. Generated method signatures expose bodies as typed Pydantic models for autocomplete DX, so the runtime is the right layer to dump them. We use `mode='json'` so nested types - like datetimes / UUIDs become wire-ready strings; we do NOT set - `exclude_none=True` because the API may distinguish explicit `null` - from "absent" for optional fields. + like datetimes / UUIDs become wire-ready strings. `exclude_unset=True` + preserves the distinction between omitted fields and explicitly supplied + defaults or nulls. `warnings='none'` silences Pydantic's serializer warnings during dump. - Those warnings fire when a string is stored in a field typed as an - enum (e.g. `ingestion_mode='fast'`), which is currently produced by - datamodel-code-generator's enum-default emission. The wire output is - still correct (Pydantic emits the string verbatim). The root fix - belongs in the generator's dmcg config, not here — see TODO. + Those warnings can fire when generated model defaults are represented + in a shape Pydantic does not expect, while the wire output remains + correct. """ if isinstance(body, BaseModel): - return body.model_dump(mode="json", by_alias=True, warnings="none") + return body.model_dump( + mode="json", by_alias=True, exclude_unset=True, warnings="none" + ) if isinstance(body, list): return [ - item.model_dump(mode="json", by_alias=True, warnings="none") + item.model_dump( + mode="json", by_alias=True, exclude_unset=True, warnings="none" + ) if isinstance(item, BaseModel) else item for item in body @@ -62,14 +67,121 @@ class ClientOptions: transport: Optional[httpx.AsyncBaseTransport] = None +@dataclass(frozen=True) +class RequestOptions: + """Per-call controls applied to one complete invocation.""" + + timeout_seconds: Optional[float] = None + + +class MutationReplayIdentity(TypedDict): + source: Literal["body", "header", "intrinsic"] + name: str + generated: NotRequired[Literal["uuid"]] + + +class GeneratedBodyField(TypedDict): + path: list[str] + kind: Literal["uuid"] + + +class Utf8ByteLimit(TypedDict): + source: Literal["body", "header"] + path: list[str] + maximum: int + + class RequestArgs(TypedDict, total=False): method: str path: str path_params: Mapping[str, Any] query: Mapping[str, Any] body: Any - headers: Mapping[str, str] - idempotent: bool + headers: Mapping[str, Optional[str]] + routing: Mapping[str, Any] + retryable: bool + http_semantically_idempotent: bool + mutation_replay_identity: MutationReplayIdentity + utf8_byte_limits: list[Utf8ByteLimit] + timeout_seconds: Optional[float] + + +def prepare_generated_body( + body: Any, + fields: list[GeneratedBodyField], +) -> Any: + """Clone a body and fill generated fields before validation and transport.""" + prepared = _serialize_body(body) + for generated_field in fields: + prepared = _prepare_generated_path( + prepared, + generated_field["path"], + generated_field["kind"], + source=body, + ) + return prepared + + +def _generated_source_child(source: Any, name: str) -> Any: + if isinstance(source, BaseModel): + return getattr(source, name, None) + if isinstance(source, Mapping): + return source.get(name) + return None + + +def _prepare_generated_path( + value: Any, + path: list[str], + kind: Literal["uuid"], + *, + source: Any = None, +) -> Any: + if not path: + return value + head, *tail = path + if head == "*": + if value is None: + return value + if not isinstance(value, list): + raise TypeError("Generated body field wildcard must traverse a list") + source_items = source if isinstance(source, list) else [None] * len(value) + return [ + _prepare_generated_path( + item, + tail, + kind, + source=source_items[index] if index < len(source_items) else None, + ) + for index, item in enumerate(value) + ] + if value is None: + return value + if not isinstance(value, Mapping): + raise TypeError("Generated body field must traverse a mapping") + copy = dict(value) + source_child = _generated_source_child(source, head) + if not tail: + if copy.get(head) is None: + copy[head] = ( + str(source_child) + if source_child is not None + else _generate_body_value(kind) + ) + elif copy.get(head) is not None: + copy[head] = _prepare_generated_path( + copy[head], + tail, + kind, + source=source_child, + ) + return copy + + +def _generate_body_value(kind: Literal["uuid"]) -> str: + if kind == "uuid": + return str(uuid4()) + raise AssertionError(f"Unsupported generated body field kind: {kind}") class NebulaCore: @@ -90,7 +202,7 @@ async def __aenter__(self) -> "NebulaCore": async def __aexit__(self, *args: Any) -> None: await self.aclose() - def _build_headers(self, per_request: Optional[Mapping[str, str]], has_body: bool) -> dict[str, str]: + def _build_headers(self, per_request: Optional[Mapping[str, Optional[str]]], has_body: bool) -> dict[str, str]: headers: dict[str, str] = dict(self._options.default_headers) headers["User-Agent"] = self._options.user_agent headers["Accept"] = "application/json" @@ -102,7 +214,7 @@ def _build_headers(self, per_request: Optional[Mapping[str, str]], has_body: boo if self._options.api_key: headers["Authorization"] = f"Bearer {self._options.api_key}" if per_request: - headers.update(per_request) + headers.update({key: value for key, value in per_request.items() if value is not None}) return headers @staticmethod @@ -139,15 +251,14 @@ def _parse_retry_after(value: Optional[str]) -> Optional[float]: """ if not value: return None - try: - return float(value) - except ValueError: - pass + normalized = value.strip() + if normalized.isascii() and normalized.isdigit(): + return float(int(normalized)) # Per RFC 7231, Retry-After may also be an HTTP-date. from email.utils import parsedate_to_datetime from datetime import datetime, timezone try: - target = parsedate_to_datetime(value) + target = parsedate_to_datetime(normalized) if target.tzinfo is None: target = target.replace(tzinfo=timezone.utc) delta = (target - datetime.now(timezone.utc)).total_seconds() @@ -156,37 +267,78 @@ def _parse_retry_after(value: Optional[str]) -> Optional[float]: return None async def request(self, args: RequestArgs) -> Any: + timeout_seconds = args.get("timeout_seconds") + if timeout_seconds is None: + timeout_seconds = self._options.timeout_seconds + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or not math.isfinite(timeout_seconds) + or timeout_seconds <= 0 + ): + raise ValueError("timeout_seconds must be a positive finite number") + deadline = time.monotonic() + timeout_seconds method = args["method"].upper() path = self._resolve_path(args["path"], args.get("path_params")) query = self._filter_query(args.get("query")) body = _serialize_body(args.get("body")) has_body = body is not None - headers = self._build_headers(args.get("headers"), has_body) - idempotent = bool(args.get("idempotent", False)) + encoded_body = ( + json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if has_body + else None + ) + route_headers = _routing_headers_for_request( + body=body, + path_params=args.get("path_params") or {}, + query=query, + routing=args.get("routing"), + ) + headers = self._build_headers( + {**route_headers, **dict(args.get("headers") or {})}, + has_body, + ) + _validate_utf8_byte_limits( + body=body, + headers=headers, + limits=args.get("utf8_byte_limits", []), + ) + retryable = bool(args.get("retryable", False)) - max_attempts = self._options.retry.max_retries + 1 if idempotent else 1 + max_attempts = self._options.retry.max_retries + 1 if retryable else 1 last_error: Optional[BaseException] = None for attempt in range(max_attempts): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _invocation_timeout(timeout_seconds) try: - response = await self._http.request( - method=method, - url=path, - params=query, - json=body if has_body else None, - headers=headers, - ) - except httpx.TimeoutException as exc: - if attempt + 1 < max_attempts: - await asyncio.sleep(backoff_seconds(attempt, self._options.retry)) + async with asyncio.timeout(remaining): + response = await self._http.request( + method=method, + url=path, + params=query, + content=encoded_body, + headers=headers, + timeout=remaining, + ) + except (TimeoutError, httpx.TimeoutException) as exc: + if time.monotonic() < deadline and attempt + 1 < max_attempts: + await _sleep_with_deadline( + backoff_seconds(attempt, self._options.retry), + deadline, + timeout_seconds, + ) last_error = exc continue - raise NebulaTimeoutError( - f"Request timed out after {self._options.timeout_seconds}s" - ) from exc + raise _invocation_timeout(timeout_seconds) from exc except httpx.HTTPError as exc: if attempt + 1 < max_attempts: - await asyncio.sleep(backoff_seconds(attempt, self._options.retry)) + await _sleep_with_deadline( + backoff_seconds(attempt, self._options.retry), + deadline, + timeout_seconds, + ) last_error = exc continue raise NebulaConnectionError(str(exc)) from exc @@ -211,8 +363,10 @@ async def request(self, args: RequestArgs) -> Any: ) if is_retryable_status(response.status_code) and attempt + 1 < max_attempts: - await asyncio.sleep( - backoff_seconds(attempt, self._options.retry, retry_after) + await _sleep_with_deadline( + backoff_seconds(attempt, self._options.retry, retry_after), + deadline, + timeout_seconds, ) last_error = err continue @@ -223,7 +377,142 @@ async def request(self, args: RequestArgs) -> Any: raise NebulaConnectionError("retry budget exhausted") +def _invocation_timeout(timeout_seconds: float) -> NebulaTimeoutError: + return NebulaTimeoutError( + f"Invocation timed out after {timeout_seconds}s" + ) + + +async def _sleep_with_deadline( + delay_seconds: float, + deadline: float, + timeout_seconds: float, +) -> None: + remaining = deadline - time.monotonic() + if delay_seconds >= remaining: + raise _invocation_timeout(timeout_seconds) + await asyncio.sleep(delay_seconds) + if time.monotonic() >= deadline: + raise _invocation_timeout(timeout_seconds) + + def _quote(value: str) -> str: from urllib.parse import quote return quote(value, safe="") + + +def _validate_utf8_byte_limits( + *, + body: Any, + headers: Mapping[str, Any], + limits: list[Utf8ByteLimit], +) -> None: + for limit in limits: + root = body if limit["source"] == "body" else headers + _validate_utf8_path( + root, + limit["path"], + limit["maximum"], + ".".join(limit["path"]), + ) + + +def _validate_utf8_path( + value: Any, + path: list[str], + maximum: int, + label: str, +) -> None: + if not path: + if isinstance(value, str) and len(value.encode("utf-8")) > maximum: + raise ValueError(f"{label} must be at most {maximum} UTF-8 bytes") + return + head, *tail = path + if head == "*": + if isinstance(value, list): + for item in value: + _validate_utf8_path(item, tail, maximum, label) + return + if not isinstance(value, Mapping): + return + _validate_utf8_path(value.get(head), tail, maximum, label) + + +def _routing_headers_for_request( + *, + body: Any, + path_params: Mapping[str, Any], + query: Any, + routing: Optional[Mapping[str, Any]], +) -> dict[str, str]: + if not routing: + return {} + owner = routing.get("owner") + explicit_value = routing.get("value") + body_fields = routing.get("body_fields") + query_fields = routing.get("query_fields") + path_fields = routing.get("path_fields") + if ( + not isinstance(owner, str) + or not _valid_field_list(body_fields) + or not _valid_field_list(query_fields) + or not _valid_field_list(path_fields) + ): + return {} + if "value" in routing: + route_id = _route_id_value(explicit_value) + else: + route_id = ( + _string_field(body, *(body_fields or [])) + or _string_field(query, *(query_fields or [])) + or _string_field(path_params, *(path_fields or [])) + ) + return {"X-Nebula-Owner-Key": f"{owner}:{route_id}"} if route_id else {} + + +def _valid_field_list(value: Any) -> bool: + return value is None or ( + isinstance(value, list) and all(isinstance(field, str) for field in value) + ) + + +def _string_field( + body: Any, + *names: str, +) -> Optional[str]: + if not isinstance(body, Mapping): + return None + for name in names: + route_id = _route_id_value(_nested_field(body, name)) + if route_id: + return route_id + return None + + +def _nested_field(body: Mapping[str, Any], path: str) -> Any: + current: Any = body + for part in path.split("."): + if not isinstance(current, Mapping): + return None + current = current.get(part) + return current + + +def _route_id_value(value: Any) -> Optional[str]: + if isinstance(value, str) and value: + return value + if ( + isinstance(value, list) + and len(value) == 1 + and isinstance(value[0], str) + and value[0] + ): + return value[0] + if isinstance(value, Mapping): + if "$eq" in value: + return _route_id_value(value.get("$eq")) + for op in ("$in", "$overlap"): + if op in value: + return _route_id_value(value.get(op)) + return None diff --git a/src/nebula/_runtime/errors.py b/src/nebula/_runtime/errors.py index 2d3c4d7..45898b9 100644 --- a/src/nebula/_runtime/errors.py +++ b/src/nebula/_runtime/errors.py @@ -2,6 +2,8 @@ from typing import Any, Mapping, Optional +from .public_api_error import PublicApiError, parse_public_api_error + class NebulaError(Exception): pass @@ -57,6 +59,7 @@ def __init__( self.type: Optional[str] = env_type self.code: Optional[str] = env_code self.details: Any = env_details + self.public_error: Optional[PublicApiError] = parse_public_api_error(body) class NebulaBadRequestError(NebulaAPIError): diff --git a/src/nebula/_runtime/public_api_error.py b/src/nebula/_runtime/public_api_error.py new file mode 100644 index 0000000..c5f8ff1 --- /dev/null +++ b/src/nebula/_runtime/public_api_error.py @@ -0,0 +1,19 @@ +# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. +# Source: nebula-sdks/openapi/openapi.json + +from __future__ import annotations + +from typing import Any, TypeAlias + +from pydantic import ValidationError + +from .. import _models as models + +PublicApiError: TypeAlias = models.PublicApiError + + +def parse_public_api_error(value: Any) -> PublicApiError | None: + try: + return models.PublicApiError.model_validate(value) + except (ValidationError, ValueError): + return None diff --git a/src/nebula/_runtime/retry.py b/src/nebula/_runtime/retry.py index 755877c..9de4315 100644 --- a/src/nebula/_runtime/retry.py +++ b/src/nebula/_runtime/retry.py @@ -14,7 +14,11 @@ class RetryPolicy: DEFAULT_RETRY = RetryPolicy() -_RETRYABLE_STATUSES: frozenset[int] = frozenset({408, 429, 502, 503, 504}) +# 421 reports that this request reached a region that does not own the +# collection. Ownership is durable and the routing directory converges, so +# the misroute is transient; the edge replays what it can, and retrying +# covers the requests it cannot (uncloneable bodies, unannotated routes). +_RETRYABLE_STATUSES: frozenset[int] = frozenset({421, 429, 503}) def is_retryable_status(status: int) -> bool: @@ -24,7 +28,9 @@ def is_retryable_status(status: int) -> bool: def backoff_seconds( attempt: int, policy: RetryPolicy, retry_after: Optional[float] = None ) -> float: - if retry_after is not None: - return min(retry_after, policy.max_seconds) cap = min(policy.base_seconds * (2 ** attempt), policy.max_seconds) - return random.uniform(0, cap) + jitter = random.uniform(0, cap) + if retry_after is not None: + # Retry-After is a server-imposed minimum, not ordinary client backoff. + return max(jitter, max(0.0, retry_after)) + return jitter diff --git a/src/nebula/_runtime/validation.py b/src/nebula/_runtime/validation.py index bae59b3..c14966f 100644 --- a/src/nebula/_runtime/validation.py +++ b/src/nebula/_runtime/validation.py @@ -5,6 +5,11 @@ from pydantic import TypeAdapter, ValidationError +def validate_request_body(annotation: Any, raw: Any) -> Any: + """Validate mapping-style SDK inputs before issuing an HTTP request.""" + return TypeAdapter(annotation).validate_python(raw, extra="forbid") + + def validate_response(annotation: Any, raw: Any) -> Any: """Validate a raw wire payload against a response annotation. diff --git a/src/nebula/resources/client.py b/src/nebula/resources/client.py index 9e1ea0e..7f5ba7b 100644 --- a/src/nebula/resources/client.py +++ b/src/nebula/resources/client.py @@ -2,10 +2,16 @@ # Source: nebula-sdks/openapi/openapi.json from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any, Literal, Mapping, Optional, Union from pydantic import ValidationError from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) class ClientResource: @@ -13,11 +19,13 @@ def __init__(self, core: NebulaCore) -> None: self._core = core async def health( - self + self, + *, + request_options: Optional[RequestOptions] = None ) -> models.GenericMessageResponse: - """Health probe + """Readiness probe - Lightweight liveness probe. Returns a 200 with a fixed message when the API process is up. Does not verify downstream dependencies (database, storage, workers) — use the internal status endpoints for those. + Returns 200 only after required bootstrap collections, the database pool, and graph storage are ready to serve requests. Use `/v1/liveness` for a process-only probe. operationId: client.health endpoint: GET /v1/health @@ -27,7 +35,10 @@ async def health( "path": "/v1/health", "path_params": {}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: diff --git a/src/nebula/resources/collections.py b/src/nebula/resources/collections.py index 43d2e40..bcf84dc 100644 --- a/src/nebula/resources/collections.py +++ b/src/nebula/resources/collections.py @@ -2,20 +2,71 @@ # Source: nebula-sdks/openapi/openapi.json from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any, Literal, Mapping, Optional, Union from pydantic import ValidationError from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response +from .. import _inputs as inputs +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) class CollectionsResource: def __init__(self, core: NebulaCore) -> None: self._core = core + async def copy( + self, + workspace_id: str, + source_collection_id: str, + body: Union[inputs.CopyCollectionInput, models.CopyCollectionRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult]: + """Copy collection + + Copy an existing collection. + + Starts a durable copy of the collection, including all engrams, chunks, + and knowledge graph data (entities, relationships, facts). The response + immediately reports whether provisioning is still processing, succeeded, + or failed. Retry with the same collection_id to recover the same result. + + You need at least READ_CONTENT access to the source collection to copy it. + For team workspace collections, you must be a workspace owner or admin. + + operationId: collections.copy + endpoint: POST /v1/workspaces/{workspace_id}/collections/{source_collection_id}/copy + """ + body = _prepare_generated_body(body, [{"path":["collection_id"],"kind":"uuid"}]) + body = _validate_request_body(models.CopyCollectionRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/workspaces/{workspace_id}/collections/{source_collection_id}/copy", + "path_params": {"workspace_id": workspace_id, "source_collection_id": source_collection_id}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "workspace", "path_fields": ["workspace_id","workspaceId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"collection_id","generated":"uuid"}, + "utf8_byte_limits": [{"source":"body","path":["name"],"maximum":256}], + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _validate_response(Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult], _raw) + async def create( self, - body: models.CreateCollectionRequest - ) -> models.CollectionResponse: + body: Union[inputs.CreateCollectionInput, models.CreateCollectionRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult]: """Create a new collection Create a new collection and automatically add the creating user @@ -28,32 +79,41 @@ async def create( operationId: collections.create endpoint: POST /v1/collections """ + body = _prepare_generated_body(body, [{"path":["collection_id"],"kind":"uuid"}]) + body = _validate_request_body(models.CreateCollectionRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/collections", "path_params": {}, "query": None, + "headers": None, "body": body, - "idempotent": False, + "routing": {"owner": "workspace", "body_fields": ["workspace_id","workspaceId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"collection_id","generated":"uuid"}, + "utf8_byte_limits": [{"source":"body","path":["name"],"maximum":256}], + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.CollectionResponse.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] + return _validate_response(Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult], _raw) async def delete( self, - id: str - ) -> models.GenericBooleanResponse: + id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult]: """Delete collection Delete an existing collection. This endpoint allows deletion of a collection identified by its UUID. The user must have appropriate permissions to delete the - collection. Deleting a collection removes all associations but does - not delete the engrams within it. + collection. The collection is marked as deleting immediately and + data removal continues asynchronously. Memories shared with other + collections remain available there; memories exclusive to this + collection are removed. operationId: collections.delete endpoint: DELETE /v1/collections/{id} @@ -63,13 +123,14 @@ async def delete( "path": "/v1/collections/{id}", "path_params": {"id": id}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_path"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.GenericBooleanResponse.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] + return _validate_response(Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult], _raw) async def list( self, @@ -79,7 +140,8 @@ async def list( cursor: Optional[Union[str, None]] = None, limit: Optional[int] = None, owner_only: Optional[bool] = None, - workspace_id: Optional[Union[str, None]] = None + workspace_id: Optional[Union[str, None]] = None, + request_options: Optional[RequestOptions] = None ) -> models.PaginatedCollectionResponse: """List collections @@ -101,7 +163,10 @@ async def list( "path": "/v1/collections", "path_params": {}, "query": {"ids": ids, "name": name, "cursor": cursor, "limit": limit, "owner_only": owner_only, "workspace_id": workspace_id}, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) try: return models.PaginatedCollectionResponse.model_validate(_raw) @@ -110,7 +175,9 @@ async def list( async def retrieve( self, - id: str + id: str, + *, + request_options: Optional[RequestOptions] = None ) -> models.CollectionResponse: """Get collection details @@ -128,7 +195,10 @@ async def retrieve( "path": "/v1/collections/{id}", "path_params": {"id": id}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -140,7 +210,8 @@ async def retrieve_by_name( self, collection_name: str, *, - owner_id: Optional[Union[str, None]] = None + owner_id: Optional[Union[str, None]] = None, + request_options: Optional[RequestOptions] = None ) -> models.CollectionResponse: """Get a collection by name @@ -157,7 +228,10 @@ async def retrieve_by_name( "path": "/v1/collections/name/{collection_name}", "path_params": {"collection_name": collection_name}, "query": {"owner_id": owner_id}, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -168,7 +242,9 @@ async def retrieve_by_name( async def update( self, id: str, - body: models.UpdateCollectionRequest + body: Union[models.UpdateCollectionRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None ) -> models.CollectionResponse: """Update collection @@ -181,13 +257,18 @@ async def update( operationId: collections.update endpoint: POST /v1/collections/{id} """ + body = _validate_request_body(models.UpdateCollectionRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/collections/{id}", "path_params": {"id": id}, "query": None, + "headers": None, "body": body, - "idempotent": False, + "retryable": False, + "http_semantically_idempotent": False, + "utf8_byte_limits": [{"source":"body","path":["name"],"maximum":256}], + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: diff --git a/src/nebula/resources/connectors.py b/src/nebula/resources/connectors.py index 66e4308..2f7b197 100644 --- a/src/nebula/resources/connectors.py +++ b/src/nebula/resources/connectors.py @@ -2,20 +2,58 @@ # Source: nebula-sdks/openapi/openapi.json from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any, Literal, Mapping, Optional, Union from pydantic import ValidationError from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) class ConnectorsResource: def __init__(self, core: NebulaCore) -> None: self._core = core + async def approve_withdrawal_plan( + self, + connection_id: str, + candidate_digest: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.ConnectorSyncResponse: + """Approve a mass-withdrawal plan + + Approve exactly the reviewed candidate set and schedule a new sync. Any change to that set requires a new approval. + + operationId: connectors.approveWithdrawalPlan + endpoint: POST /v1/connectors/{connection_id}/withdrawal-plan/{candidate_digest}/approve + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/connectors/{connection_id}/withdrawal-plan/{candidate_digest}/approve", + "path_params": {"connection_id": connection_id, "candidate_digest": candidate_digest}, + "query": None, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.ConnectorSyncResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def connect( self, provider: str, - body: models.ConnectRequest + body: Union[models.ConnectRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None ) -> models.ConnectorConnectResponse: """Start OAuth connection flow @@ -24,13 +62,17 @@ async def connect( operationId: connectors.connect endpoint: POST /v1/connectors/{provider}/connect """ + body = _validate_request_body(models.ConnectRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/connectors/{provider}/connect", "path_params": {"provider": provider}, "query": None, + "headers": None, "body": body, - "idempotent": False, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -42,7 +84,8 @@ async def disconnect( self, connection_id: str, *, - delete_memories: Optional[bool] = None + delete_memories: Optional[bool] = None, + request_options: Optional[RequestOptions] = None ) -> models.ConnectorDisconnectResponse: """Disconnect an external data source @@ -56,7 +99,10 @@ async def disconnect( "path": "/v1/connectors/{connection_id}", "path_params": {"connection_id": connection_id}, "query": {"delete_memories": delete_memories}, - "idempotent": True, + "headers": None, + "retryable": False, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -64,9 +110,69 @@ async def disconnect( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def disconnect_hosted_m365_tenant( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.HostedM365TenantDisconnectResponse: + """Disconnect a hosted Microsoft Entra tenant + + Disconnect the workspace from its hosted Microsoft 365 tenant, invalidate outstanding consent links, and require affected connections to reconnect. This removes only Nebula's local grant; it does not revoke tenant-wide admin consent in Microsoft Entra, which an Entra administrator must remove separately. + + operationId: connectors.disconnectHostedM365Tenant + endpoint: DELETE /v1/connectors/m365/admin-consent + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/connectors/m365/admin-consent", + "path_params": {}, + "query": {"workspace_id": workspace_id}, + "headers": None, + "retryable": False, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.HostedM365TenantDisconnectResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def get_hosted_m365_admin_consent( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.HostedM365ConsentStatusResponse: + """Get hosted Microsoft Entra consent status + + Return the hosted Microsoft 365 tenant consent state for a workspace without exposing credentials. + + operationId: connectors.getHostedM365AdminConsent + endpoint: GET /v1/connectors/m365/admin-consent + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/connectors/m365/admin-consent", + "path_params": {}, + "query": {"workspace_id": workspace_id}, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.HostedM365ConsentStatusResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def list( self, - collection_id: str + collection_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> list[models.ConnectorConnectionResponse]: """List active connections for a collection @@ -80,7 +186,10 @@ async def list( "path": "/v1/connectors", "path_params": {}, "query": {"collection_id": collection_id}, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -88,8 +197,39 @@ async def list( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def list_oauth_apps( + self, + collection_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> list[models.ConnectorOAuthAppResponse]: + """List workspace connector OAuth app registrations + + Return the workspace-level OAuth app registrations used by Google and Microsoft 365 connectors for the requested collection's workspace. Client secrets are never returned. + + operationId: connectors.listOAuthApps + endpoint: GET /v1/connectors/oauth-apps + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/connectors/oauth-apps", + "path_params": {}, + "query": {"collection_id": collection_id}, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return _validate_response(list[models.ConnectorOAuthAppResponse], _raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def list_providers( - self + self, + *, + request_options: Optional[RequestOptions] = None ) -> list[str]: """List available connector providers @@ -103,14 +243,49 @@ async def list_providers( "path": "/v1/connectors/providers", "path_params": {}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) return _raw + async def requeue_withdrawal( + self, + connection_id: str, + engram_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.ConnectorSyncResponse: + """Retry a failed source withdrawal + + Reset a terminally failed deletion handoff and schedule a new bounded deletion attempt. + + operationId: connectors.requeueWithdrawal + endpoint: POST /v1/connectors/{connection_id}/withdrawals/{engram_id}/requeue + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/connectors/{connection_id}/withdrawals/{engram_id}/requeue", + "path_params": {"connection_id": connection_id, "engram_id": engram_id}, + "query": None, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.ConnectorSyncResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def retrieve( self, - connection_id: str + connection_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> models.ConnectorConnectionResponse: """Get a single connection by ID @@ -124,7 +299,10 @@ async def retrieve( "path": "/v1/connectors/{connection_id}", "path_params": {"connection_id": connection_id}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -132,9 +310,71 @@ async def retrieve( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def retrieve_withdrawal_plan( + self, + connection_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.ConnectorWithdrawalPlanResponse, None]: + """Get a pending mass-withdrawal plan + + Return the operator-review plan created when a sync would remove more source items than the connection's automatic safety allowance. + + operationId: connectors.retrieveWithdrawalPlan + endpoint: GET /v1/connectors/{connection_id}/withdrawal-plan + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/connectors/{connection_id}/withdrawal-plan", + "path_params": {"connection_id": connection_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return _validate_response(Union[models.ConnectorWithdrawalPlanResponse, None], _raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def start_hosted_m365_admin_consent( + self, + body: Union[models.HostedM365ConsentStartRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.HostedM365ConsentStartResponse: + """Start hosted Microsoft Entra admin consent + + Create a one-time Entra administrator consent URL for a hosted Microsoft 365 tenant bound to this workspace. + + operationId: connectors.startHostedM365AdminConsent + endpoint: POST /v1/connectors/m365/admin-consent + """ + body = _validate_request_body(models.HostedM365ConsentStartRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/connectors/m365/admin-consent", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.HostedM365ConsentStartResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def sync( self, - connection_id: str + connection_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> models.ConnectorSyncResponse: """Manually trigger a sync @@ -148,10 +388,77 @@ async def sync( "path": "/v1/connectors/{connection_id}/sync", "path_params": {"connection_id": connection_id}, "query": None, - "idempotent": True, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: return models.ConnectorSyncResponse.model_validate(_raw) except (ValidationError, ValueError): return _raw # type: ignore[return-value] + + async def update_config( + self, + connection_id: str, + body: Union[models.UpdateConfigRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.ConnectorConfigUpdateResponse: + """Update connector config and perform a full resync + + Replace a connection's provider scope and start a full resync. + + operationId: connectors.updateConfig + endpoint: PATCH /v1/connectors/{connection_id}/config + """ + body = _validate_request_body(models.UpdateConfigRequest, body) + _raw = await self._core.request({ + "method": "PATCH", + "path": "/v1/connectors/{connection_id}/config", + "path_params": {"connection_id": connection_id}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.ConnectorConfigUpdateResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def update_oauth_app( + self, + provider_family: str, + body: Union[models.ConnectorOAuthAppUpdateRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.ConnectorOAuthAppResponse: + """Update a workspace connector OAuth app + + Update non-secret workspace OAuth app settings for a collection's workspace. Google apps can provide Pub/Sub settings to enable Gmail real-time sync. Client secrets are never returned. + + operationId: connectors.updateOAuthApp + endpoint: PATCH /v1/connectors/oauth-apps/{provider_family} + """ + body = _validate_request_body(models.ConnectorOAuthAppUpdateRequest, body) + _raw = await self._core.request({ + "method": "PATCH", + "path": "/v1/connectors/oauth-apps/{provider_family}", + "path_params": {"provider_family": provider_family}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.ConnectorOAuthAppResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] diff --git a/src/nebula/resources/enterprise.py b/src/nebula/resources/enterprise.py new file mode 100644 index 0000000..7304972 --- /dev/null +++ b/src/nebula/resources/enterprise.py @@ -0,0 +1,51 @@ +# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. +# Source: nebula-sdks/openapi/openapi.json + +from __future__ import annotations +from typing import Any, Literal, Mapping, Optional, Union +from pydantic import ValidationError +from .. import _models as models +from .. import _inputs as inputs +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) + + +class EnterpriseResource: + def __init__(self, core: NebulaCore) -> None: + self._core = core + + async def provision_account( + self, + body: Union[inputs.EnterpriseProvisioningInput, models.EnterpriseProvisioningRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.EnterpriseProvisioningResult: + """Provision an enterprise account + + Create a user, named collection, and API key as one operation. + + operationId: enterprise.provisionAccount + endpoint: POST /v1/admin/enterprise-provisioning + """ + body = _prepare_generated_body(body, [{"path":["request_id"],"kind":"uuid"}]) + body = _validate_request_body(models.EnterpriseProvisioningRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/admin/enterprise-provisioning", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"request_id","generated":"uuid"}, + "utf8_byte_limits": [{"source":"body","path":["collection_name"],"maximum":256}], + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.EnterpriseProvisioningResult.model_validate(_raw) diff --git a/src/nebula/resources/marketplace.py b/src/nebula/resources/marketplace.py new file mode 100644 index 0000000..0cbfb24 --- /dev/null +++ b/src/nebula/resources/marketplace.py @@ -0,0 +1,125 @@ +# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. +# Source: nebula-sdks/openapi/openapi.json + +from __future__ import annotations +from typing import Any, Literal, Mapping, Optional, Union +from pydantic import ValidationError +from .. import _models as models +from .. import _inputs as inputs +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) + + +class MarketplaceResource: + def __init__(self, core: NebulaCore) -> None: + self._core = core + + async def fork( + self, + workspace_id: str, + source_collection_id: str, + body: Union[inputs.ForkMarketplaceCollectionInput, models.ForkMarketplaceCollectionRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult]: + """Fork a public collection into your personal workspace + + Fork a public collection into your personal workspace. + + The destination must be the caller's personal workspace; + marketplace forks never land in team workspaces. + + The response reports whether the durable copy is still processing, + succeeded, or failed. Retry with the same collection_id to recover + the same result. + + Authentication is required. The forked collection counts toward your plan limits. + + operationId: marketplace.fork + endpoint: POST /v1/workspaces/{workspace_id}/marketplace/collections/{source_collection_id}/fork + """ + body = _prepare_generated_body(body, [{"path":["collection_id"],"kind":"uuid"}]) + body = _validate_request_body(models.ForkMarketplaceCollectionRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/workspaces/{workspace_id}/marketplace/collections/{source_collection_id}/fork", + "path_params": {"workspace_id": workspace_id, "source_collection_id": source_collection_id}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "workspace", "path_fields": ["workspace_id","workspaceId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"collection_id","generated":"uuid"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _validate_response(Union[models.CollectionProvisioningResult, models.CollectionActiveResult, models.CollectionDeletingResult, models.CollectionDeletedResult, models.CollectionFailedResult], _raw) + + async def get_collection( + self, + collection_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.CollectionResponse: + """Get details of a public collection + + Get details of a public collection from the marketplace. + Returns collection metadata including pricing, preview limits, etc. + + operationId: marketplace.getCollection + endpoint: GET /v1/marketplace/collections/{collection_id} + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/marketplace/collections/{collection_id}", + "path_params": {"collection_id": collection_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.CollectionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def list_collections( + self, + *, + cursor: Optional[Union[str, None]] = None, + limit: Optional[int] = None, + search: Optional[Union[str, None]] = None, + request_options: Optional[RequestOptions] = None + ) -> models.PaginatedCollectionResponse: + """Browse public marketplace collections + + Browse publicly available collections in the marketplace. + These collections have 'public_preview' or 'marketplace' access tier. + + No authentication required, but authenticated users may see more details. + + operationId: marketplace.listCollections + endpoint: GET /v1/marketplace/collections + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/marketplace/collections", + "path_params": {}, + "query": {"cursor": cursor, "limit": limit, "search": search}, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + try: + return models.PaginatedCollectionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] diff --git a/src/nebula/resources/memories.py b/src/nebula/resources/memories.py deleted file mode 100644 index 250471b..0000000 --- a/src/nebula/resources/memories.py +++ /dev/null @@ -1,380 +0,0 @@ -# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. -# Source: nebula-sdks/openapi/openapi.json - -from __future__ import annotations -from typing import Any, Optional, Union -from pydantic import ValidationError -from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response - - -class MemoriesResource: - def __init__(self, core: NebulaCore) -> None: - self._core = core - - async def append( - self, - id: str, - body: models.AppendMemoryRequest - ) -> Union[models.AppendMemoryResponse, models.IngestionResponse]: - """Append content to an engram - - Append content to an existing engram. - - **For conversation engrams:** - - Provide `messages` array with content, role, and optional metadata - - Works like `/conversations/{id}/messages` endpoint - - **For document engrams:** - - Provide either `raw_text` or `chunks` to append additional content - - Content will be processed and added to the engram - - operationId: memories.append - endpoint: POST /v1/memories/{id}/append - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories/{id}/append", - "path_params": {"id": id}, - "query": None, - "body": body, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return _validate_response(Union[models.AppendMemoryResponse, models.IngestionResponse], _raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def create( - self, - body: models.CreateMemoryRequest - ) -> Union[models.MemoryCreateAcceptedResponse, models.SnapshotMutationResult]: - """Create a new memory (conversation or document) - - Create a new memory (conversation or document) using clean JSON body. - - - Use `collection_id` (UUID) - - `kind` is optional and inferred from payload shape: - - If `messages` present -> conversation - - Otherwise -> document - - For conversations: provide `messages` array - - For documents: provide `raw_text` or `chunks` - - Use `snapshot` for device-memory mode (mutually exclusive with collection_id) - - operationId: memories.create - endpoint: POST /v1/memories - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories", - "path_params": {}, - "query": None, - "body": body, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return _validate_response(Union[models.MemoryCreateAcceptedResponse, models.SnapshotMutationResult], _raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def create_upload( - self, - filename: str, - content_type: str, - file_size: int - ) -> models.PresignedUploadResponse: - """Get presigned URL for large file upload - - Get a presigned URL for uploading large files directly to S3. - - Use this for files larger than 5MB that cannot be sent inline as base64. - After uploading, reference the file in memory creation using S3FileReference. - - Args: - filename: Original filename (e.g., "image.jpg") - content_type: MIME type (e.g., "image/jpeg", "application/pdf") - file_size: Expected file size in bytes (max 100MB) - - Returns: - dict with: - - upload_url: Presigned URL for PUT request (expires in 1 hour) - - upload_headers: Headers that must be sent with the presigned PUT request - - s3_key: The S3 key to reference in memory creation - - bucket: S3 bucket name - - expires_in: Seconds until URL expires - - max_size: Maximum allowed file size - - operationId: memories.createUpload - endpoint: POST /v1/memories/upload - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories/upload", - "path_params": {}, - "query": {"filename": filename, "content_type": content_type, "file_size": file_size}, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.PresignedUploadResponse.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def delete( - self, - id: str - ) -> models.GenericBooleanResponse: - """Delete an engram - - Delete a specific engram with graph awareness. All chunks corresponding to the - engram are deleted, and graph components (entities/relationships) are updated - or deleted based on remaining chunk references from other engrams. - - This method now properly handles graph components and maintains graph integrity - for search operations. - - operationId: memories.delete - endpoint: DELETE /v1/memories/{id} - """ - _raw = await self._core.request({ - "method": "DELETE", - "path": "/v1/memories/{id}", - "path_params": {"id": id}, - "query": None, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.GenericBooleanResponse.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def delete_many( - self, - body: models.DeleteMemoriesRequest - ) -> Any: - """Delete one or more engrams - - Delete one or more engrams. - - This endpoint efficiently handles both single and batch deletions. - When multiple IDs are provided, it uses optimized batch operations. - - Args: - ids: Either a single UUID or a list of UUIDs to delete - - Returns: - For single deletion: boolean success response - For batch deletion: detailed results with successful and failed deletions - - operationId: memories.deleteMany - endpoint: POST /v1/memories/delete - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories/delete", - "path_params": {}, - "query": None, - "body": body, - "idempotent": False, - }) - return _raw - - async def delete_upload( - self, - s3_key: str - ) -> models.GenericMessageResponse: - """Delete a previously uploaded S3 file - - Delete a file from S3 that was uploaded via a presigned URL. - Verifies the caller owns the file via S3 object metadata. - - operationId: memories.deleteUpload - endpoint: DELETE /v1/memories/upload - """ - _raw = await self._core.request({ - "method": "DELETE", - "path": "/v1/memories/upload", - "path_params": {}, - "query": {"s3_key": s3_key}, - "idempotent": True, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.GenericMessageResponse.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def list( - self, - *, - ids: Optional[list[str]] = None, - cursor: Optional[Union[str, None]] = None, - limit: Optional[int] = None, - chunks_limit: Optional[Union[int, None]] = None, - owner_only: Optional[bool] = None, - collection_ids: Optional[Union[list[str], None]] = None, - metadata_filters: Optional[Union[str, None]] = None, - min_applied_wal_seq: Optional[Union[int, None]] = None - ) -> models.PaginatedListedEngram: - """List engrams - - Returns a cursor-paginated list of engrams the authenticated user - has access to. - - Results can be filtered by providing specific engram IDs or collection IDs. - Regular users will only see engrams they own or have access to through - collections. Superusers can see all engrams. - - The engrams are returned in order of creation time, most recent - first. The response includes the engram's text field if available. - - operationId: memories.list - endpoint: GET /v1/memories - """ - _raw = await self._core.request({ - "method": "GET", - "path": "/v1/memories", - "path_params": {}, - "query": {"ids": ids, "cursor": cursor, "limit": limit, "chunks_limit": chunks_limit, "owner_only": owner_only, "collection_ids": collection_ids, "metadata_filters": metadata_filters, "min_applied_wal_seq": min_applied_wal_seq}, - "idempotent": True, - }) - try: - return models.PaginatedListedEngram.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def recall_workflow( - self, - body: Union[models.CursorRecallRequest, models.PredictRecallRequest, models.ResumeRecallRequest, models.EvidenceRecallRequest, models.BootstrapRecallRequest] - ) -> dict[str, Any]: - """Recall workflow patterns by intent - - Workflow-pattern recall over 5 intents. - - * ``cursor`` -- match the caller's anchor against pattern - canonical states, return ranked patterns + position. - * ``predict`` -- like cursor but include the predicted next - trace from each pattern's canonical instance. - * ``resume`` -- like cursor, biased toward longer patterns. - * ``evidence`` -- expand a specific pattern via - ``hydrate_pattern``. - * ``bootstrap`` -- top-K patterns by confidence with no anchor. - - operationId: memories.recallWorkflow - endpoint: POST /v1/memories/workflow/recall - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories/workflow/recall", - "path_params": {}, - "query": None, - "body": body, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - return _raw - - async def retrieve( - self, - id: str - ) -> models.Engram: - """Retrieve an engram - - Retrieves detailed information about a specific engram by its - ID. - - This endpoint returns the engram's metadata, status, and system information. It does not - return the engram's content - use the `/engrams/{id}/download` endpoint for that. - - Users can only retrieve engrams they own or have access to through collections. - Superusers can retrieve any engram. - - operationId: memories.retrieve - endpoint: GET /v1/memories/{id} - """ - _raw = await self._core.request({ - "method": "GET", - "path": "/v1/memories/{id}", - "path_params": {"id": id}, - "query": None, - "idempotent": True, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.Engram.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def search( - self, - body: models.MemorySearchRequest - ) -> Union[models.CompactMemoryRecallResponse, models.MemoryRecall, models.SnapshotSearchResult]: - """Search memories - - Perform a search query across your memories. - - **Standard mode** (collection_ids or readable-scope search): returns hierarchical MemoryRecall - with semantics, episodes, procedures, and sources. - - **Snapshot mode** (snapshot field): returns graph-search results with - {entities, relationships} from stateless in-memory traversal. - - operationId: memories.search - endpoint: POST /v1/memories/search - """ - _raw = await self._core.request({ - "method": "POST", - "path": "/v1/memories/search", - "path_params": {}, - "query": None, - "body": body, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return _validate_response(Union[models.CompactMemoryRecallResponse, models.MemoryRecall, models.SnapshotSearchResult], _raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] - - async def update( - self, - id: str, - body: models.UpdateMemoryRequest, - *, - collection_id: Optional[Union[str, None]] = None - ) -> models.Engram: - """Update a memory - - Update memory-level properties including name, metadata, and collection associations. - - This endpoint allows updating properties of an entire memory (document or conversation) - without modifying its content: - - **name**: Updates the authoritative engram title - - **metadata**: Can replace or merge with existing metadata - - **collection_ids**: Updates authoritative engram collection associations - - Users can only update memories they own or have access to through collections. - At least one collection association must be maintained. - - If collection_id is provided and the engram is shared across collections, a copy-on-write - will be performed to create a collection-specific copy before modification. - - operationId: memories.update - endpoint: PATCH /v1/memories/{id} - """ - _raw = await self._core.request({ - "method": "PATCH", - "path": "/v1/memories/{id}", - "path_params": {"id": id}, - "query": {"collection_id": collection_id}, - "body": body, - "idempotent": False, - }) - _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) - try: - return models.Engram.model_validate(_raw) - except (ValidationError, ValueError): - return _raw # type: ignore[return-value] diff --git a/src/nebula/resources/memory.py b/src/nebula/resources/memory.py new file mode 100644 index 0000000..b8cb1ec --- /dev/null +++ b/src/nebula/resources/memory.py @@ -0,0 +1,590 @@ +# AUTOGENERATED by nebula-sdks/generator. Do not edit by hand. +# Source: nebula-sdks/openapi/openapi.json + +from __future__ import annotations +from typing import Any, Literal, Mapping, Optional, Union +from pydantic import ValidationError +from .. import _models as models +from .. import _inputs as inputs +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) + + +class MemoryResource: + def __init__(self, core: NebulaCore) -> None: + self._core = core + + async def append( + self, + id: str, + body: Union[inputs.AppendMemoryInput, models.AppendMemoryRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryWriteProjection: + """Append content to an engram + + Append content to an existing engram. + + **For conversation engrams:** + - Provide `messages` array with content, role, and optional metadata + - Works like `/conversations/{id}/messages` endpoint + + **For document engrams:** + - Provide either `raw_text` or `chunks` to append additional content + - Content will be processed and added to the engram + + operationId: memory.append + endpoint: POST /v1/memories/{id}/append + """ + body = _prepare_generated_body(body, [{"path":["idempotency_key"],"kind":"uuid"}]) + body = _validate_request_body(models.AppendMemoryRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/{id}/append", + "path_params": {"id": id}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryWriteProjection.model_validate(_raw) + + async def complete_upload( + self, + upload_session_id: str, + body: Union[models.CompleteMultipartUploadRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.CompleteMultipartUploadResponse: + """Complete multipart upload session + + Finalize an upload session after every multipart upload part has been uploaded. + + operationId: memory.completeUpload + endpoint: POST /v1/memories/upload/{upload_session_id}/complete + """ + body = _validate_request_body(models.CompleteMultipartUploadRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/upload/{upload_session_id}/complete", + "path_params": {"upload_session_id": upload_session_id}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.CompleteMultipartUploadResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def create_event_ticket( + self, + body: Union[models.MemoryEventTicketRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryEventTicketResult: + """Create a memory lifecycle event ticket + + Mint a short-lived, single-use ticket for one collection-scoped batch of memory write lifecycle events. + + operationId: memory.createEventTicket + endpoint: POST /v1/memory-event-tickets + """ + body = _validate_request_body(models.MemoryEventTicketRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memory-event-tickets", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryEventTicketResult.model_validate(_raw) + + async def create_upload( + self, + filename: str, + content_type: str, + file_size: int, + *, + collection_id: Optional[Union[str, None]] = None, + workspace_id: Optional[Union[str, None]] = None, + request_options: Optional[RequestOptions] = None + ) -> models.MultipartUploadSessionResponse: + """Create multipart upload session + + Create a workspace-scoped multipart upload session. + + operationId: memory.createUpload + endpoint: POST /v1/memories/upload + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/upload", + "path_params": {}, + "query": {"filename": filename, "content_type": content_type, "file_size": file_size, "collection_id": collection_id, "workspace_id": workspace_id}, + "headers": None, + "routing": {"owner": "collection", "query_fields": ["collection_id","collectionId"]}, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.MultipartUploadSessionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def delete( + self, + id: str, + collection_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.MemoryDeletingResult, models.MemoryDeletionFailedResult]: + """Delete an engram + + Durably accept a specific engram for deletion. + + operationId: memory.delete + endpoint: DELETE /v1/memories/{id} + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/memories/{id}", + "path_params": {"id": id}, + "query": {"collection_id": collection_id}, + "headers": None, + "routing": {"owner": "collection", "query_fields": ["collection_id","collectionId"]}, + "retryable": True, + "http_semantically_idempotent": True, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_path"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _validate_response(Union[models.MemoryDeletingResult, models.MemoryDeletionFailedResult], _raw) + + async def delete_many( + self, + body: Union[inputs.DeleteMemoriesInput, models.DeleteMemoriesRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.DeleteMemoriesResult: + """Delete one or more engrams + + Durably accept one or more engrams for deletion. + + operationId: memory.deleteMany + endpoint: POST /v1/memories/delete + """ + body = _prepare_generated_body(body, [{"path":["operation_id"],"kind":"uuid"}]) + body = _validate_request_body(models.DeleteMemoriesRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/delete", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"operation_id","generated":"uuid"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.DeleteMemoriesResult.model_validate(_raw) + + async def delete_upload( + self, + upload_session_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.GenericMessageResponse: + """Delete a pending upload session + + Delete a pending upload session and its staged object. + + operationId: memory.deleteUpload + endpoint: DELETE /v1/memories/upload + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/memories/upload", + "path_params": {}, + "query": {"upload_session_id": upload_session_id}, + "headers": None, + "retryable": False, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.GenericMessageResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def get( + self, + id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryResponse: + """Retrieve an engram + + Retrieves detailed information about a specific engram by its + ID. + + This endpoint returns the engram's metadata, status, and system information. It does not + return the engram's content - use the `/engrams/{id}/download` endpoint for that. + + Users can only retrieve engrams they own or have access to through collections. + Superusers can retrieve any engram. + + operationId: memory.get + endpoint: GET /v1/memories/{id} + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/memories/{id}", + "path_params": {"id": id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryResponse.model_validate(_raw) + + async def list( + self, + *, + ids: Optional[list[str]] = None, + cursor: Optional[Union[str, None]] = None, + limit: Optional[int] = None, + chunks_limit: Optional[Union[int, None]] = None, + owner_only: Optional[bool] = None, + collection_ids: Optional[Union[list[str], None]] = None, + metadata_filters: Optional[Union[str, None]] = None, + request_options: Optional[RequestOptions] = None + ) -> models.PaginatedListedMemoryResponse: + """List engrams + + Returns a cursor-paginated list of engrams the authenticated user + has access to. + + Results can be filtered by providing specific engram IDs or collection IDs. + Regular users will only see engrams they own or have access to through + collections. Superusers can see all engrams. + + The engrams are returned in order of creation time, most recent + first. The response includes the engram's text field if available. + + operationId: memory.list + endpoint: GET /v1/memories + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/memories", + "path_params": {}, + "query": {"ids": ids, "cursor": cursor, "limit": limit, "chunks_limit": chunks_limit, "owner_only": owner_only, "collection_ids": collection_ids, "metadata_filters": metadata_filters}, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + try: + return models.PaginatedListedMemoryResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def recall_workflow( + self, + body: Union[models.CursorRecallRequest, models.PredictRecallRequest, models.ResumeRecallRequest, models.EvidenceRecallRequest, models.BootstrapRecallRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> dict[str, Any]: + """Recall workflow patterns by intent + + Workflow-pattern recall over 5 intents. + + * ``cursor`` -- match the caller's anchor against pattern + canonical states, return ranked patterns + position. + * ``predict`` -- like cursor but include the predicted next + trace from each pattern's canonical instance. + * ``resume`` -- like cursor, biased toward longer patterns. + * ``evidence`` -- expand a specific pattern via + ``hydrate_pattern``. + * ``bootstrap`` -- top-K patterns by confidence with no anchor. + + operationId: memory.recallWorkflow + endpoint: POST /v1/memories/workflow/recall + """ + body = _validate_request_body(Union[models.CursorRecallRequest, models.PredictRecallRequest, models.ResumeRecallRequest, models.EvidenceRecallRequest, models.BootstrapRecallRequest], body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/workflow/recall", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _raw + + async def resolve_search_sources( + self, + retrieval_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.RetrievalAuditSourcesResponse: + """Resolve sources for an audited memory search + + Hydrate stored evidence refs after rechecking current access. + + operationId: memory.resolveSearchSources + endpoint: GET /v1/memories/searches/{retrieval_id}/sources + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/memories/searches/{retrieval_id}/sources", + "path_params": {"retrieval_id": retrieval_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.RetrievalAuditSourcesResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def search( + self, + body: Union[models.MemorySearchRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.CompactMemoryRecallResponse, models.MemoryRecall, models.SnapshotSearchResult]: + """Search memories + + Perform a search query across your memories. + + **Standard mode** (collection_ids or readable-scope search): returns hierarchical MemoryRecall + with semantics, episodes, and procedures. Set ``include_sources`` + to include the raw source material grounding the retrieved memory. + + **Snapshot mode** (snapshot field): returns graph-search results with + {entities, relationships} from stateless in-memory traversal. + + operationId: memory.search + endpoint: POST /v1/memories/search + """ + body = _validate_request_body(models.MemorySearchRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/search", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_ids","collectionIds","filters.collection_id","filters.collection_ids","search_settings.filters.collection_id","search_settings.filters.collection_ids"]}, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return _validate_response(Union[models.CompactMemoryRecallResponse, models.MemoryRecall, models.SnapshotSearchResult], _raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def sign_upload_part( + self, + upload_session_id: str, + part_number: int, + checksum_sha256: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.MultipartUploadPartResponse: + """Create multipart upload part URL + + Create a presigned URL for uploading one part of an existing memory upload session. + + operationId: memory.signUploadPart + endpoint: POST /v1/memories/upload/{upload_session_id}/parts/{part_number} + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/upload/{upload_session_id}/parts/{part_number}", + "path_params": {"upload_session_id": upload_session_id, "part_number": part_number}, + "query": {"checksum_sha256": checksum_sha256}, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.MultipartUploadPartResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def status( + self, + body: Union[models.MemoryStatusRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryStatusResponse: + """Get authoritative memory write status + + Resolve up to 40 admitted memory writes and optionally wait for exact search visibility or full processing. + + operationId: memory.status + endpoint: POST /v1/memories/status + """ + body = _validate_request_body(models.MemoryStatusRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/status", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_set"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryStatusResponse.model_validate(_raw) + + async def store( + self, + body: Union[inputs.StoreMemoryInput, models.StoreMemoryRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryWriteProjection: + """Store a memory + + Store a conversation or document through one durable product + surface. Nebula SDKs generate the stable memory identity. + + operationId: memory.store + endpoint: POST /v1/memories + """ + body = _prepare_generated_body(body, [{"path":["memory_id"],"kind":"uuid"}]) + body = _validate_request_body(models.StoreMemoryRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"body","name":"memory_id","generated":"uuid"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryWriteProjection.model_validate(_raw) + + async def store_many( + self, + body: Union[inputs.StoreMemoriesInput, models.StoreMemoriesRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.StoreMemoriesResult: + """Store a bounded memory batch + + Store up to 40 independent document or conversation memories in one request. Each memory has its own stable identity, processing state, and replay outcome. + + operationId: memory.storeMany + endpoint: POST /v1/memories/batch + """ + body = _prepare_generated_body(body, [{"path":["memories","*","memory_id"],"kind":"uuid"}]) + body = _validate_request_body(models.StoreMemoriesRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/memories/batch", + "path_params": {}, + "query": None, + "headers": None, + "body": body, + "routing": {"owner": "collection", "body_fields": ["collection_id","collectionId"]}, + "retryable": True, + "http_semantically_idempotent": False, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_set"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.StoreMemoriesResult.model_validate(_raw) + + async def update( + self, + id: str, + collection_id: str, + body: Union[models.UpdateMemoryRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.MemoryResponse: + """Update a memory + + Update memory-level properties including name, metadata, and collection associations. + + This endpoint allows updating properties of an entire memory (document or conversation) + without modifying its content: + - **name**: Updates the authoritative engram title + - **metadata**: Can replace or merge with existing metadata + - **collection_ids**: Updates authoritative engram collection associations + + Users can only update memories they own or have access to through collections. + At least one collection association must be maintained. + + If collection_id is provided and the engram is shared across collections, a copy-on-write + will be performed to create a collection-specific copy before modification. + + operationId: memory.update + endpoint: PATCH /v1/memories/{id} + """ + body = _validate_request_body(models.UpdateMemoryRequest, body) + _raw = await self._core.request({ + "method": "PATCH", + "path": "/v1/memories/{id}", + "path_params": {"id": id}, + "query": {"collection_id": collection_id}, + "headers": None, + "body": body, + "routing": {"owner": "collection", "query_fields": ["collection_id","collectionId"]}, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return models.MemoryResponse.model_validate(_raw) diff --git a/src/nebula/resources/snapshots.py b/src/nebula/resources/snapshots.py index 21737a6..860b434 100644 --- a/src/nebula/resources/snapshots.py +++ b/src/nebula/resources/snapshots.py @@ -2,10 +2,16 @@ # Source: nebula-sdks/openapi/openapi.json from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any, Literal, Mapping, Optional, Union from pydantic import ValidationError from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) class SnapshotsResource: @@ -14,7 +20,9 @@ def __init__(self, core: NebulaCore) -> None: async def export( self, - body: models.SnapshotExportRequest + body: Union[models.SnapshotExportRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None ) -> Union[models.SnapshotEnvelopeOutput, models.SnapshotObjectReference]: """Export a collection snapshot @@ -24,13 +32,17 @@ async def export( operationId: snapshots.export endpoint: POST /v1/device-memory/snapshot/export """ + body = _validate_request_body(models.SnapshotExportRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/device-memory/snapshot/export", "path_params": {}, "query": None, + "headers": None, "body": body, - "idempotent": True, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -40,7 +52,9 @@ async def export( async def import_( self, - body: models.SnapshotImportRequest + body: Union[models.SnapshotImportRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None ) -> models.SnapshotImportResult: """Import a snapshot into an ephemeral collection @@ -50,13 +64,17 @@ async def import_( operationId: snapshots.import endpoint: POST /v1/device-memory/snapshot/import """ + body = _validate_request_body(models.SnapshotImportRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/device-memory/snapshot/import", "path_params": {}, "query": None, + "headers": None, "body": body, - "idempotent": False, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: diff --git a/src/nebula/resources/workspaces.py b/src/nebula/resources/workspaces.py index cd4c96c..b89fc95 100644 --- a/src/nebula/resources/workspaces.py +++ b/src/nebula/resources/workspaces.py @@ -2,20 +2,60 @@ # Source: nebula-sdks/openapi/openapi.json from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any, Literal, Mapping, Optional, Union from pydantic import ValidationError from .. import _models as models -from .._runtime import NebulaCore, validate_response as _validate_response +from .._runtime import ( + NebulaCore, + RequestOptions, + prepare_generated_body as _prepare_generated_body, + validate_request_body as _validate_request_body, + validate_response as _validate_response, +) class WorkspacesResource: def __init__(self, core: NebulaCore) -> None: self._core = core + async def create_group( + self, + workspace_id: str, + body: Union[models.GroupCreateRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.GroupResult: + """Create workspace group + + Create a group under an existing team workspace group. + + operationId: workspaces.createGroup + endpoint: POST /v1/workspaces/{workspace_id}/groups + """ + body = _validate_request_body(models.GroupCreateRequest, body) + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/workspaces/{workspace_id}/groups", + "path_params": {"workspace_id": workspace_id}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.GroupResult.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def create_storage_target( self, workspace_id: str, - body: models.StorageTargetCreateRequest + body: Union[models.StorageTargetCreateRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None ) -> models.StorageTargetResponse: """Create workspace storage target @@ -24,13 +64,17 @@ async def create_storage_target( operationId: workspaces.createStorageTarget endpoint: POST /v1/workspaces/{workspace_id}/storage-targets """ + body = _validate_request_body(models.StorageTargetCreateRequest, body) _raw = await self._core.request({ "method": "POST", "path": "/v1/workspaces/{workspace_id}/storage-targets", "path_params": {"workspace_id": workspace_id}, "query": None, + "headers": None, "body": body, - "idempotent": False, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -38,10 +82,100 @@ async def create_storage_target( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def delete( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.WorkspaceMutationProcessingResult, models.WorkspaceMutationCompletedResult, models.WorkspaceMutationFailedResult]: + """Delete Workspace + + Delete a team workspace. Owner only. + + operationId: workspaces.delete + endpoint: DELETE /v1/workspaces/{workspace_id} + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/workspaces/{workspace_id}", + "path_params": {"workspace_id": workspace_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_path"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _validate_response(Union[models.WorkspaceMutationProcessingResult, models.WorkspaceMutationCompletedResult, models.WorkspaceMutationFailedResult], _raw) + + async def delete_group( + self, + workspace_id: str, + group_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.GenericMessageResponse: + """Delete workspace group + + Delete an empty non-root group. Owner/admin only. + + operationId: workspaces.deleteGroup + endpoint: DELETE /v1/workspaces/{workspace_id}/groups/{group_id} + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/workspaces/{workspace_id}/groups/{group_id}", + "path_params": {"workspace_id": workspace_id, "group_id": group_id}, + "query": None, + "headers": None, + "retryable": False, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.GenericMessageResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def disable_managed_encryption( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.WorkspaceEncryptionResponse: + """Disable workspace managed encryption for new writes + + Stop encrypting new writes under the workspace key. The key stays + enabled so existing objects remain readable; scheduling key deletion + is a separate, gated operation. + + operationId: workspaces.disableManagedEncryption + endpoint: POST /v1/workspaces/{workspace_id}/encryption/disable + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/workspaces/{workspace_id}/encryption/disable", + "path_params": {"workspace_id": workspace_id}, + "query": None, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.WorkspaceEncryptionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def disable_storage_target( self, workspace_id: str, - target_id: str + target_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> models.StorageTargetResponse: """Disable workspace storage target @@ -55,7 +189,10 @@ async def disable_storage_target( "path": "/v1/workspaces/{workspace_id}/storage-targets/{target_id}", "path_params": {"workspace_id": workspace_id, "target_id": target_id}, "query": None, - "idempotent": False, + "headers": None, + "retryable": False, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -63,9 +200,102 @@ async def disable_storage_target( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def enable_managed_encryption( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.WorkspaceEncryptionResponse: + """Enable workspace managed encryption + + Provision a dedicated Nebula-managed KMS key for the workspace and + encrypt new graph-plane object writes under it. Forward-only: + existing objects are re-encrypted lazily as data is rewritten, not + immediately. + + operationId: workspaces.enableManagedEncryption + endpoint: POST /v1/workspaces/{workspace_id}/encryption/enable + """ + _raw = await self._core.request({ + "method": "POST", + "path": "/v1/workspaces/{workspace_id}/encryption/enable", + "path_params": {"workspace_id": workspace_id}, + "query": None, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.WorkspaceEncryptionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def get_managed_encryption( + self, + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> models.WorkspaceEncryptionResponse: + """Get workspace managed encryption status + + Current managed-encryption status for the workspace. + + operationId: workspaces.getManagedEncryption + endpoint: GET /v1/workspaces/{workspace_id}/encryption + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/workspaces/{workspace_id}/encryption", + "path_params": {"workspace_id": workspace_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.WorkspaceEncryptionResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def list_groups( + self, + workspace_id: str, + *, + cursor: Optional[Union[str, None]] = None, + limit: Optional[int] = None, + request_options: Optional[RequestOptions] = None + ) -> models.PaginatedGroupResponse: + """List workspace groups + + List groups for a team workspace. + + operationId: workspaces.listGroups + endpoint: GET /v1/workspaces/{workspace_id}/groups + """ + _raw = await self._core.request({ + "method": "GET", + "path": "/v1/workspaces/{workspace_id}/groups", + "path_params": {"workspace_id": workspace_id}, + "query": {"cursor": cursor, "limit": limit}, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + try: + return models.PaginatedGroupResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def list_storage_targets( self, - workspace_id: str + workspace_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> list[models.StorageTargetResponse]: """List workspace storage targets @@ -79,7 +309,10 @@ async def list_storage_targets( "path": "/v1/workspaces/{workspace_id}/storage-targets", "path_params": {"workspace_id": workspace_id}, "query": None, - "idempotent": True, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: @@ -87,10 +320,106 @@ async def list_storage_targets( except (ValidationError, ValueError): return _raw # type: ignore[return-value] + async def remove_member( + self, + workspace_id: str, + user_id: str, + *, + request_options: Optional[RequestOptions] = None + ) -> Union[models.WorkspaceMutationProcessingResult, models.WorkspaceMutationCompletedResult, models.WorkspaceMutationFailedResult]: + """Remove Workspace Member + + Remove a member from a workspace. Owner/admin only. + + operationId: workspaces.removeMember + endpoint: DELETE /v1/workspaces/{workspace_id}/members/{user_id} + """ + _raw = await self._core.request({ + "method": "DELETE", + "path": "/v1/workspaces/{workspace_id}/members/{user_id}", + "path_params": {"workspace_id": workspace_id, "user_id": user_id}, + "query": None, + "headers": None, + "retryable": True, + "http_semantically_idempotent": True, + "mutation_replay_identity": {"source":"intrinsic","name":"resource_path"}, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + return _validate_response(Union[models.WorkspaceMutationProcessingResult, models.WorkspaceMutationCompletedResult, models.WorkspaceMutationFailedResult], _raw) + + async def update_group( + self, + workspace_id: str, + group_id: str, + body: Union[models.GroupUpdateRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.GroupResult: + """Rename workspace group + + Rename a non-root group. Owner/admin only. + + operationId: workspaces.updateGroup + endpoint: PATCH /v1/workspaces/{workspace_id}/groups/{group_id} + """ + body = _validate_request_body(models.GroupUpdateRequest, body) + _raw = await self._core.request({ + "method": "PATCH", + "path": "/v1/workspaces/{workspace_id}/groups/{group_id}", + "path_params": {"workspace_id": workspace_id, "group_id": group_id}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.GroupResult.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + + async def update_group_members( + self, + workspace_id: str, + group_id: str, + body: Union[models.GroupMembershipDeltaRequest, Mapping[str, Any]], + *, + request_options: Optional[RequestOptions] = None + ) -> models.GenericMessageResponse: + """Update workspace group membership + + Atomically add and remove explicit local-group members. + + operationId: workspaces.updateGroupMembers + endpoint: PATCH /v1/workspaces/{workspace_id}/groups/{group_id}/members + """ + body = _validate_request_body(models.GroupMembershipDeltaRequest, body) + _raw = await self._core.request({ + "method": "PATCH", + "path": "/v1/workspaces/{workspace_id}/groups/{group_id}/members", + "path_params": {"workspace_id": workspace_id, "group_id": group_id}, + "query": None, + "headers": None, + "body": body, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, + }) + _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) + try: + return models.GenericMessageResponse.model_validate(_raw) + except (ValidationError, ValueError): + return _raw # type: ignore[return-value] + async def validate_storage_target( self, workspace_id: str, - target_id: str + target_id: str, + *, + request_options: Optional[RequestOptions] = None ) -> models.StorageTargetResponse: """Validate workspace storage target @@ -104,7 +433,10 @@ async def validate_storage_target( "path": "/v1/workspaces/{workspace_id}/storage-targets/{target_id}/validate", "path_params": {"workspace_id": workspace_id, "target_id": target_id}, "query": None, - "idempotent": False, + "headers": None, + "retryable": False, + "http_semantically_idempotent": False, + "timeout_seconds": request_options.timeout_seconds if request_options else None, }) _raw = _raw["results"] if isinstance(_raw, dict) else getattr(_raw, "results", _raw) try: diff --git a/tests/e2e/smoke.py b/tests/e2e/smoke.py index 860765f..81238d4 100644 --- a/tests/e2e/smoke.py +++ b/tests/e2e/smoke.py @@ -16,7 +16,6 @@ import os import sys import time -from uuid import UUID from nebula import ( ClientOptions, @@ -51,20 +50,19 @@ async def main() -> None: _assert(hasattr(page, "has_more") and isinstance(page.has_more, bool), "list_collections returns .has_more: bool") _assert(hasattr(page, "next_cursor"), "list_collections returns .next_cursor (nullable)") - # 2. list_memories — cursor + applied_wal_seq wire shape - memories_page = await client.memories.list(limit=10) + # 2. list_memories — cursor wire shape + memories_page = await client.memory.list(limit=10) _assert(hasattr(memories_page, "data") and isinstance(memories_page.data, list), "list_memories returns .data: list") _assert(hasattr(memories_page, "has_more"), "list_memories returns .has_more: bool") _assert(hasattr(memories_page, "next_cursor"), "list_memories returns .next_cursor (nullable)") - _assert(hasattr(memories_page, "applied_wal_seq"), "list_memories returns .applied_wal_seq (RYW token)") - # 3. Create a throwaway collection — the SDK peels the `{results: X}` - # wire envelope, so the return is the inner CollectionResponse model. - created = await client.collections.create(body={"name": f"e2e-sdk-py-{int(time.time())}"}) - collection_id = getattr(created, "id", None) - # Generated pydantic models coerce id fields to typed UUIDs — that's - # the desired SDK behavior (callers get a real UUID, not a string). - _assert(isinstance(collection_id, UUID), "collections.create returns id: UUID") + # 3. Create a throwaway collection. The SDK generates its identity. + create_token = int(time.time()) + created = await client.collections.create(body={ + "name": f"e2e-sdk-py-{create_token}", + }) + collection_id = created.id + _assert(collection_id is not None, "collection create returns an id") # 4. Retrieve retrieved = await client.collections.retrieve(id=str(collection_id)) @@ -73,7 +71,9 @@ async def main() -> None: # 5. Error envelope round-trip — force a 4xx error_caught = False try: - await client.collections.create(body={"name": ""}) + await client.collections.create(body={ + "name": "", + }) except NebulaAPIError as err: error_caught = True _assert(isinstance(err.type, str), "envelope.type decoded as str") @@ -89,17 +89,22 @@ async def main() -> None: # 6. Memory ingestion (gated) if not SKIP_INGESTION: try: - await client.memories.create(body={ - "kind": "document", - "collection_id": collection_id, + await client.memory.store(body={ + "collection_id": str(collection_id), "raw_text": "e2e smoke test memory", }) except NebulaServerError as err: - print(f" (got 500: {err.type}, likely Hatchet outage — set NEBULA_E2E_SKIP_INGESTION=1 to skip)") + print(f" (got 500: {err.type}, likely Orchestration outage — set NEBULA_E2E_SKIP_INGESTION=1 to skip)") # 7. Clean up - deleted = await client.collections.delete(id=str(collection_id)) - _assert(getattr(deleted, "success", None) is True, "collections.delete returns success: True") + deleted = await client.collections.delete( + id=str(collection_id), + ) + _assert( + deleted.id == collection_id + and deleted.state.value in {"deleting", "deleted"}, + "collections.delete returns the durable collection state", + ) print("\nAll SDK e2e smoke checks passed.") diff --git a/tests/test_dx.py b/tests/test_dx.py index 055a8c8..ee5f00f 100644 --- a/tests/test_dx.py +++ b/tests/test_dx.py @@ -1,11 +1,16 @@ from __future__ import annotations +import warnings from typing import Any +from uuid import UUID import httpx import pytest +from nebula import ClientOptions, Nebula, NebulaClient, inputs, models +from pydantic import ValidationError -from nebula import Nebula, NebulaClient, ClientOptions +RETRIEVAL_OPERATION_ID = "11111111-1111-4111-8111-111111111111" +COLLECTION_ID = "11111111-2222-4333-8444-555555555555" def _make_dx(transport: httpx.MockTransport, **overrides: Any) -> Nebula: @@ -23,82 +28,145 @@ async def test_nebula_extends_nebula_client() -> None: transport = httpx.MockTransport(lambda _r: httpx.Response(204)) client = _make_dx(transport) assert isinstance(client, NebulaClient) - assert client.memories is not None + assert client.memory is not None assert client.collections is not None @pytest.mark.asyncio -async def test_store_memory_create_dispatches_to_create() -> None: +async def test_memory_store_uses_one_document_and_conversation_surface() -> None: captured: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json={"results": {"id": "mem_new"}}) + return httpx.Response( + 202, + json={ + "results": { + "id": "11111111-2222-4333-8444-555555555555", + "memory_write_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) + body = { + "collection_id": COLLECTION_ID, + "messages": [{"role": "user", "content": "hi"}], + } async with _make_dx(httpx.MockTransport(handler)) as client: - new_id = await client.store_memory(collection_id="c1", raw_text="hi") - assert new_id == "mem_new" + result = await client.memory.store(body=body) + assert str(result.id) == "11111111-2222-4333-8444-555555555555" assert captured[0].method == "POST" assert str(captured[0].url) == "https://api.example.com/v1/memories" import json - body = json.loads(captured[0].content) - assert body["collection_id"] == "c1" - assert body["raw_text"] == "hi" + wire_body = json.loads(captured[0].content) + assert wire_body["collection_id"] == COLLECTION_ID + assert wire_body["messages"] == body["messages"] + assert len(wire_body["memory_id"]) == 36 + assert "memory_id" not in body @pytest.mark.asyncio -async def test_store_memory_append_dispatches_when_memory_id_set() -> None: +async def test_typed_memory_inputs_generate_and_preserve_identities() -> None: captured: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json={"results": {"id": "appended"}}) - - async with _make_dx(httpx.MockTransport(handler)) as client: - result = await client.store_memory( - {"memory_id": "mem_existing", "collection_id": "c1", "raw_text": "more"} + body = __import__("json").loads(request.content) + return httpx.Response( + 202, + json={ + "results": { + "id": body["memory_id"], + "memory_write_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, ) - assert result == "mem_existing" - assert str(captured[0].url) == "https://api.example.com/v1/memories/mem_existing/append" + body = inputs.StoreMemoryInput( + collection_id=COLLECTION_ID, + messages=[{"role": "user", "content": "hi"}], + ) + async with _make_dx(httpx.MockTransport(handler)) as client: + result = await client.memory.store(body=body) + + wire_body = __import__("json").loads(captured[0].content) + assert wire_body["memory_id"] == str(body.memory_id) + assert str(result.id) == str(body.memory_id) + + +def test_typed_batch_inputs_generate_nested_identities() -> None: + collection = inputs.CreateCollectionInput(name="Research") + body = inputs.StoreMemoriesInput( + collection_id=COLLECTION_ID, + memories=[ + inputs.StoreMemoryBatchItemInput(raw_text="first"), + inputs.StoreMemoryBatchItemInput( + messages=[{"role": "user", "content": "second"}], + ), + ], + ) -@pytest.mark.asyncio -async def test_store_memory_content_string_maps_to_raw_text() -> None: - captured: list[httpx.Request] = [] + assert collection.collection_id.version == 4 + assert len({item.memory_id for item in body.memories}) == 2 - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"results": {"id": "x"}}) - async with _make_dx(httpx.MockTransport(handler)) as client: - await client.store_memory(collection_id="c1", content="shorthand") - import json - body = json.loads(captured[0].content) - assert body["raw_text"] == "shorthand" - assert "content" not in body +def test_wire_models_keep_generated_identities_required() -> None: + with pytest.raises(ValidationError): + models.CreateCollectionRequest(name="Research") + with pytest.raises(ValidationError): + models.StoreMemoryRequest( + collection_id=COLLECTION_ID, + raw_text="hello", + ) + with pytest.raises(ValidationError): + models.StoreMemoryBatchItem(raw_text="hello") -@pytest.mark.asyncio -async def test_store_memory_messages_sets_kind_conversation() -> None: - captured: list[httpx.Request] = [] +def test_deprecated_response_fields_warn_on_access() -> None: + connection = models.ConnectorConnectionResponse.model_construct( + uses_workspace_oauth_app=True + ) - def handler(request: httpx.Request) -> httpx.Response: - captured.append(request) - return httpx.Response(200, json={"results": {"id": "mem_conv"}}) + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + assert connection.uses_workspace_oauth_app is True + + assert len(captured) == 1 + assert captured[0].category is DeprecationWarning + assert "Use oauth_credential_source" in str(captured[0].message) + + +def test_connection_response_uses_enum_for_omitted_credential_source() -> None: + connection = models.ConnectorConnectionResponse.model_validate( + { + "collection_id": "11111111-1111-1111-1111-111111111111", + "created_at": "2026-08-12T00:00:00Z", + "id": "22222222-2222-2222-2222-222222222222", + "provider": "outlook", + "status": "active", + "updated_at": "2026-08-12T00:00:00Z", + "user_id": "33333333-3333-3333-3333-333333333333", + } + ) - async with _make_dx(httpx.MockTransport(handler)) as client: - await client.store_memory( - collection_id="c1", - messages=[{"role": "user", "content": "hi"}], - ) - import json - body = json.loads(captured[0].content) - assert body["kind"] == "conversation" - assert "engram_type" not in body + assert ( + connection.oauth_credential_source + is models.OauthCredentialSource.default + ) @pytest.mark.asyncio -async def test_memories_search_unwraps_envelope() -> None: +async def test_memory_search_unwraps_envelope() -> None: # SnapshotSearchResult has the shape `{entities, relationships}` — # use it so the union TypeAdapter discriminates to a typed model. def handler(_req: httpx.Request) -> httpx.Response: @@ -108,7 +176,12 @@ def handler(_req: httpx.Request) -> httpx.Response: ) async with _make_dx(httpx.MockTransport(handler)) as client: - result = await client.memories.search(body={"query": "find me"}) + result = await client.memory.search( + body={ + "query": "find me", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + } + ) # The response schema is an inline anyOf of Wrapped* variants — the # generator peels `.results` and TypeAdapter discriminates the inner # dict into the matching union variant. @@ -117,34 +190,83 @@ def handler(_req: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_memories_delete_hits_path_by_id() -> None: +async def test_memory_delete_hits_path_by_id() -> None: captured: list[httpx.Request] = [] + memory_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(204) + return httpx.Response( + 202, + json={ + "results": { + "id": memory_id, + "state": "deleting", + "failure": None, + } + }, + ) async with _make_dx(httpx.MockTransport(handler)) as client: - await client.memories.delete(id="mem_to_delete") + result = await client.memory.delete( + id=memory_id, + collection_id="collection-1", + ) + assert result.state == "deleting" assert captured[0].method == "DELETE" - assert str(captured[0].url) == "https://api.example.com/v1/memories/mem_to_delete" + assert ( + str(captured[0].url) + == f"https://api.example.com/v1/memories/{memory_id}?collection_id=collection-1" + ) + assert captured[0].headers["x-nebula-owner-key"] == "collection:collection-1" @pytest.mark.asyncio -async def test_memories_delete_many_takes_id_list_as_body() -> None: +async def test_memory_delete_many_takes_collection_scoped_body() -> None: captured: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json={"results": {"succeeded": 2}}) + return httpx.Response( + 202, + json={ + "results": { + "memories": [ + { + "id": memory_ids[0], + "state": "deleting", + "failure": None, + }, + { + "id": memory_ids[1], + "state": "deleting", + "failure": None, + }, + ] + } + }, + ) async with _make_dx(httpx.MockTransport(handler)) as client: - await client.memories.delete_many(body=["a", "b"]) + memory_ids = [ + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "11111111-aaaa-4bbb-8ccc-222222222222", + ] + result = await client.memory.delete_many( + body={"collection_id": COLLECTION_ID, "ids": memory_ids} + ) + assert [memory.state for memory in result.memories] == [ + "deleting", + "deleting", + ] assert captured[0].method == "POST" assert str(captured[0].url) == "https://api.example.com/v1/memories/delete" + assert captured[0].headers["x-nebula-owner-key"] == f"collection:{COLLECTION_ID}" import json body = json.loads(captured[0].content) - assert body == ["a", "b"] + assert body["collection_id"] == COLLECTION_ID + assert body["ids"] == memory_ids + assert UUID(body["operation_id"]).version == 4 @pytest.mark.asyncio @@ -161,7 +283,7 @@ def handler(request: httpx.Request) -> httpx.Response: api_key="key_real.token", ) try: - await client.memories.retrieve(id="m1") + await client.memory.list() finally: await client.aclose() @@ -169,17 +291,29 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_collections_delete_returns_unwrapped_success() -> None: - def handler(_req: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"results": {"success": True}}) +async def test_collections_delete_returns_durable_product_state() -> None: + captured: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + captured.append(req) + return httpx.Response( + 202, + json={ + "results": { + "id": "123e4567-e89b-42d3-a456-426614174000", + "state": "deleting", + "failure": None, + } + }, + ) async with _make_dx(httpx.MockTransport(handler)) as client: - resp = await client.collections.delete(id="c1") - # Wire envelope `{"results": {"success": True}}` peeled by the - # generator → caller sees `GenericBooleanResponse(success=True)` - # directly. No DX coercion to a bare bool (that was a removed - # convenience). - assert getattr(resp, "success", None) is True + resp = await client.collections.delete( + id="123e4567-e89b-42d3-a456-426614174000" + ) + assert str(resp.id) == "123e4567-e89b-42d3-a456-426614174000" + assert resp.state == "deleting" + assert "Idempotency-Key" not in captured[0].headers @pytest.mark.asyncio @@ -195,3 +329,34 @@ def handler(request: httpx.Request) -> httpx.Response: assert "collection_ids=collection-abc" in str(captured[0].url) +@pytest.mark.asyncio +async def test_connect_provider_can_select_saved_workspace_oauth_app() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "results": { + "auth_url": "https://provider.example/auth", + "state": "state", + } + }, + ) + + async with _make_dx(httpx.MockTransport(handler)) as client: + await client.connect_provider( + "gmail", + COLLECTION_ID, + oauth_client_mode="workspace", + ) + + assert str(captured[0].url) == ( + "https://api.example.com/v1/connectors/gmail/connect" + ) + import json + + body = json.loads(captured[0].content) + assert body["collection_id"] == COLLECTION_ID + assert body["oauth_client_mode"] == "workspace" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index cd13948..96b4594 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,21 +1,26 @@ from __future__ import annotations import asyncio +import json from typing import Any +from uuid import UUID import httpx import pytest -import respx -from pydantic import ValidationError - from nebula import ( ClientOptions, NebulaClient, NebulaNotFoundError, NebulaRateLimitError, + NebulaServerError, + NebulaTimeoutError, NebulaValidationError, + RequestOptions, models, ) +from pydantic import ValidationError + +RETRIEVAL_OPERATION_ID = "11111111-1111-4111-8111-111111111111" def _make_client(transport: httpx.MockTransport, **overrides: Any) -> NebulaClient: @@ -28,19 +33,39 @@ def _make_client(transport: httpx.MockTransport, **overrides: Any) -> NebulaClie return NebulaClient(options) -def test_create_collection_model_rejects_blank_name() -> None: - with pytest.raises(ValidationError): - models.CreateCollectionRequest(name="") +def _collection_mutation_response() -> dict[str, Any]: + return { + "id": "123e4567-e89b-42d3-a456-426614174000", + "state": "provisioning", + "failure": None, + } -def test_create_collection_model_rejects_whitespace_name() -> None: +@pytest.mark.parametrize( + ("request_model", "name"), + [ + (models.CreateCollectionRequest, ""), + (models.CreateCollectionRequest, " "), + (models.UpdateCollectionRequest, " "), + ], +) +def test_collection_models_reject_blank_names(request_model: Any, name: str) -> None: with pytest.raises(ValidationError): - models.CreateCollectionRequest(name=" ") + request_model(name=name) -def test_update_collection_model_rejects_whitespace_name() -> None: +def test_memory_search_model_distinguishes_omitted_from_nullable_fields() -> None: + request = models.MemorySearchRequest(query="hello", effort=None) + + assert request.include_sources is False + assert request.effort is None with pytest.raises(ValidationError): - models.UpdateCollectionRequest(name=" ") + models.MemorySearchRequest(query="hello", include_sources=None) + + +def test_memory_search_model_rejects_unknown_typed_field() -> None: + with pytest.raises(ValidationError, match="include_source"): + models.MemorySearchRequest(query="hello", include_source=True) @pytest.mark.asyncio @@ -53,10 +78,13 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport, api_key="secret") as client: - result = await client.memories.search(body={"query": "hello"}) + result = await client.memory.search( + body={ + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + } + ) - # Inline-anyOf envelope unwrap: caller sees the inner value (the - # empty array here), not the `{results: ...}` wire shape. assert result == [] assert len(captured) == 1 req = captured[0] @@ -64,8 +92,397 @@ def handler(request: httpx.Request) -> httpx.Response: assert str(req.url) == "https://api.example.com/v1/memories/search" assert req.headers["authorization"] == "Bearer secret" assert req.headers["content-type"] == "application/json" + assert json.loads(req.content) == { + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + } + + +@pytest.mark.parametrize( + ("resource", "method", "body", "invalid_field"), + [ + ( + "memory", + "search", + {"query": "hello", "include_sources": None}, + "include_sources", + ), + ( + "memory", + "search", + {"query": "hello", "include_source": True}, + "include_source", + ), + ( + "collections", + "create", + {"name": "Support", "workflow_enabled": True}, + "workflow_enabled", + ), + ( + "memory", + "search", + {"query": "hello", "search_settings": {"include_score": True}}, + "include_score", + ), + ( + "memory", + "recall_workflow", + { + "intent": "bootstrap", + "collection_id": "11111111-2222-3333-4444-555555555555", + "top_kk": 5, + }, + "top_kk", + ), + ], +) +@pytest.mark.asyncio +async def test_invalid_request_dictionary_is_rejected_before_transport( + resource: str, + method: str, + body: dict[str, Any], + invalid_field: str, +) -> None: + def fail_on_request(_request: httpx.Request) -> httpx.Response: + pytest.fail("invalid request reached transport") + + async with _make_client(httpx.MockTransport(fail_on_request)) as client: + request_method = getattr(getattr(client, resource), method) + with pytest.raises(ValidationError, match=invalid_field): + await request_method(body=body) + + +@pytest.mark.asyncio +async def test_resolve_search_sources_parses_direct_source_union() -> None: + retrieval_id = "11111111-1111-4111-8111-111111111111" + collection_id = "22222222-2222-4222-8222-222222222222" + chunk_id = "33333333-3333-4333-8333-333333333333" + source_id = "44444444-4444-4444-8444-444444444444" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == f"/v1/memories/searches/{retrieval_id}/sources" + evidence_ref = { + "ref_type": "chunk", + "chunk_id": chunk_id, + "collection_id": collection_id, + } + return httpx.Response( + 200, + json={ + "results": { + "retrieval_id": retrieval_id, + "status": "partial", + "future_response_field": "accepted", + "groups": [ + { + "memory_id": source_id, + "memory_kind": "semantic", + "rank": 0, + "activation_score": 0.9, + "sources": [ + { + "status": "available", + "evidence_ref": evidence_ref, + "id": source_id, + "text": "grounding text", + "activation_score": 0.8, + "supporting_fact_ids": [], + "future_source_field": "accepted", + }, + { + "status": "unavailable", + "evidence_ref": evidence_ref, + }, + ], + } + ], + } + }, + ) + + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + response = await client.memory.resolve_search_sources(retrieval_id) + + sources = response.groups[0].sources + assert isinstance(sources[0], models.AvailableRetrievalAuditSource) + assert sources[0].status == "available" + assert isinstance(sources[1], models.UnavailableRetrievalAuditSource) + assert sources[1].status == "unavailable" + assert not hasattr(sources[0], "root") + + +def test_resolved_search_source_arrays_are_required_and_accept_empty_lists() -> None: + retrieval_id = "11111111-1111-4111-8111-111111111111" + response = models.RetrievalAuditSourcesResponse.model_validate( + { + "retrieval_id": retrieval_id, + "status": "complete", + "groups": [], + } + ) + + assert response.groups == [] + with pytest.raises(ValidationError, match="groups"): + models.RetrievalAuditSourcesResponse.model_validate( + {"retrieval_id": retrieval_id, "status": "complete"} + ) + + group = { + "memory_id": retrieval_id, + "memory_kind": "semantic", + "rank": 0, + "activation_score": 0.9, + } + with pytest.raises(ValidationError, match="sources"): + models.RetrievalAuditSourceGroup.model_validate(group) + parsed_group = models.RetrievalAuditSourceGroup.model_validate( + {**group, "sources": []} + ) + assert parsed_group.sources == [] + + +@pytest.mark.asyncio +async def test_request_validation_preserves_open_dictionary_fields() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": [], "total_entries": 0}) + + body = { + "query": "hello", + "filters": {"custom_operator": {"arbitrary_key": [1, 2]}}, + "search_settings": { + "graph_settings": {"provider_extension": {"depth": 3}} + }, + } + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + await client.memory.search(body=body) + import json - assert json.loads(req.content) == {"query": "hello"} + sent = json.loads(captured[0].content) + assert sent["filters"] == body["filters"] + assert sent["search_settings"]["graph_settings"] == body["search_settings"][ + "graph_settings" + ] + + +@pytest.mark.asyncio +async def test_single_collection_search_derives_edge_routing_header() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": [], "total_entries": 0}) + + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + await client.memory.search( + body={ + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + "collection_ids": ["33333333-3333-4333-8333-333333333333"], + } + ) + await client.memory.search( + body={ + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + "collection_ids": [ + "33333333-3333-4333-8333-333333333333", + "44444444-4444-4444-8444-444444444444", + ], + } + ) + + assert ( + captured[0].headers["x-nebula-owner-key"] + == "collection:33333333-3333-4333-8333-333333333333" + ) + assert "x-nebula-owner-key" not in captured[1].headers + + +@pytest.mark.asyncio +async def test_workspace_scoped_upload_uses_canonical_query_routing() -> None: + captured: list[httpx.Request] = [] + workspace_id = "22222222-2222-4222-8222-222222222222" + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "results": { + "upload_session_id": "33333333-3333-4333-8333-333333333333", + "part_size": 8388608, + "expires_in": 3600, + "max_size": 104857600, + } + }, + ) + + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + await client.memory.create_upload( + filename="image.png", + content_type="image/png", + file_size=12, + workspace_id=workspace_id, + ) + + assert captured[0].url.path == "/v1/memories/upload" + assert captured[0].url.params["workspace_id"] == workspace_id + assert "x-nebula-owner-key" not in captured[0].headers + + +@pytest.mark.asyncio +async def test_filter_scoped_search_derives_edge_routing_header() -> None: + captured: list[httpx.Request] = [] + collection_id = "33333333-3333-4333-8333-333333333333" + other_collection_id = "44444444-4444-4444-8444-444444444444" + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"results": [], "total_entries": 0}) + + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + await client.memory.search( + body={ + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + "filters": { + "collection_ids": {"$overlap": [collection_id]}, + }, + } + ) + await client.memory.search( + body={ + "query": "hello", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + "filters": { + "collection_ids": { + "$overlap": [collection_id, other_collection_id] + } + }, + } + ) + assert captured[0].headers["x-nebula-owner-key"] == f"collection:{collection_id}" + assert "x-nebula-owner-key" not in captured[1].headers + + +@pytest.mark.asyncio +async def test_write_calls_derive_edge_routing_headers_from_body_ids() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if request.url.path == "/v1/collections": + return httpx.Response( + 202, + json={"results": _collection_mutation_response()}, + ) + if request.url.path == "/v1/memories": + return httpx.Response( + 202, + json={ + "results": { + "id": "11111111-2222-4333-8444-555555555555", + "memory_write_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) + if request.url.path == ( + "/v1/memories/44444444-4444-4444-8444-444444444444/append" + ): + return httpx.Response( + 202, + json={ + "results": { + "id": "44444444-4444-4444-8444-444444444444", + "memory_write_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) + return httpx.Response(200, json={"results": {"message": "ok"}}) + + transport = httpx.MockTransport(handler) + options = ClientOptions( + base_url="https://api.example.com", + transport=transport, + ) + async with NebulaClient(options) as client: + await client.collections.create( + body={"name": "Personal collection"} + ) + await client.collections.create( + body={ + "name": "Team collection", + "workspace_id": "22222222-2222-4222-8222-222222222222", + } + ) + await client.memory.store( + body={ + "collection_id": "33333333-3333-4333-8333-333333333333", + "kind": "document", + "raw_text": "hello", + } + ) + await client.memory.append( + id="44444444-4444-4444-8444-444444444444", + body={ + "collection_id": "55555555-5555-4555-8555-555555555555", + "raw_text": "more", + }, + ) + + assert "x-nebula-owner-key" not in captured[0].headers + assert ( + captured[1].headers["x-nebula-owner-key"] + == "workspace:22222222-2222-4222-8222-222222222222" + ) + assert ( + captured[2].headers["x-nebula-owner-key"] + == "collection:33333333-3333-4333-8333-333333333333" + ) + assert ( + captured[3].headers["x-nebula-owner-key"] + == "collection:55555555-5555-4555-8555-555555555555" + ) + + +@pytest.mark.asyncio +async def test_zero_config_personal_collection_create_omits_routing_header() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 202, + json={"results": _collection_mutation_response()}, + ) + + transport = httpx.MockTransport(handler) + async with _make_client(transport) as client: + await client.collections.create( + body={"name": "Personal collection"} + ) + + assert "x-nebula-owner-key" not in captured[0].headers + @pytest.mark.asyncio @@ -100,12 +517,18 @@ def handler(request: httpx.Request) -> httpx.Response: # peels `.results` before model-validating into Engram. return httpx.Response( 200, - json={"results": {"id": "abc", "kind": "document"}}, + json={ + "results": { + "id": "11111111-2222-4333-8444-555555555555", + "kind": "conversation", + "owner_id": "22222222-3333-4444-8555-666666666666", + } + }, ) transport = httpx.MockTransport(handler) async with _make_client(transport) as client: - await client.memories.retrieve(id="11111111-2222-3333-4444-555555555555") + await client.memory.get(id="11111111-2222-3333-4444-555555555555") assert str(captured[0].url) == ( "https://api.example.com/v1/memories/11111111-2222-3333-4444-555555555555" @@ -120,7 +543,7 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport) as client: with pytest.raises(NebulaValidationError): - await client.memories.search(body={}) + await client.memory.search(body={}) @pytest.mark.asyncio @@ -131,11 +554,11 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport) as client: with pytest.raises(NebulaNotFoundError): - await client.memories.retrieve(id="missing") + await client.memory.get(id="missing") @pytest.mark.asyncio -async def test_does_not_retry_post_when_not_idempotent() -> None: +async def test_does_not_retry_non_retryable_upload_creation() -> None: attempts = 0 def handler(_request: httpx.Request) -> httpx.Response: @@ -148,14 +571,124 @@ def handler(_request: httpx.Request) -> httpx.Response: async with _make_client( transport, retry=RetryPolicy(max_retries=5, base_seconds=0.001, max_seconds=0.005) ) as client: - with pytest.raises(Exception): - await client.memories.create(body={"collection_id": "c1"}) + with pytest.raises(NebulaServerError): + await client.memory.create_upload( + filename="file.txt", + content_type="text/plain", + file_size=5, + ) assert attempts == 1 @pytest.mark.asyncio -async def test_retries_get_on_503() -> None: +async def test_memory_store_retries_identical_generated_body_with_deadline() -> None: + attempts = 0 + bodies: list[bytes] = [] + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + bodies.append(request.content) + if attempts == 1: + return httpx.Response(503, json={"detail": "warming up"}) + return httpx.Response( + 202, + json={ + "results": { + "id": "11111111-1111-4111-8111-111111111111", + "memory_write_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) + + from nebula import RetryPolicy + + async with _make_client( + httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=1, base_seconds=0, max_seconds=0), + ) as client: + result = await client.memory.store( + body={ + "collection_id": "44444444-4444-4444-8444-444444444444", + "messages": [{"role": "user", "content": "hello"}], + }, + request_options=RequestOptions(timeout_seconds=1), + ) + + assert str(result.id) == "11111111-1111-4111-8111-111111111111" + assert attempts == 2 + assert bodies[0] == bodies[1] + + +@pytest.mark.asyncio +async def test_wrong_region_misdirect_is_retried_against_converged_directory() -> None: + attempts = 0 + bodies: list[bytes] = [] + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + bodies.append(request.content) + if attempts == 1: + return httpx.Response( + 421, + json={ + "type": "misdirected_request", + "code": "wrong_write_region", + "message": "collection is owned by another region", + "request_id": None, + "details": { + "error_type": "wrong_write_region", + "subject": "collection", + "subject_id": "44444444-4444-4444-8444-444444444444", + "home_region": "us-west-2", + "local_region": "us-east-1", + "region_owner_epoch": 7, + }, + }, + ) + return httpx.Response( + 202, + json={ + "results": { + "id": "11111111-1111-4111-8111-111111111111", + "memory_write_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) + + from nebula import RetryPolicy + + async with _make_client( + httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=1, base_seconds=0, max_seconds=0), + ) as client: + result = await client.memory.store( + body={ + "collection_id": "44444444-4444-4444-8444-444444444444", + "messages": [{"role": "user", "content": "hello"}], + }, + request_options=RequestOptions(timeout_seconds=1), + ) + + assert str(result.id) == "11111111-1111-4111-8111-111111111111" + assert attempts == 2 + assert bodies[0] == bodies[1] + + +@pytest.mark.asyncio +async def test_retries_only_when_operation_is_marked_retryable() -> None: attempts = 0 def handler(_request: httpx.Request) -> httpx.Response: @@ -169,14 +702,23 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) from nebula import RetryPolicy - async with _make_client( - transport, retry=RetryPolicy(max_retries=3, base_seconds=0.001, max_seconds=0.005) - ) as client: - result = await client.collections.list(limit=10) - - # `result` is now a validated Pydantic model (the model_validate fix); - # `model_dump()` round-trips back to the wire shape. - assert result.model_dump() == { + from nebula._runtime import NebulaCore + core = NebulaCore(ClientOptions( + base_url="https://api.example.com", + transport=transport, + retry=RetryPolicy(max_retries=3, base_seconds=0.001, max_seconds=0.005), + )) + try: + result = await core.request({ + "method": "GET", + "path": "/v1/collections", + "retryable": True, + "http_semantically_idempotent": True, + }) + finally: + await core.aclose() + + assert result == { "data": [], "next_cursor": None, "has_more": False, @@ -184,27 +726,271 @@ def handler(_request: httpx.Request) -> httpx.Response: assert attempts == 3 +@pytest.mark.asyncio +async def test_http_semantic_idempotence_alone_does_not_enable_retries() -> None: + attempts = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(503, json={"detail": "down"}) + + from nebula import RetryPolicy + from nebula._runtime import NebulaCore + core = NebulaCore(ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=3, base_seconds=0.001, max_seconds=0.005), + )) + try: + with pytest.raises(NebulaServerError): + await core.request({ + "method": "PUT", + "path": "/v1/things", + "http_semantically_idempotent": True, + "retryable": False, + }) + finally: + await core.aclose() + assert attempts == 1 + + +@pytest.mark.asyncio +async def test_freezes_encoded_request_body_across_transport_retries() -> None: + body = {"value": "original"} + encoded_bodies: list[bytes] = [] + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + encoded_bodies.append(request.content) + if attempts == 1: + body["value"] = "mutated" + raise httpx.ConnectError("connection reset", request=request) + return httpx.Response(200, json={"ok": True}) + + from nebula import RetryPolicy + from nebula._runtime import NebulaCore + core = NebulaCore(ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=1, base_seconds=0, max_seconds=0), + )) + try: + await core.request({ + "method": "PUT", + "path": "/v1/things", + "body": body, + "retryable": True, + }) + finally: + await core.aclose() + assert encoded_bodies == [b'{"value":"original"}', b'{"value":"original"}'] + + +@pytest.mark.asyncio +async def test_retryable_request_reconciles_after_transport_timeout() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise httpx.ReadTimeout("response was lost", request=request) + return httpx.Response(200, json={"ok": True}) + + from nebula import RetryPolicy + from nebula._runtime import NebulaCore + + core = NebulaCore( + ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + retry=RetryPolicy( + max_retries=1, + base_seconds=0, + max_seconds=0, + ), + ) + ) + try: + result = await core.request( + { + "method": "POST", + "path": "/v1/collections", + "body": { + "collection_id": "123e4567-e89b-12d3-a456-426614174000" + }, + "retryable": True, + } + ) + finally: + await core.aclose() + + assert result == {"ok": True} + assert attempts == 2 + + +@pytest.mark.asyncio +async def test_transport_timeout_retry_cannot_exceed_invocation_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ReadTimeout("response was lost", request=request) + + monkeypatch.setattr( + "nebula._runtime.retry.random.uniform", lambda _low, _high: 1.0 + ) + from nebula import RetryPolicy + from nebula._runtime import NebulaCore + + core = NebulaCore( + ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + retry=RetryPolicy( + max_retries=3, + base_seconds=1, + max_seconds=1, + ), + ) + ) + try: + with pytest.raises(NebulaTimeoutError): + await core.request( + { + "method": "POST", + "path": "/v1/collections", + "body": { + "collection_id": "123e4567-e89b-12d3-a456-426614174000" + }, + "retryable": True, + "timeout_seconds": 0.01, + } + ) + finally: + await core.aclose() + + assert attempts == 1 + + +@pytest.mark.asyncio +async def test_retry_after_cannot_exceed_total_invocation_budget() -> None: + attempts = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response( + 503, + json={"detail": "busy"}, + headers={"Retry-After": "10"}, + ) + + from nebula import RetryPolicy + from nebula._runtime import NebulaCore + core = NebulaCore(ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=3, base_seconds=0.001, max_seconds=0.002), + )) + try: + with pytest.raises(NebulaTimeoutError): + await core.request({ + "method": "GET", + "path": "/v1/things", + "retryable": True, + "timeout_seconds": 0.02, + }) + finally: + await core.aclose() + assert attempts == 1 + + +def test_retry_after_is_a_floor_over_ordinary_jitter(monkeypatch: pytest.MonkeyPatch) -> None: + from nebula import RetryPolicy + from nebula._runtime.retry import backoff_seconds + + monkeypatch.setattr("nebula._runtime.retry.random.uniform", lambda _low, _high: 0.9) + policy = RetryPolicy(max_retries=1, base_seconds=1.0, max_seconds=1.0) + + assert backoff_seconds(0, policy, retry_after=0.1) == 0.9 + assert backoff_seconds(0, policy, retry_after=2.0) == 2.0 + + +@pytest.mark.asyncio +async def test_total_deadline_is_enforced_when_transport_does_not_timeout() -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + await asyncio.sleep(60) + return httpx.Response(200, json={"ok": True}) + + from nebula._runtime import NebulaCore + core = NebulaCore(ClientOptions( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + )) + try: + with pytest.raises(NebulaTimeoutError): + await core.request({ + "method": "GET", + "path": "/v1/things", + "timeout_seconds": 0.01, + }) + finally: + await core.aclose() + + +@pytest.mark.asyncio +async def test_generated_method_accepts_total_timeout_request_options() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": {"message": "ok"}}) + + async with _make_client(httpx.MockTransport(handler)) as client: + result = await client.client.health( + request_options=RequestOptions(timeout_seconds=1.0) + ) + assert result.message == "ok" + + @pytest.mark.asyncio async def test_request_body_accepts_pydantic_model_instance() -> None: """Regression: previously the runtime passed a BaseModel to httpx.json, which raised TypeError. The runtime now dumps BaseModels via - model_dump(mode='json', by_alias=True) before httpx. + model_dump(mode='json', by_alias=True, exclude_unset=True) before httpx. """ captured: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json={"results": {"id": "mem_1", "message": "ok"}}) + return httpx.Response( + 200, + json={ + "results": { + "id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "memory_write_id": "99999999-9999-4999-8999-999999999999", + "outcome": "processing", + "searchable": False, + "fully_processed": False, + "failure": None, + "projection_version": 1, + } + }, + ) transport = httpx.MockTransport(handler) - body = models.CreateMemoryRequest( + body = models.StoreMemoryRequest( + memory_id="aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", collection_id="11111111-2222-3333-4444-555555555555", raw_text="hello world", ) async with _make_client(transport) as client: - await client.memories.create(body=body) + await client.memory.store(body=body) - import json sent = json.loads(captured[0].content) assert sent["collection_id"] == "11111111-2222-3333-4444-555555555555" assert sent["raw_text"] == "hello world" @@ -214,22 +1000,47 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_request_body_list_of_strings_passes_through_unchanged() -> None: - """`DeleteMemoriesRequest` is `anyOf[str, list[str]]` — confirms the - list path in _serialize_body doesn't mangle plain-string lists.""" +async def test_delete_many_collection_scoped_body_sets_routing_header() -> None: captured: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: captured.append(request) - return httpx.Response(200, json={"results": {"succeeded": 2}}) + return httpx.Response( + 202, + json={ + "results": { + "memories": [ + { + "id": memory_id, + "state": "deleting", + "failure": None, + } + for memory_id in memory_ids + ] + } + }, + ) transport = httpx.MockTransport(handler) + collection_id = "11111111-2222-3333-4444-555555555555" + memory_ids = [ + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "11111111-aaaa-bbbb-cccc-222222222222", + ] async with _make_client(transport) as client: - await client.memories.delete_many(body=["id-1", "id-2"]) + result = await client.memory.delete_many( + body={"collection_id": collection_id, "ids": memory_ids} + ) - import json + assert [memory.state for memory in result.memories] == [ + "deleting", + "deleting", + ] sent = json.loads(captured[0].content) - assert sent == ["id-1", "id-2"] + assert captured[0].headers["x-nebula-owner-key"] == f"collection:{collection_id}" + assert sent["collection_id"] == collection_id + assert sent["ids"] == memory_ids + assert UUID(sent["operation_id"]).version == 4 def test_serialize_body_handles_list_of_pydantic_models() -> None: @@ -240,8 +1051,15 @@ def test_serialize_body_handles_list_of_pydantic_models() -> None: from nebula._runtime.client import _serialize_body items = [ - models.CreateCollectionRequest(name="alpha"), - models.CreateCollectionRequest(name="beta", description="b"), + models.CreateCollectionRequest( + collection_id="aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + name="alpha", + ), + models.CreateCollectionRequest( + collection_id="11111111-2222-4333-8444-555555555555", + name="beta", + description="b", + ), ] out = _serialize_body(items) assert isinstance(out, list) @@ -266,7 +1084,7 @@ async def test_429_retry_after_accepts_http_date() -> None: """RFC 7231 allows Retry-After as either numeric-seconds or HTTP-date. The Python runtime now matches the TS runtime's parser behavior. """ - from datetime import datetime, timezone, timedelta + from datetime import datetime, timedelta, timezone from email.utils import format_datetime future = datetime.now(timezone.utc) + timedelta(seconds=30) @@ -281,7 +1099,7 @@ def handler(_req: httpx.Request) -> httpx.Response: transport, retry=RetryPolicy(max_retries=0, base_seconds=0.001, max_seconds=0.005) ) as client: with pytest.raises(NebulaRateLimitError) as excinfo: - await client.memories.retrieve(id="x") + await client.memory.get(id="x") # ~30 seconds (allow a small drift window for the time between header # construction and parser invocation). @@ -300,11 +1118,32 @@ def handler(_request: httpx.Request) -> httpx.Response: transport, retry=RetryPolicy(max_retries=0, base_seconds=0.001, max_seconds=0.005) ) as client: with pytest.raises(NebulaRateLimitError) as excinfo: - await client.memories.retrieve(id="x") + await client.memory.get(id="x") assert excinfo.value.retry_after == 2.0 +@pytest.mark.asyncio +async def test_malformed_retry_after_is_not_parsed_as_delta_seconds() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"detail": "slow"}, + headers={"Retry-After": "1x"}, + ) + + from nebula import RetryPolicy + + async with _make_client( + httpx.MockTransport(handler), + retry=RetryPolicy(max_retries=0, base_seconds=0, max_seconds=0), + ) as client: + with pytest.raises(NebulaRateLimitError) as excinfo: + await client.memory.get(id="x") + + assert excinfo.value.retry_after is None + + @pytest.mark.asyncio async def test_canonical_envelope_populates_type_code_details_request_id() -> None: envelope = { @@ -325,7 +1164,12 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport) as client: with pytest.raises(NebulaValidationError) as excinfo: - await client.memories.search(body={"query": "x"}) + await client.memory.search( + body={ + "query": "x", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + } + ) err = excinfo.value assert err.type == "validation_error" @@ -359,7 +1203,12 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport) as client: with pytest.raises(NebulaValidationError) as excinfo: - await client.memories.search(body={"query": "x"}) + await client.memory.search( + body={ + "query": "x", + "retrieval_operation_id": RETRIEVAL_OPERATION_ID, + } + ) err = excinfo.value assert isinstance(err.details, list) @@ -378,12 +1227,67 @@ def handler(_request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) async with _make_client(transport) as client: with pytest.raises(NebulaNotFoundError) as excinfo: - await client.memories.retrieve(id="nope") + await client.memory.get(id="nope") err = excinfo.value assert err.type is None assert err.code is None assert err.details is None + assert err.public_error is None # Falls back to the transport header when the body isn't an envelope. assert err.request_id == "rid-fallback" assert str(err) == "Nebula API error (status 404)" + + +@pytest.mark.asyncio +async def test_closed_public_error_is_validated_on_exception() -> None: + envelope = { + "type": "not_found", + "message": "missing", + "code": "resource_not_found", + "request_id": "rid-public", + "details": None, + } + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json=envelope) + + async with _make_client(httpx.MockTransport(handler)) as client: + with pytest.raises(NebulaNotFoundError) as excinfo: + await client.memory.get(id="nope") + + public_error = excinfo.value.public_error + assert public_error is not None + assert public_error.root.code == "resource_not_found" + + +def test_public_api_error_validates_real_discriminator_codes() -> None: + validation = models.PublicApiError.model_validate( + { + "type": "validation_error", + "message": "invalid request", + "code": "validation_error", + "request_id": "request-1", + "details": { + "issues": [ + { + "code": "invalid", + "path": ["body", "messages"], + "message": "messages are required", + } + ] + }, + } + ) + not_found = models.PublicApiError.model_validate( + { + "type": "not_found", + "message": "missing", + "code": "resource_not_found", + "request_id": "request-2", + "details": None, + } + ) + + assert validation.root.code == "validation_error" + assert not_found.root.code == "resource_not_found"