Merge branch 'main' into fix/deriver-dont-silently-drop-saves
Bring in lazy provider SDK loading (#1011). New on_oversize tests now patch openai.AsyncOpenAI, the same seam _build_openai_client uses after the import moved inside _EmbeddingClient.__init__.
This commit is contained in:
commit
d1889b14c7
|
|
@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
### Added
|
||||
|
||||
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (403 otherwise) (#882)
|
||||
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882)
|
||||
- Bare-list membership sugar in the filter DSL: `{"session_id": ["s1", "s2"]}` is now shorthand for `{"session_id": {"in": [...]}}` on regular columns generically. JSONB metadata columns are excluded and keep containment semantics. Strictly additive, since a bare list on a regular column previously compiled to a type-mismatched equality that matched nothing (#881)
|
||||
- Optional structured outputs on the Dialectic: `response_format` (a JSON Schema with root type `object`) on peer chat makes `content` a JSON string conforming to that schema. Only a conservative subset of JSON Schema is supported, with DoS guards and non-recursive `$ref` support (#896)
|
||||
- Combined tool calling and structured output in the LLM transport layer, with per-backend request shaping: OpenAI routes tool-carrying structured requests through `create()` with an explicit `json_schema` response format (`parse()` 500s on non-strict function tools), Anthropic skips the `{` JSON prefill when tools are present so `tool_use` blocks stay reachable, and Gemini injects a schema instruction into the final turn instead of using native `response_schema` (rejected alongside function calling before Gemini 3). All backends skip structured-output parsing on tool-call turns, which carry no consumable content (#907)
|
||||
|
|
|
|||
44
Dockerfile
44
Dockerfile
|
|
@ -1,10 +1,11 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# https://pythonspeed.com/articles/base-image-python-docker-images/
|
||||
# https://testdriven.io/blog/docker-best-practices/
|
||||
FROM python:3.13-slim-bookworm
|
||||
FROM python:3.13-slim-bookworm AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv
|
||||
|
||||
# Set Working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Enable bytecode compilation
|
||||
|
|
@ -20,21 +21,42 @@ ENV PYTHONUNBUFFERED=1
|
|||
# Copy only requirements to cache them in docker layer
|
||||
COPY uv.lock pyproject.toml /app/
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
# Optionall include lancedb with:
|
||||
# docker build --build-arg INSTALL_LANCEDB=true .
|
||||
ARG INSTALL_LANCEDB=false
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-project --no-group dev
|
||||
if [ "$INSTALL_LANCEDB" = "true" ]; then \
|
||||
uv sync --frozen --no-install-project --no-group dev --extra lancedb; \
|
||||
elif [ "$INSTALL_LANCEDB" = "false" ]; then \
|
||||
uv sync --frozen --no-install-project --no-group dev; \
|
||||
else \
|
||||
echo "INSTALL_LANCEDB must be 'true' or 'false'" >&2; \
|
||||
exit 2; \
|
||||
fi
|
||||
|
||||
# Sync the project
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-group dev
|
||||
FROM python:3.13-slim-bookworm AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Create the runtime user before copying dependencies with their final owner.
|
||||
# A recursive chown in a later layer would copy the whole virtualenv and nearly
|
||||
# double the image size.
|
||||
RUN addgroup --system app \
|
||||
&& adduser --system --group app \
|
||||
&& chown app:app /app \
|
||||
# Pre-create the LanceDB dir so a named volume mounted here inherits app
|
||||
# ownership instead of defaulting to root.
|
||||
&& mkdir /app/lancedb_data \
|
||||
&& chown app:app /app/lancedb_data
|
||||
|
||||
COPY --from=builder --chown=app:app /app/.venv /app/.venv
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV HOME=/app
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
|
||||
# Create non-root user and set ownership
|
||||
RUN addgroup --system app && adduser --system --group app && mkdir -p /tmp/uv-cache && chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
COPY --chown=app:app src/ /app/src/
|
||||
COPY --chown=app:app migrations/ /app/migrations/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
# cp docker-compose.yml.example docker-compose.yml
|
||||
# cp .env.template .env # edit with your provider config
|
||||
# docker compose up -d --build
|
||||
# INSTALL_LANCEDB=true docker compose up -d --build # optional local vector store
|
||||
#
|
||||
# By default, ports are bound to 127.0.0.1 (localhost only).
|
||||
# For development, uncomment the source mounts and monitoring services below.
|
||||
|
|
@ -13,6 +14,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
INSTALL_LANCEDB: ${INSTALL_LANCEDB:-false}
|
||||
entrypoint: ["sh", "docker/entrypoint.sh"]
|
||||
depends_on:
|
||||
database:
|
||||
|
|
@ -33,10 +36,12 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
# -- Development: mount source for live reload --
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
volumes:
|
||||
# Shared LanceDB data (used when VECTOR_STORE_TYPE=lancedb)
|
||||
- lancedb-data:/app/lancedb_data
|
||||
# -- Development: mount source for live reload --
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
|
|
@ -50,6 +55,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
INSTALL_LANCEDB: ${INSTALL_LANCEDB:-false}
|
||||
entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"]
|
||||
depends_on:
|
||||
api:
|
||||
|
|
@ -58,10 +65,12 @@ services:
|
|||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# -- Development: mount source for live reload --
|
||||
# volumes:
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
volumes:
|
||||
# Shared LanceDB data (used when VECTOR_STORE_TYPE=lancedb)
|
||||
- lancedb-data:/app/lancedb_data
|
||||
# -- Development: mount source for live reload --
|
||||
# - .:/app
|
||||
# - venv:/app/.venv
|
||||
environment:
|
||||
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
|
||||
- CACHE_URL=redis://redis:6379/0?suppress=true
|
||||
|
|
@ -137,6 +146,7 @@ services:
|
|||
volumes:
|
||||
pgdata:
|
||||
redis-data:
|
||||
lancedb-data:
|
||||
# -- Development: uncomment if using source mounts --
|
||||
# venv:
|
||||
# prometheus-data:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
|
|||
<Update label="v3.0.12 (Current)">
|
||||
### Added
|
||||
|
||||
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (403 otherwise) (#882)
|
||||
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882)
|
||||
- Bare-list membership sugar in the filter DSL: `{"session_id": ["s1", "s2"]}` is now shorthand for `{"session_id": {"in": [...]}}` on regular columns generically. JSONB metadata columns are excluded and keep containment semantics. Strictly additive, since a bare list on a regular column previously compiled to a type-mismatched equality that matched nothing (#881)
|
||||
- Optional structured outputs on the Dialectic: `response_format` (a JSON Schema with root type `object`) on peer chat makes `content` a JSON string conforming to that schema. Only a conservative subset of JSON Schema is supported, with DoS guards and non-recursive `$ref` support (#896)
|
||||
- Combined tool calling and structured output in the LLM transport layer, with per-backend request shaping: OpenAI routes tool-carrying structured requests through `create()` with an explicit `json_schema` response format (`parse()` 500s on non-strict function tools), Anthropic skips the `{` JSON prefill when tools are present so `tool_use` blocks stay reachable, and Gemini injects a schema instruction into the final turn instead of using native `response_schema` (rejected alongside function calling before Gemini 3). All backends skip structured-output parsing on tool-call turns, which carry no consumable content (#907)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
"v3/documentation/features/advanced/representation-scopes",
|
||||
"v3/documentation/features/advanced/dreaming",
|
||||
"v3/documentation/features/advanced/queue-status",
|
||||
"v3/documentation/features/advanced/webhooks",
|
||||
"v3/documentation/features/advanced/search",
|
||||
"v3/documentation/features/advanced/using-filters",
|
||||
"v3/documentation/features/advanced/structured-outputs",
|
||||
|
|
|
|||
|
|
@ -596,6 +596,8 @@ VECTOR_STORE_TURBOPUFFER_REGION=us-east-1
|
|||
VECTOR_STORE_LANCEDB_PATH=./lancedb_data
|
||||
```
|
||||
|
||||
LanceDB is an optional extra and is not included in the default Docker image. Build with `docker build --build-arg INSTALL_LANCEDB=true .` (or `INSTALL_LANCEDB=true docker compose up -d --build`), or run `uv sync --extra lancedb` for manual setups. Note the extra is unavailable on Intel macOS.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Prometheus Metrics
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ docker compose up -d --build
|
|||
|
||||
The first build takes a few minutes (compiling from source). Subsequent starts are fast.
|
||||
|
||||
The default image does not include LanceDB. To use `VECTOR_STORE_TYPE=lancedb`, build with `INSTALL_LANCEDB=true docker compose up -d --build`.
|
||||
|
||||
This starts four services: **api** (port 8000), **deriver** (background worker), **database** (PostgreSQL with pgvector, port 5432), and **redis** (port 6379). All ports are bound to `127.0.0.1`. Redis caching is enabled by default.
|
||||
|
||||
For development, uncomment the source mount and monitoring sections inside `docker-compose.yml` to enable live reload, Prometheus, and Grafana.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ You can retrieve a subset of conclusions from a peer's representation using `rep
|
|||
alice_rep = session.representation("alice")
|
||||
|
||||
# Or via chat
|
||||
response = alice.chat("What are Alice's main interests?", session_id=session.id)
|
||||
response = alice.chat("What are Alice's main interests?", session=session.id)
|
||||
```
|
||||
|
||||
This is sufficient for most applications—Honcho reasons over every message written to the peer, storing conclusions that any part of your system can retrieve.
|
||||
|
|
@ -160,13 +160,13 @@ The `target` parameter also works with the chat endpoint:
|
|||
# Query using conclusions from Honcho's representation (across all sessions)
|
||||
honcho_answer = alice.chat(
|
||||
"What did Bob say about breakfast?",
|
||||
session_id=session.id
|
||||
session=session.id
|
||||
)
|
||||
|
||||
# Query using conclusions from Alice's representation of Bob (from Alice's sessions only)
|
||||
alice_answer = alice.chat(
|
||||
"What did Bob say about breakfast?",
|
||||
session_id=session.id,
|
||||
session=session.id,
|
||||
target="bob"
|
||||
)
|
||||
```
|
||||
|
|
@ -175,13 +175,13 @@ alice_answer = alice.chat(
|
|||
// Query using conclusions from Honcho's representation (across all sessions)
|
||||
const honchoAnswer = await alice.chat(
|
||||
"What did Bob say about breakfast?",
|
||||
{ sessionId: session.id }
|
||||
{ session: session.id }
|
||||
);
|
||||
|
||||
// Query using conclusions from Alice's representation of Bob (from Alice's sessions only)
|
||||
const aliceAnswer = await alice.chat(
|
||||
"What did Bob say about breakfast?",
|
||||
{ sessionId: session.id, target: "bob" }
|
||||
{ session: session.id, target: "bob" }
|
||||
);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
|
@ -225,7 +225,7 @@ This architecture enables:
|
|||
|
||||
## Semantic Search Parameters
|
||||
|
||||
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session to retrieve only conclusions from specific session context:
|
||||
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to scope to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to scope to a set of sessions:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
|
|
|
|||
|
|
@ -192,6 +192,90 @@ sessions = honcho.sessions(filters={
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Negation and Unset Fields
|
||||
|
||||
A field can be unset, and negation has to account for it. `NOT` and the `ne`
|
||||
comparison operator both **include** rows where the field has no value at all: a
|
||||
field with no value is not the value you are excluding, so excluding that value
|
||||
keeps the row.
|
||||
|
||||
Positive conditions work the other way around. An unset field matches nothing,
|
||||
so equality and `contains` never return those rows. To select them, filter on
|
||||
`null` directly:
|
||||
|
||||
| Filter | Rows where the field is unset |
|
||||
| --- | --- |
|
||||
| `{"field": "x"}`, `{"field": {"contains": "x"}}` | Excluded |
|
||||
| `{"NOT": [{"field": "x"}]}`, `{"field": {"ne": "x"}}` | Included |
|
||||
| `{"field": null}` | Only these |
|
||||
| `{"field": {"ne": null}}` | Excluded — the field must have some value |
|
||||
|
||||
Of the filterable fields, only a conclusion's `session_id` can be unset: a
|
||||
conclusion drawn across a whole workspace belongs to no single session. Every
|
||||
other field is always populated, so none of this affects filters on them. See
|
||||
[Filtering Conclusions](#filtering-conclusions) for what conclusions are.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Every conclusion except the ones in this session — including
|
||||
# workspace-level conclusions, which belong to no session at all
|
||||
conclusions = peer.conclusions.list(filters={
|
||||
"NOT": [
|
||||
{"session_id": "session-123"}
|
||||
]
|
||||
})
|
||||
|
||||
# Equivalent
|
||||
conclusions = peer.conclusions.list(filters={
|
||||
"session_id": {"ne": "session-123"}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Every conclusion except the ones in this session — including
|
||||
// workspace-level conclusions, which belong to no session at all
|
||||
const conclusions = await peer.conclusions.list({
|
||||
filters: { NOT: [{ session_id: "session-123" }] }
|
||||
});
|
||||
|
||||
// Equivalent
|
||||
const same = await peer.conclusions.list({
|
||||
filters: { session_id: { ne: "session-123" } }
|
||||
});
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
To exclude a value **and** require the field to be set, combine the two with
|
||||
`AND`:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Excludes workspace-level conclusions and `session-123`
|
||||
conclusions = peer.conclusions.list(filters={
|
||||
"AND": [
|
||||
{"session_id": {"ne": "session-123"}},
|
||||
{"session_id": {"ne": None}}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Excludes workspace-level conclusions and `session-123`
|
||||
const conclusions = await peer.conclusions.list({
|
||||
filters: {
|
||||
AND: [
|
||||
{ session_id: { ne: "session-123" } },
|
||||
{ session_id: { ne: null } }
|
||||
]
|
||||
}
|
||||
});
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Combining Logical Operators
|
||||
|
||||
Create sophisticated queries by combining different logical operators:
|
||||
|
|
@ -304,6 +388,33 @@ sessions = honcho.sessions(filters={
|
|||
|
||||
### List Membership
|
||||
|
||||
A bare list is shorthand for `in`, so `{"peer_id": ["alice", "bob"]}` and
|
||||
`{"peer_id": {"in": ["alice", "bob"]}}` are equivalent:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Shorthand: a bare list means "any of these"
|
||||
messages = session.messages(filters={
|
||||
"peer_id": ["alice", "bob", "charlie"]
|
||||
})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Shorthand: a bare list means "any of these"
|
||||
const messages = await session.messages({
|
||||
filters: { peer_id: ["alice", "bob", "charlie"] }
|
||||
});
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Warning>
|
||||
Bare lists behave differently inside metadata — use `{"in": [...]}` there for OR matching.
|
||||
</Warning>
|
||||
|
||||
The explicit form, plus the other comparison operators:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Find messages from specific peers in a session
|
||||
|
|
@ -673,6 +784,145 @@ bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"})
|
|||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Value Types
|
||||
|
||||
A filter value has to be usable against the field it targets. Honcho validates
|
||||
this before running the query and returns a `422` with an explanation when it
|
||||
doesn't hold, rather than failing mid-query or quietly returning nothing.
|
||||
|
||||
| Field | Accepts |
|
||||
| --- | --- |
|
||||
| Text — `peer_id`, `session_id`, `id`, `content` | Strings |
|
||||
| Numeric — `token_count` | Numbers, or numeric strings like `"5"`. Exact for integers of any size |
|
||||
| Timestamps — `created_at` | ISO 8601 strings such as `"2026-01-01"` or `"2026-01-01T12:00:00Z"` |
|
||||
| Boolean — `is_active` | `true` / `false` |
|
||||
| `metadata` | An object, matched by containment — bare or under `contains` |
|
||||
| Fields with fixed values — `level` | One of the documented values |
|
||||
| Any field | `null`, which matches rows where the field is unset |
|
||||
|
||||
Three consequences worth knowing:
|
||||
|
||||
- **Booleans must be real booleans.** `{"is_active": True}` filters; the string
|
||||
`{"is_active": "true"}` is rejected.
|
||||
- **Fixed-value fields are checked.** `{"level": "explicit"}` filters;
|
||||
`{"level": "typo"}` is rejected instead of returning an empty list, so a
|
||||
misspelling doesn't look like "no results".
|
||||
- **`metadata` takes only the two shapes above** — bare, or under `contains`.
|
||||
Comparison operators don't apply to the object as a whole, so
|
||||
`{"metadata": {"ne": {...}}}` is rejected. To compare *within* metadata, put
|
||||
the operator on the key — `{"metadata": {"status": {"ne": "done"}}}`. To negate
|
||||
a match, wrap the whole condition in `NOT`. See
|
||||
[Metadata Filtering](#metadata-filtering).
|
||||
|
||||
For every field other than `metadata`, the same rules apply however the value is
|
||||
wrapped — bare, under an operator, or inside an `in` list — so
|
||||
`{"level": "explicit"}`, `{"level": {"ne": "explicit"}}` and
|
||||
`{"level": {"in": ["explicit"]}}` all validate identically.
|
||||
|
||||
An empty `in` list matches nothing:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
# Returns no results — an empty allowlist excludes everything
|
||||
messages = session.messages(filters={"peer_id": {"in": []}})
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
(async () => {
|
||||
// Returns no results — an empty allowlist excludes everything
|
||||
const messages = await session.messages({ filters: { peer_id: { in: [] } } });
|
||||
})();
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Scoping Recall to Sessions
|
||||
|
||||
The [chat endpoint](/v3/documentation/features/chat) and the representation
|
||||
endpoint accept a `filters` body too, but a deliberately narrow one: it defines
|
||||
a **session allowlist**, restricting what the request can recall to the sessions
|
||||
you name — conclusions on both endpoints, and on chat the messages the agent
|
||||
reads as well.
|
||||
|
||||
This is how you scope recall to more than one session. The `session_id`
|
||||
parameter pins a request to exactly one session; an allowlist accepts a set.
|
||||
|
||||
Only the `session_id` key is supported here, in three shapes:
|
||||
|
||||
```json
|
||||
{"filters": {"session_id": "support-chat-1"}}
|
||||
{"filters": {"session_id": ["support-chat-1", "support-chat-2"]}}
|
||||
{"filters": {"session_id": {"in": ["support-chat-1", "support-chat-2"]}}}
|
||||
```
|
||||
|
||||
<CodeGroup>
|
||||
```bash Chat
|
||||
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "What did the user ask about billing?",
|
||||
"filters": { "session_id": ["support-chat-1", "support-chat-2"] }
|
||||
}'
|
||||
```
|
||||
|
||||
```bash Representation
|
||||
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/representation" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filters": { "session_id": ["support-chat-1", "support-chat-2"] }
|
||||
}'
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
The session allowlist is REST-only today. The SDKs cover the single-session case
|
||||
with `session`, but do not yet expose the allowlist — call the endpoint directly
|
||||
when you need a set of sessions.
|
||||
</Note>
|
||||
|
||||
### Rules
|
||||
|
||||
Unlike the list endpoints above, this filter **fails closed**: an unrecognized
|
||||
key or shape is rejected with `422` rather than ignored, because a silently
|
||||
dropped filter here would widen recall instead of narrowing it.
|
||||
|
||||
| Rule | Behavior |
|
||||
|------|----------|
|
||||
| Any key other than `session_id` | `422` |
|
||||
| A shape other than a string, a list of strings, or `{"in": [...]}` | `422` |
|
||||
| An entry that isn't a well-formed session id — wildcards included | `422` |
|
||||
| More than 1,000 sessions | `422` |
|
||||
| `session_id` set alongside `filters` | The `session_id` must appear in the allowlist, else `422` |
|
||||
| An empty allowlist (`[]`) | Valid, and recalls nothing |
|
||||
| A peer-scoped key naming a session its peer isn't an active member of | `401` on chat — see below |
|
||||
|
||||
<Note>
|
||||
On chat, a peer-scoped key must be an active member of every session it names —
|
||||
the allowlist reaches message recall there — and the request is rejected with
|
||||
`401` otherwise. The representation endpoint runs no membership check: key scope
|
||||
already confines the caller to its own peer's representation, which an allowlist
|
||||
can only narrow.
|
||||
</Note>
|
||||
|
||||
### What Changes Under an Allowlist
|
||||
|
||||
Scoping recall by session narrows what the reasoning agent can draw on:
|
||||
|
||||
- **Only `explicit` conclusions are recalled.** Dream-derived conclusions
|
||||
(`deductive`, `inductive`) are synthesized across sessions, so they can't be
|
||||
attributed to one session and are excluded.
|
||||
- **Reasoning-chain traversal is unavailable**, since it walks into those
|
||||
derived conclusions.
|
||||
- **Message recall is restricted to the allowlisted sessions** across every
|
||||
search path — semantic, keyword, and date-range.
|
||||
|
||||
<Note>
|
||||
Because of this, an allowlisted request answers from directly-stated facts
|
||||
rather than higher-order inferences. If you want the full representation, omit
|
||||
`filters` and let the agent search everything.
|
||||
</Note>
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle filter errors gracefully:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
---
|
||||
title: 'Webhooks'
|
||||
description: 'Receive push notifications when Honcho finishes background work'
|
||||
icon: 'satellite-dish'
|
||||
---
|
||||
|
||||
Honcho's reasoning runs in the background, so a message you just created is not
|
||||
immediately reflected in the peer's representation. Instead of polling
|
||||
[queue status](/v3/documentation/features/advanced/queue-status), you can
|
||||
register a webhook endpoint and have Honcho notify you when the work it queued
|
||||
for a session has drained.
|
||||
|
||||
Webhooks are registered per workspace. Every event for that workspace is
|
||||
delivered to every endpoint registered on it.
|
||||
|
||||
## Registering an Endpoint
|
||||
|
||||
<CodeGroup>
|
||||
```bash Register
|
||||
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/webhooks" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com/honcho/webhook"}'
|
||||
```
|
||||
|
||||
```bash List
|
||||
curl -X GET "$HONCHO_URL/v3/workspaces/my-app/webhooks" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY"
|
||||
```
|
||||
|
||||
```bash Test
|
||||
curl -X GET "$HONCHO_URL/v3/workspaces/my-app/webhooks/test" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY"
|
||||
```
|
||||
|
||||
```bash Delete
|
||||
curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/webhooks/$ENDPOINT_ID" \
|
||||
-H "Authorization: Bearer $HONCHO_API_KEY"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Registration is get-or-create: a URL already registered on the workspace
|
||||
returns `200` with the existing endpoint, a new one returns `201`. The test
|
||||
route emits a `test.event` to every endpoint on the workspace, which is the
|
||||
quickest way to confirm your receiver and signature check work end to end.
|
||||
|
||||
Webhook routes accept an admin key or a workspace-scoped key for that
|
||||
workspace. Peer- and session-scoped keys cannot manage webhooks.
|
||||
|
||||
<Note>
|
||||
Webhook management is also available in the dashboard on the
|
||||
[Webhooks](https://app.honcho.dev/webhooks) page.
|
||||
</Note>
|
||||
|
||||
### URL Requirements
|
||||
|
||||
A webhook URL must be absolute and use `http` or `https`. URLs whose host is an
|
||||
IP literal in a private, loopback, link-local, reserved, multicast, or
|
||||
unspecified range are rejected with `422`.
|
||||
|
||||
<Warning>
|
||||
This check inspects IP literals only — hostnames are accepted without
|
||||
resolution. If you self-host, treat network-level egress controls, not this
|
||||
validation, as your defense against internal-address delivery.
|
||||
</Warning>
|
||||
|
||||
Each workspace can register up to `WEBHOOK_MAX_WORKSPACE_LIMIT` endpoints
|
||||
(default 10). Exceeding the limit returns `409`.
|
||||
|
||||
## Events
|
||||
|
||||
| Event | When it fires | `data` fields |
|
||||
|-------|---------------|---------------|
|
||||
| `queue.empty` | A unit of queued background work finished draining | `workspace_id`, `queue_type` (`representation` or `summary`), `session_id`, `observer`, `observed` |
|
||||
| `test.event` | You called `GET /webhooks/test` | `workspace_id` |
|
||||
|
||||
<Warning>
|
||||
`queue.empty` is scoped to a single unit of work — one task type for one
|
||||
session and observer/observed pair — not to the workspace as a whole. Other
|
||||
work may still be queued elsewhere in the workspace when it fires. A session
|
||||
whose messages produce both representation and summary work emits one event per
|
||||
task type.
|
||||
</Warning>
|
||||
|
||||
## Payload
|
||||
|
||||
Every delivery is a `POST` with a `Content-Type: application/json` body in this
|
||||
envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "queue.empty",
|
||||
"data": {
|
||||
"workspace_id": "my-app",
|
||||
"queue_type": "representation",
|
||||
"session_id": "support-chat-1",
|
||||
"observer": "assistant",
|
||||
"observed": "user-123"
|
||||
},
|
||||
"timestamp": "2026-08-10T18:24:05.123456Z"
|
||||
}
|
||||
```
|
||||
|
||||
**`data` is event-specific — its keys differ by event type.** A `test.event`
|
||||
carries only `workspace_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "test.event",
|
||||
"data": { "workspace_id": "my-app" },
|
||||
"timestamp": "2026-08-10T18:24:05.123456Z"
|
||||
}
|
||||
```
|
||||
|
||||
Within one event type, an optional field with no value is sent as an explicit
|
||||
`null` — on `queue.empty`, that's `session_id`, `observer`, and `observed` for
|
||||
work that isn't tied to a session or an observer pair. Across event types the key
|
||||
is simply absent.
|
||||
|
||||
Parse defensively: branch on `type` as the discriminator, treat every `data` key
|
||||
as optional rather than required, and tolerate new event types and new fields.
|
||||
A parser that requires the `queue.empty` keys on every event will break on a
|
||||
`test.event`.
|
||||
|
||||
## Verifying Signatures
|
||||
|
||||
Each delivery carries an `X-Honcho-Signature` header: the hex-encoded
|
||||
HMAC-SHA256 of the **raw request body**, keyed with your deployment's
|
||||
`WEBHOOK_SECRET`. Always compare with a constant-time function, and always sign
|
||||
the bytes you received — Honcho serializes the body compactly with sorted keys,
|
||||
so re-serializing your parsed JSON will not reliably reproduce it.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
|
||||
def verify(raw_body: bytes, signature: str) -> bool:
|
||||
expected = hmac.new(
|
||||
os.environ["WEBHOOK_SECRET"].encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
# FastAPI — read the raw body, not a parsed model
|
||||
@app.post("/honcho/webhook")
|
||||
async def handle(request: Request):
|
||||
raw = await request.body()
|
||||
if not verify(raw, request.headers.get("X-Honcho-Signature", "")):
|
||||
raise HTTPException(status_code=401)
|
||||
event = json.loads(raw)
|
||||
...
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function verify(rawBody: Buffer, signature: string): boolean {
|
||||
const expected = crypto
|
||||
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
|
||||
.update(rawBody)
|
||||
.digest('hex');
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(signature);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// Express — note express.raw(), not express.json()
|
||||
app.post('/honcho/webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
||||
if (!verify(req.body, req.header('X-Honcho-Signature') ?? '')) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
const event = JSON.parse(req.body.toString());
|
||||
res.sendStatus(200);
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Delivery Semantics
|
||||
|
||||
Delivery is best-effort and fire-and-forget:
|
||||
|
||||
- Events fan out to all of the workspace's endpoints concurrently.
|
||||
- Each request has a 30-second timeout.
|
||||
- **There are no retries.** A non-2xx response, a timeout, or a connection
|
||||
error is logged on the server and the event is dropped.
|
||||
|
||||
Design your receiver accordingly: treat the event as a hint to re-read state
|
||||
from the API rather than as the state itself, and fall back to
|
||||
[queue status](/v3/documentation/features/advanced/queue-status) polling if you
|
||||
need a guarantee.
|
||||
|
||||
## Self-Hosting Requirements
|
||||
|
||||
<Warning>
|
||||
`WEBHOOK_SECRET` must be set, or nothing is delivered. Honcho signs every
|
||||
payload before sending it; with no secret configured, signing fails and the
|
||||
event is dropped after being logged. Registration still succeeds, so a missing
|
||||
secret looks like silence rather than an error.
|
||||
</Warning>
|
||||
|
||||
Webhook delivery is queued work handled by the deriver process, so a deriver
|
||||
worker must be running for events to be sent. See
|
||||
[Configuration](/v3/contributing/configuration#webhooks) for
|
||||
`WEBHOOK_SECRET` and `WEBHOOK_MAX_WORKSPACE_LIMIT`.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Queue Status" icon="list-check" href="/v3/documentation/features/advanced/queue-status">
|
||||
Poll background processing state instead of waiting for a push
|
||||
</Card>
|
||||
<Card title="Webhook API Reference" icon="code" href="/v3/api-reference/endpoint/webhooks/get-or-create-webhook-endpoint">
|
||||
Full request and response schemas for the webhook endpoints
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -94,6 +94,27 @@ for await (const chunk of responseStream.iter_text()) {
|
|||
|
||||
Streaming is useful for displaying real-time responses in chat interfaces or when asking complex questions that require longer answers.
|
||||
|
||||
## Scoping to Sessions
|
||||
|
||||
By default the chat endpoint reasons over everything Honcho knows about the
|
||||
peer. Pass `session` (`session_id` on the REST body) to restrict it to one
|
||||
session:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
answer = peer.chat("What did the user ask about?", session=session.id)
|
||||
```
|
||||
|
||||
```typescript TypeScript
|
||||
const answer = await peer.chat("What did the user ask about?", { session: session.id });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
To scope a request to a *set* of sessions, use the session allowlist — a
|
||||
constrained `filters` body on the endpoint. See
|
||||
[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions)
|
||||
for the accepted shapes and for what an allowlist changes about the answer.
|
||||
|
||||
## Structured Outputs
|
||||
|
||||
When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it:
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ Scoped keys are authorized by their narrowest claim and never widen to the whole
|
|||
- A **peer-scoped** key acts on its own peer, plus **read-only** access to the sessions its peer is an active member of (context, summaries, peers, its own per-session config, search, and message reads). It cannot write to those sessions or act on other peers.
|
||||
- A **session-scoped** key is confined to its own session and cannot reach peer routes.
|
||||
- Peer- and session-scoped keys **must carry their parent workspace** — creating one without a workspace is rejected.
|
||||
- On the chat endpoint, a peer-scoped key can only name sessions its peer is an active member of — both the `session_id` and every session in a [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions). Naming any other session returns `401`. Workspace and admin keys pass the allowlist through as given.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-screenshots/api-keys.png" alt="API Key Management Dashboard" width="1200" height="800" loading="lazy" decoding="async" fetchpriority="low" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"url": "https://honcho.dev/",
|
||||
"email": "hello@plasticlabs.ai"
|
||||
},
|
||||
"version": "3.0.11"
|
||||
"version": "3.0.12"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
|
|
@ -2909,6 +2909,14 @@
|
|||
"title": "Session Id",
|
||||
"description": "ID of the session to scope the representation to"
|
||||
},
|
||||
"filters": {
|
||||
"anyOf": [
|
||||
{ "additionalProperties": true, "type": "object" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Filters",
|
||||
"description": "Optional filters to scope recall. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. Recall (conclusions and messages) is restricted to the allowlist; unsupported keys are rejected. When session_id is also set, it must be included in the allowlist."
|
||||
},
|
||||
"target": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Target",
|
||||
|
|
@ -2928,6 +2936,14 @@
|
|||
"title": "Reasoning Level",
|
||||
"description": "Level of reasoning to apply: minimal, low, medium, high, or max",
|
||||
"default": "low"
|
||||
},
|
||||
"response_format": {
|
||||
"anyOf": [
|
||||
{ "additionalProperties": true, "type": "object" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Response Format",
|
||||
"description": "Optional JSON Schema (root type 'object') the response must conform to. When provided, `content` is a JSON string matching this schema. Only a conservative subset of JSON Schema is supported; unsupported schemas are rejected with 422. Constraint keywords (minItems, maxLength, ...) are hints to the model, not enforced server-side."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -2947,7 +2963,7 @@
|
|||
},
|
||||
"DreamType": {
|
||||
"type": "string",
|
||||
"enum": ["omni"],
|
||||
"enum": ["omni", "card_refresh"],
|
||||
"title": "DreamType",
|
||||
"description": "Types of dreams that can be triggered."
|
||||
},
|
||||
|
|
@ -3352,6 +3368,14 @@
|
|||
"title": "Session Id",
|
||||
"description": "Optional session ID within which to scope the representation"
|
||||
},
|
||||
"filters": {
|
||||
"anyOf": [
|
||||
{ "additionalProperties": true, "type": "object" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Filters",
|
||||
"description": "Optional filters to scope the representation. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. When session_id is also set, it must be included in the allowlist."
|
||||
},
|
||||
"target": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Target",
|
||||
|
|
@ -3506,6 +3530,12 @@
|
|||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Session Id",
|
||||
"description": "Session ID to scope the dream to if specified"
|
||||
},
|
||||
"rebuild": {
|
||||
"type": "boolean",
|
||||
"title": "Rebuild",
|
||||
"description": "card_refresh dreams only: rebuild the peer card solely from observations currently in the collection, without injecting the existing card (use after removals)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ authors = [
|
|||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.131.0",
|
||||
"fastapi[standard-no-fastapi-cloud-cli]>=0.131.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"fastapi-pagination>=0.14.2",
|
||||
|
|
@ -32,14 +32,17 @@ dependencies = [
|
|||
"typing-extensions>=4.11.0",
|
||||
"json-repair>=0.49.0",
|
||||
"turbopuffer>=1.8.1",
|
||||
"lancedb>=0.25.3; sys_platform != \"darwin\" or platform_machine != \"x86_64\"",
|
||||
"pyarrow>=19.0.0",
|
||||
"redis>=7.0.0,<8.0.0",
|
||||
"cashews[redis]==7.5.0",
|
||||
"scikit-learn>=1.6.0",
|
||||
"prometheus_client>=0.21.0",
|
||||
"cloudevents>=1.12.0,<2.0",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
lancedb = [
|
||||
"lancedb>=0.25.3; sys_platform != \"darwin\" or platform_machine != \"x86_64\"",
|
||||
"pyarrow>=19.0.0",
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.2.2",
|
||||
|
|
@ -53,7 +56,6 @@ dev = [
|
|||
"pre-commit>=4.2.0",
|
||||
"pytest-cov>=6.2.1",
|
||||
"honcho-ai",
|
||||
"fakeredis>=2.32.0",
|
||||
"scipy>=1.15.3",
|
||||
"boto3>=1.42.5",
|
||||
"pytest-xdist>=3.8.0",
|
||||
|
|
|
|||
|
|
@ -39,12 +39,23 @@ from .peer import (
|
|||
get_peer,
|
||||
get_peers,
|
||||
get_sessions_for_peer,
|
||||
reject_scope_observed,
|
||||
reject_scope_peers,
|
||||
update_peer,
|
||||
)
|
||||
from .peer_card import get_peer_card, set_peer_card
|
||||
from .representation import (
|
||||
get_working_representation,
|
||||
)
|
||||
from .scope import (
|
||||
add_sessions_to_scope,
|
||||
get_or_create_scopes,
|
||||
get_scope_or_raise,
|
||||
get_scope_sessions,
|
||||
get_scopes,
|
||||
remove_session_from_scope,
|
||||
resolve_scope_peers,
|
||||
)
|
||||
from .session import (
|
||||
SessionDeletionResult,
|
||||
clone_session,
|
||||
|
|
@ -114,6 +125,8 @@ __all__ = [
|
|||
# Peer
|
||||
"get_or_create_peers",
|
||||
"get_peer",
|
||||
"reject_scope_observed",
|
||||
"reject_scope_peers",
|
||||
"get_peers",
|
||||
"update_peer",
|
||||
"get_sessions_for_peer",
|
||||
|
|
@ -122,6 +135,14 @@ __all__ = [
|
|||
"set_peer_card",
|
||||
# Representation
|
||||
"get_working_representation",
|
||||
# Scope
|
||||
"add_sessions_to_scope",
|
||||
"get_or_create_scopes",
|
||||
"get_scope_or_raise",
|
||||
"get_scope_sessions",
|
||||
"get_scopes",
|
||||
"remove_session_from_scope",
|
||||
"resolve_scope_peers",
|
||||
# Session
|
||||
"SessionDeletionResult",
|
||||
"get_sessions",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from sqlalchemy.sql.functions import func
|
|||
from src import models, schemas
|
||||
from src.config import settings
|
||||
from src.crud.collection import get_or_create_collection
|
||||
from src.crud.peer import get_peer
|
||||
from src.crud.peer import get_peer, reject_scope_observed
|
||||
from src.crud.session import get_session
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
|
|
@ -955,7 +955,25 @@ async def create_observations(
|
|||
|
||||
# Validate all peers exist
|
||||
for peer_name in peers_to_validate:
|
||||
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
|
||||
await get_peer(db, workspace_name, peer_name)
|
||||
|
||||
# A scope may be an *observer* — that is how scoped conclusions are stored —
|
||||
# but it must never be *observed*: scope peers carry observe_me=false and no
|
||||
# representation is ever formed of one. Without this, a conclusion about a
|
||||
# scope persists and a (observer, scope) collection is created for it.
|
||||
#
|
||||
# The strict variant because this is an observed position, though defence in
|
||||
# depth rather than the active guard: the loop above resolves every peer, so a
|
||||
# reserved name that does not exist yet already 404s before reaching here. If
|
||||
# that validation ever stops covering observed_id, this still refuses the
|
||||
# pre-seeding case instead of persisting a conclusion that a later-created
|
||||
# scope would retroactively own.
|
||||
await reject_scope_observed(
|
||||
db,
|
||||
workspace_name,
|
||||
{obs.observed_id for obs in observations},
|
||||
action="No conclusion is ever formed about a scope.",
|
||||
)
|
||||
|
||||
# Get or create all collections
|
||||
for observer, observed in collection_pairs:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern
|
|||
from src.utils.types import embedding_call_purpose
|
||||
from src.vector_store import get_external_vector_store
|
||||
|
||||
from .peer import reject_scope_peers
|
||||
from .session import get_or_create_session
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
|
@ -312,7 +313,22 @@ async def create_messages(
|
|||
|
||||
Returns:
|
||||
List of created message objects
|
||||
|
||||
Raises:
|
||||
ValidationException: If a message is authored by a scope peer
|
||||
"""
|
||||
# Scope peers are silent observers — they can never author messages. Keyed
|
||||
# off name+flag so a legacy peer merely occupying the reserved namespace
|
||||
# keeps ingesting. Must stay *before* get_or_create_session below: that call
|
||||
# would create the scope peer and add it with a default SessionPeerConfig(),
|
||||
# clobbering its observe_others=True/observe_me=False membership config.
|
||||
await reject_scope_peers(
|
||||
db,
|
||||
workspace_name,
|
||||
(message.peer_name for message in messages),
|
||||
action="Scope peers cannot author messages.",
|
||||
)
|
||||
|
||||
# Get or create session with peers in messages list
|
||||
peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages}
|
||||
await get_or_create_session(
|
||||
|
|
|
|||
302
src/crud/peer.py
302
src/crud/peer.py
|
|
@ -1,10 +1,12 @@
|
|||
"""CRUD helpers for peer records and peer-scoped session queries."""
|
||||
|
||||
import re
|
||||
from collections.abc import Collection, Iterable
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from cashews import NOT_NONE
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy import ColumnElement, Select, and_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import make_transient_to_detached
|
||||
|
|
@ -13,13 +15,22 @@ from src import models, schemas
|
|||
from src.cache.client import cache, get_cache_namespace, safe_cache_delete
|
||||
from src.config import settings
|
||||
from src.crud.workspace import get_or_create_workspace
|
||||
from src.exceptions import ConflictException, ResourceNotFoundException
|
||||
from src.exceptions import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from src.models import Peer
|
||||
from src.schemas.api import RESOURCE_NAME_PATTERN
|
||||
from src.utils import scopes as scopes_util
|
||||
from src.utils.filter import apply_filter
|
||||
from src.utils.types import GetOrCreateResult
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Matches the peers.name CHECK constraint and PeerCreate's max_length.
|
||||
PEER_NAME_MAX_LENGTH = 512
|
||||
|
||||
PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}"
|
||||
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
|
||||
|
||||
|
|
@ -36,12 +47,214 @@ def peer_cache_key(workspace_name: str, peer_name: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _reject_impossible_peer_names(names: Collection[str]) -> None:
|
||||
"""Reject names that cannot correspond to any stored row, before querying.
|
||||
|
||||
``PeerSpec`` accepts anything so existing names can be looked up, and the
|
||||
full new-name rules run later on the insert path — but a couple of values
|
||||
cannot be a legacy row *by construction*, and sending them to Postgres first
|
||||
fails before that 422 can happen:
|
||||
|
||||
- NUL bytes: Postgres text cannot hold them, so psycopg raises DataError
|
||||
during the lookup itself, surfacing as a 500.
|
||||
- Over-length names: the ``peers.name`` CHECK caps them at
|
||||
``PEER_NAME_MAX_LENGTH``, so no stored row can exceed it.
|
||||
|
||||
Takes a ``Collection`` rather than an ``Iterable`` on purpose: it inspects the
|
||||
input twice, so a generator would be half-consumed and the second check would
|
||||
silently see nothing.
|
||||
|
||||
Raises:
|
||||
ValidationException: On a NUL byte or an over-length name.
|
||||
"""
|
||||
if any("\x00" in name for name in names):
|
||||
raise ValidationException("Peer name(s) must not contain NUL (0x00) bytes")
|
||||
too_long = sorted({n for n in names if len(n) > PEER_NAME_MAX_LENGTH})
|
||||
if too_long:
|
||||
raise ValidationException(
|
||||
f"Peer name(s) {too_long} must be at most "
|
||||
+ f"{PEER_NAME_MAX_LENGTH} characters"
|
||||
)
|
||||
|
||||
|
||||
def _validate_new_peer_names(names: list[str]) -> None:
|
||||
"""Validate peer names that are about to be created.
|
||||
|
||||
Mirrors ``PeerCreate``'s contract for peers arriving through crud rather than
|
||||
the peers route. The reserved prefix is reported separately because it is
|
||||
also outside ``RESOURCE_NAME_PATTERN``, so the charset check would otherwise
|
||||
mask the real problem.
|
||||
|
||||
Raises:
|
||||
ValidationException: On a reserved-prefix or non-conforming name.
|
||||
"""
|
||||
scopes_util.validate_no_scope_peer_names(
|
||||
names, action="Use the scopes routes to create scopes."
|
||||
)
|
||||
# Length and NUL bytes are already refused before the lookup by
|
||||
# _reject_impossible_peer_names; RESOURCE_NAME_PATTERN's `+` rejects empty.
|
||||
offenders = sorted({n for n in names if not re.fullmatch(RESOURCE_NAME_PATTERN, n)})
|
||||
if offenders:
|
||||
raise ValidationException(
|
||||
f"Peer name(s) {offenders} must match pattern {RESOURCE_NAME_PATTERN}"
|
||||
)
|
||||
|
||||
|
||||
def scope_peer_clause() -> ColumnElement[bool]:
|
||||
"""SQL form of ``is_scope_peer()``: reserved name prefix AND the internal kind flag.
|
||||
|
||||
Lives here rather than in ``crud/scope.py`` because that module already imports
|
||||
from this one, and ``get_peers`` below needs the clause — the other direction
|
||||
would be a cycle.
|
||||
|
||||
``autoescape=True`` is future-proofing: '.' is not a LIKE wildcard, but '_' is,
|
||||
so under a ``scope__``-style prefix an unescaped ``startswith`` would also match
|
||||
``scopeXY...``. Both columns are NOT NULL with defaults, so the negation
|
||||
``~scope_peer_clause()`` has no NULL-semantics trap.
|
||||
"""
|
||||
return and_(
|
||||
models.Peer.name.startswith(scopes_util.SCOPE_PEER_PREFIX, autoescape=True),
|
||||
models.Peer.internal_metadata.contains({"kind": scopes_util.SCOPE_KIND}),
|
||||
)
|
||||
|
||||
|
||||
def _reserved_name_candidates(names: Iterable[str]) -> list[str]:
|
||||
"""Materialize ``names`` once and return the reserved-prefix ones, sorted.
|
||||
|
||||
Materializing up front matters: callers pass generators (the message-author
|
||||
path does), and validating impossible names iterates the input separately from
|
||||
the prefix filter — a generator would be silently half-consumed.
|
||||
|
||||
Impossible values are refused here, before any SQL, because a reserved-prefix
|
||||
name containing a NUL byte would otherwise reach the text comparison below and
|
||||
raise ``psycopg.DataError`` inside the query — a 500 instead of the 422 the
|
||||
caller should get.
|
||||
|
||||
Raises:
|
||||
ValidationException: On a NUL byte or an over-length name.
|
||||
"""
|
||||
materialized = tuple(names)
|
||||
_reject_impossible_peer_names(materialized)
|
||||
return sorted({n for n in materialized if scopes_util.is_scope_peer_name(n)})
|
||||
|
||||
|
||||
async def reject_scope_observed(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
names: Iterable[str],
|
||||
*,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Reject any name that is — or could later become — an observed scope.
|
||||
|
||||
Stricter than ``reject_scope_peers`` in exactly one case: a **missing**
|
||||
reserved name is refused. Use this for the *observed* position, where nothing
|
||||
creates the peer and so nothing else would ever catch it. Without it a caller
|
||||
can pre-seed state about ``scope.future`` while that peer does not exist, then
|
||||
create the scope and have the state retroactively describe it.
|
||||
|
||||
Three-way on the reserved namespace:
|
||||
|
||||
============================== ======
|
||||
State Result
|
||||
============================== ======
|
||||
Existing flagged scope reject
|
||||
Missing reserved name reject
|
||||
Existing unflagged squatter allow
|
||||
============================== ======
|
||||
|
||||
Non-reserved names are left entirely to the caller's own existence semantics.
|
||||
|
||||
Raises:
|
||||
ValidationException: On a real scope or a missing reserved name.
|
||||
"""
|
||||
candidates = _reserved_name_candidates(names)
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(models.Peer.name, scope_peer_clause())
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name.in_(candidates))
|
||||
)
|
||||
).all()
|
||||
flagged = {name for name, is_scope in rows if is_scope}
|
||||
existing = {name for name, _ in rows}
|
||||
|
||||
scopes = sorted(flagged)
|
||||
if scopes:
|
||||
raise ValidationException(f"Peer name(s) {scopes} are scopes. {action}")
|
||||
|
||||
missing = sorted(set(candidates) - existing)
|
||||
if missing:
|
||||
raise ValidationException(
|
||||
f"Peer name(s) {missing} are in the reserved scope namespace and do"
|
||||
+ f" not exist, so they may become scopes later. {action}"
|
||||
)
|
||||
|
||||
|
||||
async def scope_peer_names(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
names: Iterable[str],
|
||||
) -> set[str]:
|
||||
"""Return the subset of ``names`` that are really scope peers (name AND flag).
|
||||
|
||||
Unlike a pure name check, a legacy peer that merely occupies the reserved
|
||||
namespace (names were length-only validated before migration
|
||||
``d429de0e5338``, so ``scope.production`` is a possible user name) is not
|
||||
reported, so it keeps its ordinary semantics instead of being locked out of
|
||||
its own data. A *missing* reserved name is likewise not reported.
|
||||
|
||||
Costs nothing on the common path: with no reserved-prefix name in ``names``
|
||||
there is no query at all.
|
||||
|
||||
Raises:
|
||||
ValidationException: On a NUL byte or an over-length name.
|
||||
"""
|
||||
candidates = _reserved_name_candidates(names)
|
||||
if not candidates:
|
||||
return set()
|
||||
|
||||
result = await db.execute(
|
||||
select(models.Peer.name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name.in_(candidates))
|
||||
.where(scope_peer_clause())
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
|
||||
async def reject_scope_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
names: Iterable[str],
|
||||
*,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Reject peers that really are scopes, keyed off name AND flag.
|
||||
|
||||
A *missing* reserved name passes here — the create paths this guards
|
||||
(`get_or_create_peers`) refuse it themselves. Positions where nothing creates
|
||||
the peer need ``reject_scope_observed`` instead. See ``scope_peer_names`` for
|
||||
the name-vs-flag semantics.
|
||||
|
||||
Raises:
|
||||
ValidationException: If any name resolves to a real scope peer.
|
||||
"""
|
||||
offenders = sorted(await scope_peer_names(db, workspace_name, names))
|
||||
if offenders:
|
||||
raise ValidationException(f"Peer name(s) {offenders} are scopes. {action}")
|
||||
|
||||
|
||||
async def get_or_create_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peers: list[schemas.PeerCreate],
|
||||
peers: list[schemas.PeerSpec],
|
||||
*,
|
||||
_retry: bool = False,
|
||||
_pending_invalidation: list[str] | None = None,
|
||||
) -> GetOrCreateResult[list[models.Peer]]:
|
||||
"""
|
||||
Get an existing list of peers or create new peers if they don't exist.
|
||||
|
|
@ -52,16 +265,23 @@ async def get_or_create_peers(
|
|||
workspace_name: Name of the workspace
|
||||
peers: List of peer creation schemas
|
||||
_retry: Whether to retry the operation
|
||||
_pending_invalidation: Names of peers already mutated by a prior attempt,
|
||||
whose cache keys must still be purged. See the retry branch below.
|
||||
|
||||
Returns:
|
||||
GetOrCreateResult containing the list of peers and whether any were created
|
||||
|
||||
Raises:
|
||||
ConflictException: If we fail to get or create the peers
|
||||
ValidationException: On an impossible name (NUL byte, over-length), or a
|
||||
reserved-prefix or non-conforming name on the create path
|
||||
"""
|
||||
|
||||
await get_or_create_workspace(db, schemas.WorkspaceCreate(name=workspace_name))
|
||||
peer_names = [p.name for p in peers]
|
||||
# Before the lookup: these values cannot match a stored row and would fail
|
||||
# inside the query itself rather than as a clean 422.
|
||||
_reject_impossible_peer_names(peer_names)
|
||||
stmt = (
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
|
|
@ -104,6 +324,17 @@ async def get_or_create_peers(
|
|||
existing_names = {p.name for p in existing_peers}
|
||||
peers_to_create = [p for p in peers if p.name not in existing_names]
|
||||
|
||||
# Names are validated on the *create* path only. `PeerSpec` deliberately
|
||||
# carries no charset pattern so already-existing names (legacy dotted names,
|
||||
# scope peers) can be looked up without a spurious 422 — but a name we are
|
||||
# about to INSERT is a new peer, and new peers must obey the public contract.
|
||||
# Without this, request-controlled names reach here unvalidated via message
|
||||
# authors, session peer maps, and the chat observer path, letting a caller
|
||||
# mint `scope.x` squatters (permanently 409-blocking that scope) or peers
|
||||
# that violate RESOURCE_NAME_PATTERN outright.
|
||||
if peers_to_create:
|
||||
_validate_new_peer_names([p.name for p in peers_to_create])
|
||||
|
||||
# Create new peers
|
||||
new_peers = [
|
||||
models.Peer(
|
||||
|
|
@ -122,11 +353,26 @@ async def get_or_create_peers(
|
|||
raise ConflictException(
|
||||
f"Unable to create or get peers: {peer_names}"
|
||||
) from None
|
||||
return await get_or_create_peers(db, workspace_name, peers, _retry=True)
|
||||
# `begin_nested()` autoflushes the mutations above *before* opening the
|
||||
# savepoint, so they are already committed-in-transaction and the rollback
|
||||
# doesn't undo them — nor does it expire the now-clean ORM state. The retry
|
||||
# would therefore compare already-updated values, find no change, and skip
|
||||
# the purge. Carry the names forward so the invalidation can't be lost.
|
||||
return await get_or_create_peers(
|
||||
db,
|
||||
workspace_name,
|
||||
peers,
|
||||
_retry=True,
|
||||
_pending_invalidation=(_pending_invalidation or [])
|
||||
+ [p.name for p in changed_peers],
|
||||
)
|
||||
|
||||
# Capture peer names eagerly so the closure holds plain strings, not ORM objects
|
||||
_cache_keys_to_invalidate = [
|
||||
peer_cache_key(workspace_name, p.name) for p in changed_peers + new_peers
|
||||
peer_cache_key(workspace_name, name)
|
||||
for name in dict.fromkeys(
|
||||
(_pending_invalidation or []) + [p.name for p in changed_peers + new_peers]
|
||||
)
|
||||
]
|
||||
|
||||
async def _invalidate_peer_cache():
|
||||
|
|
@ -181,15 +427,20 @@ async def _fetch_peer(
|
|||
async def get_peer(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
peer: schemas.PeerCreate,
|
||||
peer_name: str,
|
||||
) -> models.Peer:
|
||||
"""
|
||||
Get an existing peer.
|
||||
|
||||
Takes a plain name, not a create schema: this is a pure read, and validating
|
||||
an already-existing name against ``PeerCreate``'s charset pattern turns a
|
||||
lookup into a raw pydantic ValidationError (an HTTP 500) for legacy dotted
|
||||
names and every ``scope.``-prefixed peer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
peer: Peer creation schema
|
||||
peer_name: Name of the peer
|
||||
|
||||
Returns:
|
||||
The peer if found
|
||||
|
|
@ -197,10 +448,10 @@ async def get_peer(
|
|||
Raises:
|
||||
ResourceNotFoundException: If the peer does not exist
|
||||
"""
|
||||
data = await _fetch_peer(db, workspace_name, peer.name)
|
||||
data = await _fetch_peer(db, workspace_name, peer_name)
|
||||
if data is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Peer {peer.name} not found in workspace {workspace_name}"
|
||||
f"Peer {peer_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
# Reconstruct ORM object from cached dict and merge into session
|
||||
|
|
@ -215,10 +466,26 @@ async def get_peers(
|
|||
workspace_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
kind: Literal["scope", "all"] | None = None,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
"""Build a filtered peer list query ordered by creation time."""
|
||||
"""Build a filtered peer list query ordered by creation time.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
filters: Filter peers by metadata
|
||||
reverse: Whether to reverse the default creation order
|
||||
kind: Which kinds of peers to include. None (default) excludes scope
|
||||
peers (see ``scope_peer_clause``: reserved name prefix AND the
|
||||
``{"kind": "scope"}`` internal_metadata flag), "scope" returns only
|
||||
scope peers, and "all" returns everything.
|
||||
"""
|
||||
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
|
||||
|
||||
if kind is None:
|
||||
stmt = stmt.where(~scope_peer_clause())
|
||||
elif kind == "scope":
|
||||
stmt = stmt.where(scope_peer_clause())
|
||||
|
||||
stmt = apply_filter(stmt, models.Peer, filters)
|
||||
|
||||
if reverse:
|
||||
|
|
@ -250,10 +517,21 @@ async def update_peer(
|
|||
the peer
|
||||
"""
|
||||
peers_result = await get_or_create_peers(
|
||||
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
|
||||
db, workspace_name, [schemas.PeerSpec(name=peer_name)]
|
||||
)
|
||||
honcho_peer = peers_result.resource[0]
|
||||
|
||||
# Refuse a real scope on the row just resolved, not on the name beforehand:
|
||||
# this route replaces `configuration` wholesale, and a name-level check leaves
|
||||
# a window in which a concurrently-created scope is resolved as existing (so
|
||||
# create-path validation never fires) and then overwritten. An existing
|
||||
# *unflagged* peer in the reserved namespace is an ordinary peer and passes.
|
||||
if scopes_util.is_scope_peer(honcho_peer.name, honcho_peer.internal_metadata):
|
||||
raise ValidationException(
|
||||
f"Peer '{peer_name}' is a scope."
|
||||
+ " Use the scopes routes to manage scopes."
|
||||
)
|
||||
|
||||
needs_update = False
|
||||
|
||||
if peer.metadata is not None and honcho_peer.h_metadata != peer.metadata:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from src import exceptions, models, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
from src.crud.peer import get_or_create_peers, get_peer, peer_cache_key
|
||||
from src.crud.peer import (
|
||||
get_or_create_peers,
|
||||
get_peer,
|
||||
peer_cache_key,
|
||||
reject_scope_observed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -38,7 +43,7 @@ async def get_peer_card(
|
|||
Raises:
|
||||
ResourceNotFoundException: If the peer does not exist.
|
||||
"""
|
||||
peer = await get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
|
||||
peer = await get_peer(db, workspace_name, observer)
|
||||
return cast(
|
||||
list[str] | None,
|
||||
peer.internal_metadata.get(
|
||||
|
|
@ -68,9 +73,24 @@ async def set_peer_card(
|
|||
observer: Peer name of the observer
|
||||
|
||||
"""
|
||||
# A scope may be the card's *observer* — the Dreamer writes (scope, observed)
|
||||
# cards — but never its subject. Authoritative here rather than only in the
|
||||
# route, so the Dreamer and agent-tool paths are covered too, and in the same
|
||||
# transaction as the JSONB write below.
|
||||
#
|
||||
# A *missing* reserved name is refused as well: only the observer is resolved
|
||||
# below, so a card keyed on `scope.future` would otherwise persist while that
|
||||
# peer does not exist and retroactively describe the scope once created.
|
||||
await reject_scope_observed(
|
||||
db,
|
||||
workspace_name,
|
||||
[observed],
|
||||
action="No peer card is ever formed about a scope.",
|
||||
)
|
||||
|
||||
# Ensure the peer exists (get-or-create)
|
||||
peers_result = await get_or_create_peers(
|
||||
db, workspace_name, [schemas.PeerCreate(name=observer)]
|
||||
db, workspace_name, [schemas.PeerSpec(name=observer)]
|
||||
)
|
||||
|
||||
stmt = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,438 @@
|
|||
"""CRUD helpers for scopes.
|
||||
|
||||
A scope is a named grouping of sessions, implemented as a peer named
|
||||
``scope.<name>`` carrying ``{"kind": "scope"}`` in ``internal_metadata`` (the
|
||||
authoritative, user-unwritable flag) and ``{"observe_me": false}`` in
|
||||
``configuration``, that observes its member sessions (``observe_others=true``)
|
||||
and never speaks.
|
||||
See ``src/utils/scopes.py`` for the namespace helpers.
|
||||
|
||||
Membership only affects messages ingested *after* a session is added to a
|
||||
scope. Conclusions already derived are neither backfilled on add nor
|
||||
reconciled on removal.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from logging import getLogger
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
from src.exceptions import (
|
||||
ConflictException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from src.utils.scopes import (
|
||||
SCOPE_KIND,
|
||||
is_scope_peer,
|
||||
scope_peer_name,
|
||||
)
|
||||
from src.utils.types import GetOrCreateResult
|
||||
|
||||
from .peer import peer_cache_key, scope_peer_clause
|
||||
from .workspace import get_or_create_workspace
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Internal metadata stamped on every scope peer at creation. `kind` is the
|
||||
# authoritative scope flag and lives here — NOT in `configuration` — because
|
||||
# `configuration` is user-writable (`PeerCreate`/`PeerUpdate` accept a free-form
|
||||
# dict, and `update_peer` replaces it wholesale), so a user could forge or clear
|
||||
# the flag. `internal_metadata` appears in no API schema at all.
|
||||
SCOPE_PEER_INTERNAL_METADATA: dict[str, str] = {
|
||||
"kind": SCOPE_KIND,
|
||||
}
|
||||
|
||||
# Peer-level configuration stamped on every scope peer at creation.
|
||||
# `observe_me: false` ensures no representation is ever formed *of* a scope peer.
|
||||
# This one stays user-visible: `observe_me` is a legitimate config knob.
|
||||
SCOPE_PEER_CONFIGURATION: dict[str, str | bool] = {
|
||||
"observe_me": False,
|
||||
}
|
||||
|
||||
# Session-level configuration for a scope peer's membership in a session.
|
||||
SCOPE_MEMBERSHIP_CONFIG = schemas.SessionPeerConfig(
|
||||
observe_others=True, observe_me=False
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_scopes(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scopes: list[schemas.ScopeCreate],
|
||||
*,
|
||||
_retry: bool = False,
|
||||
_pending_invalidation: list[str] | None = None,
|
||||
) -> GetOrCreateResult[list[models.Peer]]:
|
||||
"""
|
||||
Get existing scopes or create new ones if they don't exist.
|
||||
|
||||
Existing scope peers have their metadata updated when provided. A
|
||||
pre-existing peer that occupies a scope's reserved name *without* the
|
||||
authoritative ``kind`` flag (a legacy collision) is never adopted.
|
||||
|
||||
Note: does not commit; the caller owns the transaction (mirror of
|
||||
``get_or_create_peers``). Run ``result.post_commit()`` after committing.
|
||||
|
||||
Deliberately does NOT scan for pre-existing state naming the backing peer
|
||||
(peer-card keys, pending dream queue items). ``reject_scope_observed`` now
|
||||
refuses writes against a not-yet-existing reserved name, so no new such state
|
||||
can be created; only data written before that guard existed could collide, and
|
||||
since ``scope.`` was never a meaningful namespace then, any such row is
|
||||
coincidental. The consequence would also be inert — a card or queue item
|
||||
describing a scope, which nothing reads, because no representation is formed of
|
||||
a scope. Detecting card keys means scanning every peer's ``internal_metadata``
|
||||
for a label containing this name, i.e. a full table scan per scope creation:
|
||||
disproportionate to that risk. Revisit if scope names ever become guessable
|
||||
across tenants.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
scopes: List of scope creation schemas (unprefixed names)
|
||||
_retry: Whether this is the retry attempt
|
||||
_pending_invalidation: Names of scope peers already mutated by a prior
|
||||
attempt, whose cache keys must still be purged. See the retry branch.
|
||||
|
||||
Returns:
|
||||
GetOrCreateResult containing the backing peers and whether any were
|
||||
created
|
||||
|
||||
Raises:
|
||||
ConflictException: If a peer already occupies a scope's reserved name
|
||||
without the scope kind flag, or if we fail to get or create the
|
||||
scope peers
|
||||
"""
|
||||
await get_or_create_workspace(db, schemas.WorkspaceCreate(name=workspace_name))
|
||||
|
||||
peer_names = {scope_peer_name(s.name): s for s in scopes}
|
||||
stmt = (
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name.in_(peer_names.keys()))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_peers: list[models.Peer] = list(result.scalars().all())
|
||||
|
||||
changed_peers: list[models.Peer] = []
|
||||
for existing_peer in existing_peers:
|
||||
if not is_scope_peer(existing_peer.name, existing_peer.internal_metadata):
|
||||
raise ConflictException(
|
||||
f"A peer named '{existing_peer.name}' already exists in workspace "
|
||||
+ f"{workspace_name} but is not a scope. Rename or delete that "
|
||||
+ "peer before creating this scope."
|
||||
)
|
||||
scope_schema = peer_names[existing_peer.name]
|
||||
if (
|
||||
scope_schema.metadata is not None
|
||||
and existing_peer.h_metadata != scope_schema.metadata
|
||||
):
|
||||
existing_peer.h_metadata = scope_schema.metadata
|
||||
changed_peers.append(existing_peer)
|
||||
|
||||
existing_names = {p.name for p in existing_peers}
|
||||
new_peers = [
|
||||
models.Peer(
|
||||
workspace_name=workspace_name,
|
||||
name=name,
|
||||
h_metadata=scope_schema.metadata or {},
|
||||
internal_metadata=dict(SCOPE_PEER_INTERNAL_METADATA),
|
||||
configuration=dict(SCOPE_PEER_CONFIGURATION),
|
||||
)
|
||||
for name, scope_schema in peer_names.items()
|
||||
if name not in existing_names
|
||||
]
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add_all(new_peers)
|
||||
except IntegrityError:
|
||||
if _retry:
|
||||
raise ConflictException(
|
||||
f"Unable to create or get scopes: {sorted(peer_names)}"
|
||||
) from None
|
||||
# `begin_nested()` autoflushes the mutations above *before* opening the
|
||||
# savepoint, so they survive the rollback and leave the ORM state clean —
|
||||
# the retry would compare already-updated values, find no change, and skip
|
||||
# the purge. Carry the names forward so the invalidation can't be lost.
|
||||
return await get_or_create_scopes(
|
||||
db,
|
||||
workspace_name,
|
||||
scopes,
|
||||
_retry=True,
|
||||
_pending_invalidation=(_pending_invalidation or [])
|
||||
+ [p.name for p in changed_peers],
|
||||
)
|
||||
|
||||
_cache_keys_to_invalidate = [
|
||||
peer_cache_key(workspace_name, name)
|
||||
for name in dict.fromkeys(
|
||||
(_pending_invalidation or []) + [p.name for p in changed_peers + new_peers]
|
||||
)
|
||||
]
|
||||
|
||||
async def _invalidate_peer_cache():
|
||||
for cache_key in _cache_keys_to_invalidate:
|
||||
await safe_cache_delete(cache_key)
|
||||
|
||||
return GetOrCreateResult(
|
||||
existing_peers + new_peers,
|
||||
created=len(new_peers) > 0,
|
||||
on_commit=_invalidate_peer_cache if _cache_keys_to_invalidate else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_scopes(
|
||||
workspace_name: str,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Peer]]:
|
||||
"""Build a scope list query, ordered by creation time.
|
||||
|
||||
Requires both halves via ``scope_peer_clause`` (reserved name prefix AND the
|
||||
internal kind flag), so a peer carrying a forged ``configuration`` cannot
|
||||
inject itself into the scope list.
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(scope_peer_clause())
|
||||
)
|
||||
if reverse:
|
||||
return stmt.order_by(models.Peer.created_at.desc(), models.Peer.id.desc())
|
||||
return stmt.order_by(models.Peer.created_at.asc(), models.Peer.id.asc())
|
||||
|
||||
|
||||
async def get_scope_or_raise(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
) -> models.Peer:
|
||||
"""
|
||||
Get an existing scope's backing peer by its unprefixed scope name.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
scope_name: Unprefixed scope name
|
||||
|
||||
Returns:
|
||||
The backing peer if found and flagged as a scope
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If no scope with that name exists (a peer
|
||||
occupying the reserved name without the kind flag does not count)
|
||||
"""
|
||||
peer = await db.scalar(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == scope_peer_name(scope_name))
|
||||
)
|
||||
if peer is None or not is_scope_peer(peer.name, peer.internal_metadata):
|
||||
raise ResourceNotFoundException(
|
||||
f"Scope {scope_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
return peer
|
||||
|
||||
|
||||
async def resolve_scope_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_names: Sequence[str],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Resolve unprefixed scope names to their backing scope-peer names.
|
||||
|
||||
Used by the read routes that accept a ``scope`` option (chat,
|
||||
representation, session context, workspace search) to turn user-facing
|
||||
scope names into the observer peers that implement them.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
scope_names: Unprefixed scope names (duplicates are collapsed,
|
||||
preserving first-seen order)
|
||||
|
||||
Returns:
|
||||
The backing scope-peer names, in first-requested order
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If any named scope does not exist
|
||||
ValidationException: If a peer occupies a scope's reserved name
|
||||
without the authoritative kind flag (a legacy collision)
|
||||
"""
|
||||
requested: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for name in scope_names:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
requested.append(name)
|
||||
|
||||
peer_names = [scope_peer_name(name) for name in requested]
|
||||
if not peer_names:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name.in_(peer_names))
|
||||
)
|
||||
peers_by_name = {peer.name: peer for peer in result.scalars().all()}
|
||||
|
||||
resolved: list[str] = []
|
||||
for name, peer_name in zip(requested, peer_names, strict=True):
|
||||
peer = peers_by_name.get(peer_name)
|
||||
if peer is None:
|
||||
raise ResourceNotFoundException(
|
||||
f"Scope {name} not found in workspace {workspace_name}"
|
||||
)
|
||||
# The kind flag is authoritative and lives in internal_metadata, so a
|
||||
# legacy peer merely occupying the reserved name is refused rather than
|
||||
# silently treated as a scope.
|
||||
if not is_scope_peer(peer.name, peer.internal_metadata):
|
||||
raise ValidationException(
|
||||
f"'{name}' does not name a scope: a non-scope peer occupies "
|
||||
+ "its reserved name."
|
||||
)
|
||||
resolved.append(peer_name)
|
||||
return resolved
|
||||
|
||||
|
||||
async def get_scope_sessions(
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
reverse: bool = False,
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Build a query for the active sessions that are members of a scope.
|
||||
|
||||
Membership is unbounded — a scope may span every session in a workspace — so
|
||||
this returns a query for the caller to paginate rather than a materialized
|
||||
list. Callers must check the scope exists themselves (``get_scope_or_raise``);
|
||||
an unknown scope yields an empty page here, not a 404.
|
||||
|
||||
Ordered by membership age, with the session id as a unique tiebreaker:
|
||||
``session_peers`` has a composite primary key and no id of its own, so
|
||||
``joined_at`` alone is not a stable pagination key.
|
||||
|
||||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
scope_name: Unprefixed scope name
|
||||
reverse: Whether to return newest memberships first
|
||||
|
||||
Returns:
|
||||
Select for the scope's member sessions
|
||||
"""
|
||||
stmt = (
|
||||
select(models.Session)
|
||||
.join(
|
||||
models.SessionPeer,
|
||||
(models.Session.name == models.SessionPeer.session_name)
|
||||
& (models.Session.workspace_name == models.SessionPeer.workspace_name),
|
||||
)
|
||||
.where(models.SessionPeer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.peer_name == scope_peer_name(scope_name))
|
||||
.where(models.SessionPeer.left_at.is_(None))
|
||||
.where(models.Session.is_active == True) # noqa: E712
|
||||
)
|
||||
if reverse:
|
||||
return stmt.order_by(
|
||||
models.SessionPeer.joined_at.desc(), models.Session.id.desc()
|
||||
)
|
||||
return stmt.order_by(models.SessionPeer.joined_at.asc(), models.Session.id.asc())
|
||||
|
||||
|
||||
async def add_sessions_to_scope(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
session_names: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Add sessions to a scope by creating observer memberships for its peer.
|
||||
|
||||
Each membership is a ``session_peers`` row for the scope peer with
|
||||
``observe_others=true, observe_me=false`` — exactly what a hand-built
|
||||
observer peer would carry. No backfill happens here: membership only affects
|
||||
messages ingested after this call, and conclusions already derived are left
|
||||
as they are.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
scope_name: Unprefixed scope name
|
||||
session_names: Names of existing sessions to add
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the scope or any named session does not
|
||||
exist
|
||||
"""
|
||||
# Imported lazily: crud.session imports this module for the session-create
|
||||
# `scopes` path, so a module-level import would be circular.
|
||||
from .session import upsert_session_peers
|
||||
|
||||
await get_scope_or_raise(db, workspace_name, scope_name)
|
||||
|
||||
requested = set(session_names)
|
||||
result = await db.execute(
|
||||
select(models.Session.name)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name.in_(requested))
|
||||
.where(models.Session.is_active == True) # noqa: E712
|
||||
)
|
||||
found = {row[0] for row in result.all()}
|
||||
missing = sorted(requested - found)
|
||||
if missing:
|
||||
raise ResourceNotFoundException(
|
||||
f"Session(s) {missing} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
for session_name in sorted(requested):
|
||||
await upsert_session_peers(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_names={scope_peer_name(scope_name): SCOPE_MEMBERSHIP_CONFIG},
|
||||
fetch_after_upsert=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def remove_session_from_scope(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
scope_name: str,
|
||||
session_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Remove a session from a scope by ending the scope peer's membership.
|
||||
|
||||
Ends the membership the same way the generic remove-peer path does (sets
|
||||
``left_at``). Conclusions derived while the session was a member are left in
|
||||
place — nothing reconciles them.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
workspace_name: Name of the workspace
|
||||
scope_name: Unprefixed scope name
|
||||
session_name: Name of the session to remove
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the scope or session does not exist
|
||||
"""
|
||||
# Lazy import for the same circular-import reason as add_sessions_to_scope.
|
||||
from .session import remove_peers_from_session
|
||||
|
||||
await get_scope_or_raise(db, workspace_name, scope_name)
|
||||
|
||||
await remove_peers_from_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_names={scope_peer_name(scope_name)},
|
||||
# This *is* the supported path for ending scope membership.
|
||||
_allow_scope_peers=True,
|
||||
)
|
||||
|
|
@ -7,7 +7,18 @@ from typing import cast as typing_cast
|
|||
|
||||
from cashews import NOT_NONE
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import Select, and_, case, cast, delete, func, insert, select, update
|
||||
from sqlalchemy import (
|
||||
Select,
|
||||
and_,
|
||||
case,
|
||||
cast,
|
||||
delete,
|
||||
exists,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -27,12 +38,21 @@ from src.exceptions import (
|
|||
ConflictException,
|
||||
ObserverException,
|
||||
ResourceNotFoundException,
|
||||
ValidationException,
|
||||
)
|
||||
from src.utils.filter import apply_filter
|
||||
from src.utils.scopes import is_scope_peer, scope_peer_name
|
||||
from src.utils.types import GetOrCreateResult
|
||||
from src.vector_store import get_external_vector_store
|
||||
|
||||
from .peer import get_or_create_peers, get_peer
|
||||
from .peer import (
|
||||
get_or_create_peers,
|
||||
get_peer,
|
||||
reject_scope_peers,
|
||||
scope_peer_clause,
|
||||
scope_peer_names,
|
||||
)
|
||||
from .scope import SCOPE_MEMBERSHIP_CONFIG, get_or_create_scopes
|
||||
from .workspace import get_or_create_workspace
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
|
@ -99,6 +119,30 @@ async def _fetch_session(
|
|||
}
|
||||
|
||||
|
||||
def _reject_resolved_scope_peers(peers: list[models.Peer]) -> None:
|
||||
"""Reject scope peers among rows already resolved for a membership upsert.
|
||||
|
||||
The route-level guards check names *before* peers are resolved, which leaves a
|
||||
check-then-upsert window: if a scope is created concurrently between that
|
||||
check and the upsert below, the generic path would attach the now-flagged
|
||||
scope peer with a default ``SessionPeerConfig()``, clobbering its
|
||||
``observe_others=True/observe_me=False`` membership config. This runs on the
|
||||
resolved rows inside the same transaction as the upsert, so there is no
|
||||
window and no extra query.
|
||||
|
||||
Raises:
|
||||
ValidationException: If any resolved peer is a scope.
|
||||
"""
|
||||
offenders = sorted(
|
||||
p.name for p in peers if is_scope_peer(p.name, p.internal_metadata)
|
||||
)
|
||||
if offenders:
|
||||
raise ValidationException(
|
||||
f"Peer name(s) {offenders} are scopes."
|
||||
+ " Scope membership is managed via the scopes routes."
|
||||
)
|
||||
|
||||
|
||||
def count_observers_in_config(
|
||||
peer_configs: dict[str, schemas.SessionPeerConfig],
|
||||
) -> int:
|
||||
|
|
@ -262,14 +306,39 @@ async def get_or_create_session(
|
|||
db,
|
||||
workspace_name=workspace_name,
|
||||
peers=[
|
||||
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
|
||||
schemas.PeerSpec(name=peer_name) for peer_name in session.peer_names
|
||||
],
|
||||
)
|
||||
_reject_resolved_scope_peers(peers_result.resource)
|
||||
await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session.name,
|
||||
peer_names=session.peer_names,
|
||||
fetch_after_upsert=False,
|
||||
)
|
||||
|
||||
# Add the session to any requested scopes: create-or-get each scope peer
|
||||
# and record an observer membership (observe_others=true, observe_me=false).
|
||||
# No backfill happens here — membership only affects messages ingested
|
||||
# after this point.
|
||||
scopes_result = None
|
||||
if session.scopes:
|
||||
scopes_result = await get_or_create_scopes(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
scopes=[
|
||||
schemas.ScopeCreate(name=scope_name) for scope_name in session.scopes
|
||||
],
|
||||
)
|
||||
await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session.name,
|
||||
peer_names=session.peer_names,
|
||||
peer_names={
|
||||
scope_peer_name(scope_name): SCOPE_MEMBERSHIP_CONFIG
|
||||
for scope_name in session.scopes
|
||||
},
|
||||
fetch_after_upsert=False,
|
||||
)
|
||||
|
||||
|
|
@ -280,6 +349,8 @@ async def get_or_create_session(
|
|||
await ws_result.post_commit()
|
||||
if peers_result is not None:
|
||||
await peers_result.post_commit()
|
||||
if scopes_result is not None:
|
||||
await scopes_result.post_commit()
|
||||
|
||||
# Only update cache if session data changed or was newly created
|
||||
if needs_cache_update:
|
||||
|
|
@ -768,6 +839,8 @@ async def remove_peers_from_session(
|
|||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: set[str],
|
||||
*,
|
||||
_allow_scope_peers: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Remove specified peers from a session.
|
||||
|
|
@ -777,16 +850,32 @@ async def remove_peers_from_session(
|
|||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
peer_names: Set of peer names to remove from the session
|
||||
_allow_scope_peers: Internal. Set only by the scopes facade, which ends
|
||||
scope membership through this same path and must not be blocked by
|
||||
the guard below.
|
||||
|
||||
Returns:
|
||||
True if peers were removed successfully
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session does not exist
|
||||
ValidationException: If any named peer is a scope
|
||||
"""
|
||||
# Verify session exists
|
||||
await get_session(db, session_name, workspace_name)
|
||||
|
||||
# Scope membership is ended through the scopes routes, which also reconcile
|
||||
# the scope's copies. Rejected up front for a clear 422 rather than a silent
|
||||
# no-op — but this check alone is only advisory: under READ COMMITTED a scope
|
||||
# can be created between it and the UPDATE below.
|
||||
if not _allow_scope_peers:
|
||||
await reject_scope_peers(
|
||||
db,
|
||||
workspace_name,
|
||||
peer_names,
|
||||
action="Scope membership is managed via the scopes routes.",
|
||||
)
|
||||
|
||||
# Soft delete specified session peers by setting left_at timestamp
|
||||
update_stmt = (
|
||||
update(models.SessionPeer)
|
||||
|
|
@ -798,6 +887,20 @@ async def remove_peers_from_session(
|
|||
)
|
||||
.values(left_at=func.now())
|
||||
)
|
||||
if not _allow_scope_peers:
|
||||
# Closes the window the advisory check above cannot: the exclusion is
|
||||
# evaluated by Postgres as part of the UPDATE, so a scope committed after
|
||||
# that check still cannot be detached here. Correlated rather than a join
|
||||
# so the statement stays a plain UPDATE.
|
||||
update_stmt = update_stmt.where(
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(scope_peer_clause())
|
||||
.correlate(models.SessionPeer)
|
||||
)
|
||||
)
|
||||
await db.execute(update_stmt)
|
||||
|
||||
await db.commit()
|
||||
|
|
@ -816,6 +919,13 @@ async def get_peers_from_session(
|
|||
workspace_name: Name of the workspace
|
||||
session_name: Name of the session
|
||||
|
||||
Scope peers are excluded: a scope's membership is the facade's internal
|
||||
observer wiring, and this is the generic peer surface. Listing them here
|
||||
would show a caller a peer named ``scope.<name>`` with ``observe_others``
|
||||
set, which is exactly the mechanic the facade exists to hide. Mirrors the
|
||||
``kind``-less default of ``crud.peer.get_peers``; the scopes routes expose
|
||||
membership from the other direction.
|
||||
|
||||
Returns:
|
||||
Paginated list of Peer objects in the session
|
||||
"""
|
||||
|
|
@ -832,6 +942,10 @@ async def get_peers_from_session(
|
|||
.where(models.SessionPeer.session_name == session_name)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.SessionPeer.left_at.is_(None)) # Only active peers
|
||||
# models.Peer is already in the FROM via the join above, so the clause
|
||||
# composes directly — no correlated exists() as in the SessionPeer-only
|
||||
# UPDATE statements elsewhere in this module.
|
||||
.where(~scope_peer_clause())
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -946,13 +1060,27 @@ async def set_peers_for_session(
|
|||
f"Session {session_name} not found in workspace {workspace_name}"
|
||||
)
|
||||
|
||||
# Soft delete specified session peers by setting left_at timestamp
|
||||
# Soft delete every *ordinary* active membership. Scope memberships are
|
||||
# deliberately preserved: this route replaces the peers the caller names, and a
|
||||
# caller detaches a scope by simply *omitting* it from an otherwise valid
|
||||
# replacement map — never naming it, so no request-level guard can see it.
|
||||
# Without the exclusion a plain replacement silently bypasses the facade that
|
||||
# owns scope membership and its removal reconciliation. Being part of the
|
||||
# UPDATE, this holds regardless of the request body or concurrent scope
|
||||
# creation.
|
||||
update_stmt = (
|
||||
update(models.SessionPeer)
|
||||
.where(
|
||||
models.SessionPeer.session_name == session_name,
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
models.SessionPeer.left_at.is_(None), # Only update active peers
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(scope_peer_clause())
|
||||
.correlate(models.SessionPeer)
|
||||
),
|
||||
)
|
||||
.values(left_at=func.now())
|
||||
)
|
||||
|
|
@ -962,8 +1090,9 @@ async def set_peers_for_session(
|
|||
peers_result = await get_or_create_peers(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
|
||||
peers=[schemas.PeerSpec(name=peer_name) for peer_name in peer_names],
|
||||
)
|
||||
_reject_resolved_scope_peers(peers_result.resource)
|
||||
|
||||
# Add new peers to session
|
||||
peers = await _get_or_add_peers_to_session(
|
||||
|
|
@ -978,6 +1107,30 @@ async def set_peers_for_session(
|
|||
return peers
|
||||
|
||||
|
||||
async def upsert_session_peers(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
session_name: str,
|
||||
peer_names: dict[str, schemas.SessionPeerConfig],
|
||||
*,
|
||||
fetch_after_upsert: bool = True,
|
||||
) -> list[models.SessionPeer]:
|
||||
"""Public wrapper around the session-peer membership upsert.
|
||||
|
||||
Exists for other crud modules (currently the scopes facade in
|
||||
``src/crud/scope.py``) that manage memberships directly, bypassing the
|
||||
route-level scope-peer guardrails. See ``_get_or_add_peers_to_session``
|
||||
for semantics.
|
||||
"""
|
||||
return await _get_or_add_peers_to_session(
|
||||
db,
|
||||
workspace_name=workspace_name,
|
||||
session_name=session_name,
|
||||
peer_names=peer_names,
|
||||
fetch_after_upsert=fetch_after_upsert,
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_add_peers_to_session(
|
||||
db: AsyncSession,
|
||||
workspace_name: str,
|
||||
|
|
@ -1020,8 +1173,17 @@ async def _get_or_add_peers_to_session(
|
|||
result = await db.execute(select_stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
# Only validate observer limit if we're adding peers with observe_others=True
|
||||
new_observer_count = count_observers_in_config(peer_names)
|
||||
# Scope memberships carry observe_others=True but do not count against the
|
||||
# limit. The limit bounds per-observer deriver fan-out for real peers; a scope
|
||||
# costs document rows, not LLM calls, and counting them would
|
||||
# cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an
|
||||
# observer-shaped 400 through a facade that hides observers entirely.
|
||||
scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys())
|
||||
|
||||
# Only validate observer limit if we're adding non-scope peers with observe_others=True
|
||||
new_observer_count = count_observers_in_config(
|
||||
{n: c for n, c in peer_names.items() if n not in scopes_being_added}
|
||||
)
|
||||
|
||||
if new_observer_count > 0:
|
||||
# Use a single efficient query to count existing observers not being updated
|
||||
|
|
@ -1036,6 +1198,14 @@ async def _get_or_add_peers_to_session(
|
|||
models.SessionPeer.configuration["observe_others"].astext.cast(
|
||||
Boolean
|
||||
), # Only observers
|
||||
# Existing scope memberships are excluded for the same reason as above.
|
||||
~exists(
|
||||
select(models.Peer.id)
|
||||
.where(models.Peer.workspace_name == workspace_name)
|
||||
.where(models.Peer.name == models.SessionPeer.peer_name)
|
||||
.where(scope_peer_clause())
|
||||
.correlate(models.SessionPeer)
|
||||
),
|
||||
)
|
||||
result = await db.execute(existing_observers_stmt)
|
||||
existing_observer_count = result.scalar() or 0
|
||||
|
|
@ -1111,7 +1281,14 @@ async def get_peer_config(
|
|||
|
||||
Raises:
|
||||
ResourceNotFoundException: If the session or peer does not exist
|
||||
ValidationException: If the peer is a scope
|
||||
"""
|
||||
# A scope's membership config belongs to the facade, not the caller — the
|
||||
# write path refuses it in set_peer_config below, and reading it back is the
|
||||
# same internal wiring by another route. Checked on the resolved row, so a
|
||||
# legacy peer merely occupying the reserved name keeps working.
|
||||
_reject_resolved_scope_peers([await get_peer(db, workspace_name, peer_id)])
|
||||
|
||||
# Get row from session_peer table
|
||||
stmt = select(models.SessionPeer).where(
|
||||
models.SessionPeer.workspace_name == workspace_name,
|
||||
|
|
@ -1148,10 +1325,18 @@ async def set_peer_config(
|
|||
|
||||
Raises:
|
||||
ObserverException: If the update would exceed the observer limit
|
||||
ValidationException: If the peer is a scope
|
||||
"""
|
||||
# First, get the session and peer to ensure they exist
|
||||
await get_session(db, session_name, workspace_name)
|
||||
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
|
||||
peer = await get_peer(db, workspace_name, peer_name)
|
||||
|
||||
# A scope's membership config is the facade's, not the caller's: setting
|
||||
# observe_others=false silently stops all fan-out into the scope, and
|
||||
# observe_me=true makes Honcho form a representation *of* a scope, which
|
||||
# never happens by design. Checked on the row just resolved above, so there
|
||||
# is no check-then-use window and no extra query.
|
||||
_reject_resolved_scope_peers([peer])
|
||||
|
||||
# Check if a SessionPeer entry already exists
|
||||
stmt = (
|
||||
|
|
|
|||
|
|
@ -477,6 +477,22 @@ async def enqueue_dream(
|
|||
rebuild: card_refresh only — rebuild the card without the prior card
|
||||
"""
|
||||
async with tracked_db("dream_enqueue") as db_session:
|
||||
# Authoritative scope check, in the same transaction as the queue insert.
|
||||
# A route-level precheck cannot be relied on: it runs in its own session,
|
||||
# and a *missing* reserved name passes it (nothing has flagged that peer
|
||||
# yet) — so the dream would be enqueued and the scope created before the
|
||||
# worker picked it up, letting the Dreamer run with a real scope as
|
||||
# observed. A scope as `observer` stays allowed: consolidating scoped
|
||||
# collections is exactly what the Dreamer does.
|
||||
await crud.reject_scope_observed(
|
||||
db_session,
|
||||
workspace_name,
|
||||
[observed],
|
||||
action=(
|
||||
"No representation is formed of a scope, so a scope cannot be the"
|
||||
" observed peer of a dream."
|
||||
),
|
||||
)
|
||||
try:
|
||||
dream_record = create_dream_record(
|
||||
workspace_name,
|
||||
|
|
|
|||
|
|
@ -10,15 +10,41 @@ from collections.abc import AsyncIterator
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src import crud, schemas
|
||||
from src import crud, models
|
||||
from src.config import ReasoningLevel
|
||||
from src.dependencies import tracked_db
|
||||
from src.dialectic.core import DialecticAgent
|
||||
from src.exceptions import ValidationException
|
||||
from src.utils.config_helpers import get_configuration
|
||||
from src.utils.scopes import is_scope_peer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reject_scope_observed(peer: models.Peer) -> None:
|
||||
"""Refuse a dialectic run whose *observed* peer is a scope.
|
||||
|
||||
A scope is a silent observer with ``observe_me=false``: no representation of
|
||||
one exists to query, so it can never be the subject.
|
||||
|
||||
The observer position is deliberately NOT checked here. A single `scope` on
|
||||
chat swaps the observer to the scope peer — answering from a scope's
|
||||
perspective is the entire point of that option — so a guard here would reject
|
||||
every scoped chat. The raw path peer is still refused as an observer, by the
|
||||
route (``routers/peers.py``), where the distinction between "the caller named
|
||||
a scope" and "the `scope` option resolved to one" is still visible.
|
||||
|
||||
Raises:
|
||||
ValidationException: If the observed peer is a scope.
|
||||
"""
|
||||
if is_scope_peer(peer.name, peer.internal_metadata):
|
||||
raise ValidationException(
|
||||
f"Peer name '{peer.name}' is a scope."
|
||||
+ " No representation is formed of a scope, so a scope cannot be a"
|
||||
+ " dialectic target."
|
||||
)
|
||||
|
||||
|
||||
async def agentic_chat(
|
||||
workspace_name: str,
|
||||
session_name: str | None,
|
||||
|
|
@ -48,9 +74,18 @@ async def agentic_chat(
|
|||
"""
|
||||
# Short-lived DB session for validation + config
|
||||
async with tracked_db("dialectic.preflight", read_only=True) as db:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
|
||||
observer_peer = await crud.get_peer(db, workspace_name, observer)
|
||||
observed_peer = observer_peer
|
||||
if observer != observed:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
|
||||
observed_peer = await crud.get_peer(db, workspace_name, observed)
|
||||
|
||||
# Resolved-row scope check, not a name check. The routes reject a scope
|
||||
# target up front for a clear error, but that runs before resolution: a
|
||||
# scope created in between would otherwise be answered about here.
|
||||
# Checking the row we just resolved closes that window — an absent name
|
||||
# already failed above, and an existing unflagged squatter cannot
|
||||
# retroactively become a scope.
|
||||
_reject_scope_observed(observed_peer)
|
||||
|
||||
session = None
|
||||
if session_name:
|
||||
|
|
@ -120,9 +155,18 @@ async def agentic_chat_stream(
|
|||
"""
|
||||
# Short-lived DB session for validation + config
|
||||
async with tracked_db("dialectic.preflight", read_only=True) as db:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
|
||||
observer_peer = await crud.get_peer(db, workspace_name, observer)
|
||||
observed_peer = observer_peer
|
||||
if observer != observed:
|
||||
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
|
||||
observed_peer = await crud.get_peer(db, workspace_name, observed)
|
||||
|
||||
# Resolved-row scope check, not a name check. The routes reject a scope
|
||||
# target up front for a clear error, but that runs before resolution: a
|
||||
# scope created in between would otherwise be answered about here.
|
||||
# Checking the row we just resolved closes that window — an absent name
|
||||
# already failed above, and an existing unflagged squatter cannot
|
||||
# retroactively become a scope.
|
||||
_reject_scope_observed(observed_peer)
|
||||
|
||||
session = None
|
||||
if session_name:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from typing import Any, cast
|
|||
|
||||
from nanoid import generate as generate_nanoid
|
||||
|
||||
from src import crud, schemas
|
||||
from src import crud
|
||||
from src.config import ConfiguredModelSettings, settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.exceptions import ValidationException
|
||||
|
|
@ -266,13 +266,9 @@ If you update it, send the full deduplicated list and remove stale entries.
|
|||
try:
|
||||
# Short-lived DB session for preflight operations
|
||||
async with tracked_db("dream.specialist.preflight") as db:
|
||||
await crud.get_peer(
|
||||
db, workspace_name, schemas.PeerCreate(name=observer)
|
||||
)
|
||||
await crud.get_peer(db, workspace_name, observer)
|
||||
if observer != observed:
|
||||
await crud.get_peer(
|
||||
db, workspace_name, schemas.PeerCreate(name=observed)
|
||||
)
|
||||
await crud.get_peer(db, workspace_name, observed)
|
||||
|
||||
# Determine if peer card tools should be included. Specialists that
|
||||
# cannot write to the peer card (e.g., induction) skip the fetch and
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Literal, NamedTuple, TypeVar
|
||||
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast
|
||||
|
||||
import tiktoken
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from nanoid import generate as generate_nanoid
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .config import (
|
||||
EmbeddingEncodingFormat,
|
||||
|
|
@ -19,6 +18,10 @@ from .config import (
|
|||
settings,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from google import genai
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
|
@ -189,6 +192,9 @@ class _EmbeddingClient:
|
|||
if self.transport == "gemini":
|
||||
if not config.api_key:
|
||||
raise ValueError("Gemini API key is required")
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
# 10-minute HTTP timeout, in lockstep with the LLM registry's Gemini
|
||||
# client (`src/llm/registry.py:_build_gemini_http_options`). Without
|
||||
# this, a stalled Gemini embedding socket wedges the deriver worker
|
||||
|
|
@ -208,6 +214,8 @@ class _EmbeddingClient:
|
|||
else: # openai
|
||||
if not config.api_key:
|
||||
raise ValueError("OpenAI API key is required")
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
|
|
@ -264,11 +272,11 @@ class _EmbeddingClient:
|
|||
f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)"
|
||||
)
|
||||
|
||||
# Bind the typed client at the dispatch site so pyright can narrow it
|
||||
# for the closures without needing `assert isinstance(...)` (bandit
|
||||
# B101). The closures close over the narrowed local, not `self.client`.
|
||||
if isinstance(self.client, genai.Client):
|
||||
gemini_client = self.client
|
||||
# Dispatch on transport rather than isinstance so this module never
|
||||
# needs the SDK types at runtime; the cast gives the closures a typed
|
||||
# local to close over.
|
||||
if self.transport == "gemini":
|
||||
gemini_client = cast("genai.Client", self.client)
|
||||
|
||||
async def _call_gemini() -> list[float]:
|
||||
response = await gemini_client.aio.models.embed_content(
|
||||
|
|
@ -290,7 +298,7 @@ class _EmbeddingClient:
|
|||
fn=_call_gemini,
|
||||
)
|
||||
|
||||
openai_client = self.client
|
||||
openai_client = cast("AsyncOpenAI", self.client)
|
||||
|
||||
async def _call_openai() -> list[float]:
|
||||
openai_kwargs: dict[str, Any] = {"model": self.model, "input": [query]}
|
||||
|
|
@ -531,8 +539,11 @@ class _EmbeddingClient:
|
|||
attempt is a distinct provider hit and shows up as its own line
|
||||
item in analytics."""
|
||||
result: dict[str, dict[int, list[float]]] = defaultdict(dict)
|
||||
if isinstance(self.client, genai.Client):
|
||||
response = await self.client.aio.models.embed_content(
|
||||
if self.transport == "gemini":
|
||||
from google.genai import types as genai_types
|
||||
|
||||
gemini_client = cast("genai.Client", self.client)
|
||||
response = await gemini_client.aio.models.embed_content(
|
||||
model=self.model,
|
||||
# One Content per item: a list of bare strings is folded
|
||||
# into a single document by gemini-embedding-2*, which
|
||||
|
|
@ -557,7 +568,8 @@ class _EmbeddingClient:
|
|||
self._apply_encoding_format(openai_kwargs)
|
||||
if self.send_dimensions:
|
||||
openai_kwargs["dimensions"] = self.vector_dimensions
|
||||
response = await self.client.embeddings.create(**openai_kwargs)
|
||||
openai_client = cast("AsyncOpenAI", self.client)
|
||||
response = await openai_client.embeddings.create(**openai_kwargs)
|
||||
self._validate_embedding_count(len(batch), len(response.data))
|
||||
for item, embedding_data in zip(batch, response.data, strict=True):
|
||||
result[item.text_id][item.chunk_index] = (
|
||||
|
|
@ -666,10 +678,10 @@ class EmbeddingClient:
|
|||
and allowing the application to start even if API keys are not yet configured.
|
||||
"""
|
||||
|
||||
_instance: "_EmbeddingClient | None" = None
|
||||
_instance: _EmbeddingClient | None = None
|
||||
_instance_signature: tuple[object, ...] | None = None
|
||||
_lock: threading.Lock = threading.Lock()
|
||||
_wrapper_instance: "EmbeddingClient | None" = None
|
||||
_wrapper_instance: EmbeddingClient | None = None
|
||||
|
||||
def __new__(cls):
|
||||
"""Ensure only one instance of EmbeddingClient exists."""
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .registry import (
|
|||
CLIENTS,
|
||||
backend_for_provider,
|
||||
client_for_model_config,
|
||||
default_client,
|
||||
get_anthropic_client,
|
||||
get_anthropic_override_client,
|
||||
get_backend,
|
||||
|
|
@ -51,6 +52,7 @@ __all__ = [
|
|||
"VerbosityType",
|
||||
"backend_for_provider",
|
||||
"client_for_model_config",
|
||||
"default_client",
|
||||
"default_transport_api_key",
|
||||
"get_anthropic_client",
|
||||
"get_anthropic_override_client",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from .backend import CompletionResult as BackendCompletionResult
|
|||
from .backend import StreamChunk as BackendStreamChunk
|
||||
from .backend import ToolCallResult
|
||||
from .capture import build_captured_call, dispatch_captured_call, has_exporters
|
||||
from .registry import CLIENTS, backend_for_provider
|
||||
from .registry import backend_for_provider, default_client
|
||||
from .request_builder import execute_completion, execute_stream
|
||||
from .runtime import (
|
||||
AttemptPlan,
|
||||
|
|
@ -439,7 +439,7 @@ async def honcho_llm_call_inner(
|
|||
post-stream at this layer; aggregate envelopes (DialecticCompletedEvent
|
||||
etc.) carry the accurate totals.
|
||||
"""
|
||||
client = client_override or CLIENTS.get(provider)
|
||||
client = client_override or default_client(provider)
|
||||
if client is None:
|
||||
raise ValueError(f"Missing client for {provider}")
|
||||
|
||||
|
|
|
|||
|
|
@ -9,20 +9,12 @@ history adapter selection) lives here now.
|
|||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import assert_never
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from openai import AsyncOpenAI
|
||||
from typing import TYPE_CHECKING, assert_never
|
||||
|
||||
from src.config import ModelConfig, ModelTransport, settings
|
||||
from src.exceptions import ValidationException
|
||||
|
||||
from .backend import ProviderBackend
|
||||
from .backends.anthropic import AnthropicBackend
|
||||
from .backends.gemini import GeminiBackend
|
||||
from .backends.openai import OpenAIBackend
|
||||
from .credentials import default_transport_api_key
|
||||
from .history_adapters import (
|
||||
AnthropicHistoryAdapter,
|
||||
|
|
@ -32,6 +24,15 @@ from .history_adapters import (
|
|||
)
|
||||
from .types import ProviderClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# Provider SDKs are imported lazily inside the client factories below so a
|
||||
# process only pays the import-time memory cost of the providers it uses.
|
||||
|
||||
# Default client-level HTTP timeouts. Anthropic accepts seconds (float);
|
||||
# google-genai's HttpOptions.timeout is an int in milliseconds, so the Gemini
|
||||
# value is kept separately. Both default to 10 minutes to match the existing
|
||||
|
|
@ -71,6 +72,8 @@ def _build_gemini_http_options(base_url: str | None) -> genai_types.HttpOptions:
|
|||
timeout even when no ``base_url`` is configured — that's the path the
|
||||
default ``get_gemini_client`` takes and it's the one that was hanging.
|
||||
"""
|
||||
from google.genai import types as genai_types
|
||||
|
||||
return genai_types.HttpOptions(
|
||||
base_url=base_url,
|
||||
timeout=_GEMINI_TIMEOUT_MS,
|
||||
|
|
@ -80,6 +83,8 @@ def _build_gemini_http_options(base_url: str | None) -> genai_types.HttpOptions:
|
|||
@lru_cache(maxsize=1)
|
||||
def get_anthropic_client() -> AsyncAnthropic:
|
||||
"""Default Anthropic client built from settings.LLM.ANTHROPIC_API_KEY."""
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
return AsyncAnthropic(
|
||||
api_key=settings.LLM.ANTHROPIC_API_KEY,
|
||||
base_url=settings.LLM.ANTHROPIC_BASE_URL,
|
||||
|
|
@ -90,6 +95,8 @@ def get_anthropic_client() -> AsyncAnthropic:
|
|||
@lru_cache(maxsize=1)
|
||||
def get_openai_client() -> AsyncOpenAI:
|
||||
"""Default OpenAI client built from settings.LLM.OPENAI_API_KEY."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
return AsyncOpenAI(
|
||||
api_key=settings.LLM.OPENAI_API_KEY,
|
||||
base_url=settings.LLM.OPENAI_BASE_URL,
|
||||
|
|
@ -100,6 +107,8 @@ def get_openai_client() -> AsyncOpenAI:
|
|||
@lru_cache(maxsize=1)
|
||||
def get_gemini_client() -> genai.Client:
|
||||
"""Default Gemini client built from settings.LLM.GEMINI_API_KEY."""
|
||||
from google import genai
|
||||
|
||||
return genai.Client(
|
||||
api_key=settings.LLM.GEMINI_API_KEY,
|
||||
http_options=_build_gemini_http_options(settings.LLM.GEMINI_BASE_URL),
|
||||
|
|
@ -113,6 +122,8 @@ def get_openai_override_client(
|
|||
base_url: str | None, api_key: str | None
|
||||
) -> AsyncOpenAI:
|
||||
"""OpenAI client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
|
|
@ -126,6 +137,8 @@ def get_anthropic_override_client(
|
|||
api_key: str | None,
|
||||
) -> AsyncAnthropic:
|
||||
"""Anthropic client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
return AsyncAnthropic(
|
||||
api_key=api_key, base_url=base_url, timeout=_ANTHROPIC_TIMEOUT_S
|
||||
)
|
||||
|
|
@ -136,35 +149,48 @@ def get_gemini_override_client(
|
|||
base_url: str | None, api_key: str | None
|
||||
) -> genai.Client:
|
||||
"""Gemini client for a specific (base_url, api_key) pair. Cached by key."""
|
||||
from google import genai
|
||||
|
||||
return genai.Client(
|
||||
api_key=api_key,
|
||||
http_options=_build_gemini_http_options(base_url),
|
||||
)
|
||||
|
||||
|
||||
# Module-level default-client registry, populated at import time. Tests patch
|
||||
# this dict via `patch.dict(CLIENTS, {...})` to inject mock provider clients.
|
||||
# Module-level default-client registry, populated lazily on first use so a
|
||||
# provider's SDK is only imported when that provider is actually called. Tests
|
||||
# patch this dict via `patch.dict(CLIENTS, {...})` to inject mock provider
|
||||
# clients; a patched entry always wins because `default_client` checks the
|
||||
# dict before constructing anything.
|
||||
CLIENTS: dict[ModelTransport, ProviderClient] = {}
|
||||
|
||||
if settings.LLM.ANTHROPIC_API_KEY:
|
||||
CLIENTS["anthropic"] = AsyncAnthropic(
|
||||
api_key=settings.LLM.ANTHROPIC_API_KEY,
|
||||
base_url=settings.LLM.ANTHROPIC_BASE_URL,
|
||||
timeout=_ANTHROPIC_TIMEOUT_S,
|
||||
)
|
||||
|
||||
if settings.LLM.OPENAI_API_KEY:
|
||||
CLIENTS["openai"] = AsyncOpenAI(
|
||||
api_key=settings.LLM.OPENAI_API_KEY,
|
||||
base_url=settings.LLM.OPENAI_BASE_URL,
|
||||
default_headers=_default_headers_for(settings.LLM.OPENAI_BASE_URL),
|
||||
)
|
||||
def default_client(provider: ModelTransport) -> ProviderClient | None:
|
||||
"""Default client for ``provider``, built on first use.
|
||||
|
||||
if settings.LLM.GEMINI_API_KEY:
|
||||
CLIENTS["gemini"] = genai.Client(
|
||||
api_key=settings.LLM.GEMINI_API_KEY,
|
||||
http_options=_build_gemini_http_options(settings.LLM.GEMINI_BASE_URL),
|
||||
)
|
||||
Returns None when no API key is configured for the provider.
|
||||
"""
|
||||
existing = CLIENTS.get(provider)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
if provider == "anthropic":
|
||||
if not settings.LLM.ANTHROPIC_API_KEY:
|
||||
return None
|
||||
client: ProviderClient = get_anthropic_client()
|
||||
elif provider == "openai":
|
||||
if not settings.LLM.OPENAI_API_KEY:
|
||||
return None
|
||||
client = get_openai_client()
|
||||
elif provider == "gemini":
|
||||
if not settings.LLM.GEMINI_API_KEY:
|
||||
return None
|
||||
client = get_gemini_client()
|
||||
else:
|
||||
assert_never(provider)
|
||||
|
||||
CLIENTS[provider] = client
|
||||
return client
|
||||
|
||||
|
||||
def client_for_model_config(
|
||||
|
|
@ -178,7 +204,7 @@ def client_for_model_config(
|
|||
override factories.
|
||||
"""
|
||||
if model_config.api_key is None and model_config.base_url is None:
|
||||
existing_client = CLIENTS.get(provider)
|
||||
existing_client = default_client(provider)
|
||||
if existing_client is not None:
|
||||
return existing_client
|
||||
|
||||
|
|
@ -202,10 +228,16 @@ def backend_for_provider(
|
|||
) -> ProviderBackend:
|
||||
"""Wrap a raw provider SDK client in the matching ProviderBackend adapter."""
|
||||
if provider == "anthropic":
|
||||
from .backends.anthropic import AnthropicBackend
|
||||
|
||||
return AnthropicBackend(client)
|
||||
if provider == "openai":
|
||||
from .backends.openai import OpenAIBackend
|
||||
|
||||
return OpenAIBackend(client)
|
||||
if provider == "gemini":
|
||||
from .backends.gemini import GeminiBackend
|
||||
|
||||
return GeminiBackend(client)
|
||||
assert_never(provider)
|
||||
|
||||
|
|
@ -236,6 +268,7 @@ __all__ = [
|
|||
"CLIENTS",
|
||||
"backend_for_provider",
|
||||
"client_for_model_config",
|
||||
"default_client",
|
||||
"get_anthropic_client",
|
||||
"get_anthropic_override_client",
|
||||
"get_backend",
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@ from collections.abc import AsyncIterator, Callable
|
|||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anthropic import AsyncAnthropic
|
||||
from google import genai
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from src.llm.capture import CapturedMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,8 +31,13 @@ ReasoningEffortType = (
|
|||
)
|
||||
VerbosityType = Literal["low", "medium", "high"] | None
|
||||
|
||||
# Raw SDK client union used by the provider-selection layer.
|
||||
ProviderClient = AsyncAnthropic | AsyncOpenAI | genai.Client
|
||||
# Raw SDK client union used by the provider-selection layer. The SDK types are
|
||||
# only imported for type checking; at runtime this stays Any so importing this
|
||||
# module doesn't load any provider SDK.
|
||||
if TYPE_CHECKING:
|
||||
ProviderClient = AsyncAnthropic | AsyncOpenAI | genai.Client
|
||||
else:
|
||||
ProviderClient = Any
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from src.routers import (
|
|||
keys,
|
||||
messages,
|
||||
peers,
|
||||
scopes,
|
||||
sessions,
|
||||
webhooks,
|
||||
workspaces,
|
||||
|
|
@ -171,6 +172,7 @@ add_pagination(app)
|
|||
app.include_router(workspaces.router, prefix="/v3")
|
||||
app.include_router(peers.router, prefix="/v3")
|
||||
app.include_router(sessions.router, prefix="/v3")
|
||||
app.include_router(scopes.router, prefix="/v3")
|
||||
app.include_router(messages.router, prefix="/v3")
|
||||
app.include_router(conclusions.router, prefix="/v3")
|
||||
app.include_router(keys.router, prefix="/v3")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import logging
|
|||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -28,8 +29,13 @@ from src.exceptions import (
|
|||
from src.security import JWTParams, require_auth
|
||||
from src.telemetry import prometheus_metrics
|
||||
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
|
||||
from src.utils.filter import extract_session_allowlist
|
||||
from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES, extract_session_allowlist
|
||||
from src.utils.schema_conversion import json_response_schema_to_pydantic
|
||||
from src.utils.scopes import (
|
||||
is_scope_peer,
|
||||
is_scope_peer_name,
|
||||
validate_no_scope_peer_names,
|
||||
)
|
||||
from src.utils.search import search
|
||||
from src.utils.types import embedding_call_purpose
|
||||
|
||||
|
|
@ -41,6 +47,73 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
def _validate_scope_option(
|
||||
*,
|
||||
filters: dict[str, Any] | None,
|
||||
session_id: str | None,
|
||||
jwt_params: JWTParams,
|
||||
) -> None:
|
||||
"""Enforce the v1 `scope` exclusions and auth rule (chat/representation).
|
||||
|
||||
`scope` is mutually exclusive with `filters` and `session_id` (422), and a
|
||||
scope's member sessions may exceed a peer's own membership, so scoped
|
||||
reads require a workspace- or admin-level key.
|
||||
|
||||
401 rather than 403: every other scope surface refuses a narrow key with 401
|
||||
— the `/scopes` router via `require_auth`, and the `scopes` field on session
|
||||
create — so a peer key would otherwise get two different codes for the same
|
||||
feature depending on which side of it was touched.
|
||||
"""
|
||||
if filters is not None:
|
||||
raise ValidationException("`scope` and `filters` are mutually exclusive")
|
||||
if session_id:
|
||||
raise ValidationException("`scope` and `session_id` are mutually exclusive")
|
||||
if jwt_params.p is not None:
|
||||
raise AuthenticationException(
|
||||
"`scope` requires a workspace- or admin-level key"
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_scope_option(
|
||||
workspace_id: str,
|
||||
scope: str | list[str],
|
||||
*,
|
||||
db_action: str,
|
||||
) -> tuple[str | None, list[str] | None]:
|
||||
"""Map a validated `scope` option to (observer_override, session_allowlist).
|
||||
|
||||
A single scope swaps the observer to the scope peer: conclusion recall is
|
||||
then confined to the (scope, observed) collection and message recall to
|
||||
the scope's session membership by existing observer semantics. A list of
|
||||
scopes keeps the path peer as observer and returns the union of the
|
||||
scopes' member sessions as an explicit allowlist (fail-closed when empty).
|
||||
"""
|
||||
async with tracked_db(db_action, read_only=True) as scope_db:
|
||||
if isinstance(scope, str):
|
||||
[scope_peer] = await crud.resolve_scope_peers(
|
||||
scope_db, workspace_id, [scope]
|
||||
)
|
||||
return scope_peer, None
|
||||
|
||||
scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope)
|
||||
union: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for scope_peer in scope_peers:
|
||||
for session_name in await get_peer_session_names(
|
||||
scope_db, workspace_id, scope_peer
|
||||
):
|
||||
if session_name not in seen:
|
||||
seen.add(session_name)
|
||||
union.append(session_name)
|
||||
|
||||
if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES:
|
||||
raise ValidationException(
|
||||
"The scopes' combined membership exceeds the maximum of "
|
||||
+ f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
|
||||
)
|
||||
return None, union
|
||||
|
||||
|
||||
@router.post(
|
||||
"/list",
|
||||
response_model=Page[schemas.Peer],
|
||||
|
|
@ -54,7 +127,11 @@ async def get_peers(
|
|||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""Get all Peers for a Workspace, paginated with optional filters."""
|
||||
"""Get all Peers for a Workspace, paginated with optional filters.
|
||||
|
||||
Scope peers are excluded by default; set `kind` to "scope" for scope peers
|
||||
only, or "all" for everything.
|
||||
"""
|
||||
filter_param = None
|
||||
if options and hasattr(options, "filters"):
|
||||
filter_param = options.filters
|
||||
|
|
@ -67,6 +144,7 @@ async def get_peers(
|
|||
workspace_name=workspace_id,
|
||||
filters=filter_param,
|
||||
reverse=reverse,
|
||||
kind=options.kind if options else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -100,6 +178,14 @@ async def get_or_create_peer(
|
|||
if not jwt_params.p:
|
||||
raise AuthenticationException("Peer ID not found in query parameter or JWT")
|
||||
peer.name = jwt_params.p
|
||||
|
||||
# The scope namespace is reserved: scope peers are only created through
|
||||
# the scopes facade (POST /workspaces/{workspace_id}/scopes).
|
||||
validate_no_scope_peer_names(
|
||||
[peer.name],
|
||||
action="Use the scopes routes to create scopes.",
|
||||
)
|
||||
|
||||
result = await crud.get_or_create_peers(
|
||||
db, workspace_name=workspace_id, peers=[peer]
|
||||
)
|
||||
|
|
@ -122,7 +208,19 @@ async def update_peer(
|
|||
peer: schemas.PeerUpdate = Body(..., description="Updated peer parameters"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Update a Peer's metadata and/or configuration."""
|
||||
"""Update a Peer's metadata and/or configuration.
|
||||
|
||||
Returns 422 if the peer is a scope — use the scopes routes to manage scopes.
|
||||
"""
|
||||
# Three-way on the reserved namespace, all enforced inside ``crud.update_peer``
|
||||
# on the resolved row so there is no check-then-use window: a real scope is
|
||||
# refused (this route replaces `configuration` wholesale, so it must never touch
|
||||
# a facade-managed peer); an existing *unflagged* peer that merely occupies the
|
||||
# namespace is a normal peer and updates fine; and a reserved-prefix name that
|
||||
# does not exist is refused by create-path validation rather than being minted.
|
||||
#
|
||||
# Kept out of the docstring deliberately: FastAPI publishes that into the
|
||||
# OpenAPI description, and callers need the contract, not the mechanism.
|
||||
updated_peer = await crud.update_peer(
|
||||
db, workspace_name=workspace_id, peer_name=peer_id, peer=peer
|
||||
)
|
||||
|
|
@ -189,6 +287,46 @@ async def chat(
|
|||
Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively
|
||||
answer the query based on all latent knowledge gathered about the peer from their messages and conclusions.
|
||||
"""
|
||||
# Scope peers are never observed, so no representation of them exists to
|
||||
# query. Covers the path-level observer too: a scope `peer_id` no longer
|
||||
# errors out downstream now that crud.get_peer takes a plain name, and
|
||||
# querying from a scope's perspective is a read-side surface that does not
|
||||
# exist yet.
|
||||
scope_candidates = [
|
||||
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
|
||||
]
|
||||
if scope_candidates:
|
||||
async with tracked_db("peers.chat.scope_check", read_only=True) as s_db:
|
||||
# Strict variant, matching the representation route: `target` is an
|
||||
# observed position and nothing here creates the peer, so a reserved
|
||||
# name that does not exist yet must be refused rather than answered
|
||||
# and then turned into a scope.
|
||||
await crud.reject_scope_observed(
|
||||
s_db,
|
||||
workspace_id,
|
||||
scope_candidates,
|
||||
action=(
|
||||
"No representation is formed of a scope, so a scope cannot "
|
||||
"be a chat observer or target."
|
||||
),
|
||||
)
|
||||
|
||||
# Scoped reads: a single scope swaps the observer to the scope
|
||||
# peer; a list of scopes becomes a session allowlist over their union.
|
||||
observer = peer_id
|
||||
scope_session_union: list[str] | None = None
|
||||
if options.scope is not None:
|
||||
_validate_scope_option(
|
||||
filters=options.filters,
|
||||
session_id=options.session_id,
|
||||
jwt_params=jwt_params,
|
||||
)
|
||||
observer_override, scope_session_union = await _resolve_scope_option(
|
||||
workspace_id, options.scope, db_action="peers.chat.resolve_scope"
|
||||
)
|
||||
if observer_override is not None:
|
||||
observer = observer_override
|
||||
|
||||
# The session id arrives in the body, so require_auth can't gate on it. A
|
||||
# peer-scoped key may only scope a chat to a session its peer belongs to;
|
||||
# without this check it could read any session's messages (the dialectic
|
||||
|
|
@ -220,6 +358,12 @@ async def chat(
|
|||
if not set(session_allowlist) <= member_sessions:
|
||||
raise AuthenticationException("JWT not permissioned for this resource")
|
||||
|
||||
# A list of scopes resolves to a session allowlist over their union, which
|
||||
# replaces any filters-derived allowlist (the two are mutually exclusive, so
|
||||
# only one can be set).
|
||||
if scope_session_union is not None:
|
||||
session_allowlist = scope_session_union
|
||||
|
||||
# Convert the caller's JSON Schema so malformed schemas fail immediately with 422
|
||||
response_model: type[BaseModel] | None = None
|
||||
if options.response_format is not None:
|
||||
|
|
@ -233,8 +377,20 @@ async def chat(
|
|||
peers_result = await crud.get_or_create_peers(
|
||||
peer_db,
|
||||
workspace_name=workspace_id,
|
||||
peers=[schemas.PeerCreate(name=peer_id)],
|
||||
peers=[schemas.PeerSpec(name=peer_id)],
|
||||
)
|
||||
# Re-check on the resolved row: the name-level check above ran before the
|
||||
# peer was resolved, so a scope created in between would be picked up here
|
||||
# as existing and used as the chat observer. Deliberately NOT named
|
||||
# `observer` — that holds the effective observer, which a single `scope`
|
||||
# has already swapped to the scope peer, and rebinding it here would
|
||||
# silently undo the swap.
|
||||
path_peer = peers_result.resource[0]
|
||||
if is_scope_peer(path_peer.name, path_peer.internal_metadata):
|
||||
raise ValidationException(
|
||||
"No representation is formed of a scope, so a scope cannot be a "
|
||||
+ "chat observer or target."
|
||||
)
|
||||
await peer_db.commit()
|
||||
await peers_result.post_commit()
|
||||
|
||||
|
|
@ -262,7 +418,7 @@ async def chat(
|
|||
workspace_name=workspace_id,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
observer=peer_id,
|
||||
observer=observer,
|
||||
observed=options.target if options.target is not None else peer_id,
|
||||
reasoning_level=options.reasoning_level,
|
||||
session_allowlist=session_allowlist,
|
||||
|
|
@ -276,7 +432,8 @@ async def chat(
|
|||
workspace_name=workspace_id,
|
||||
session_name=options.session_id,
|
||||
query=options.query,
|
||||
observer=peer_id,
|
||||
# a single `scope` swaps the observer to the scope peer
|
||||
observer=observer,
|
||||
# if target is given, that's the observed peer. otherwise, observer==observed
|
||||
# and it's answered from the omniscient Honcho perspective
|
||||
observed=options.target if options.target is not None else peer_id,
|
||||
|
|
@ -298,9 +455,6 @@ async def chat(
|
|||
@router.post(
|
||||
"/{peer_id}/representation",
|
||||
response_model=schemas.RepresentationResponse,
|
||||
dependencies=[
|
||||
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
|
||||
],
|
||||
)
|
||||
async def get_representation(
|
||||
workspace_id: str = Path(...),
|
||||
|
|
@ -308,6 +462,9 @@ async def get_representation(
|
|||
options: schemas.PeerRepresentationGet = Body(
|
||||
..., description="Options for getting the peer representation"
|
||||
),
|
||||
jwt_params: JWTParams = Depends(
|
||||
require_auth(workspace_name="workspace_id", peer_name="peer_id")
|
||||
),
|
||||
):
|
||||
"""Get a curated subset of a Peer's Representation. A Representation is always a subset of the total
|
||||
knowledge about the Peer. The subset can be scoped and filtered in various ways.
|
||||
|
|
@ -317,45 +474,124 @@ async def get_representation(
|
|||
If a target is provided, we get the Representation of the target from the perspective of the Peer.
|
||||
If no target is provided, we get the omniscient Honcho Representation of the Peer.
|
||||
"""
|
||||
# Fast-fail before any embedding work. Same guard as the authoritative one
|
||||
# below, so a reserved name is refused here rather than after paying for an
|
||||
# embedding; the check is repeated at the read because this session closes and
|
||||
# a scope could be created in between.
|
||||
scope_candidates = [
|
||||
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
|
||||
]
|
||||
if scope_candidates:
|
||||
async with tracked_db(
|
||||
"peers.representation.scope_check", read_only=True
|
||||
) as s_db:
|
||||
await crud.reject_scope_observed(
|
||||
s_db,
|
||||
workspace_id,
|
||||
scope_candidates,
|
||||
action=(
|
||||
"No representation is formed of a scope, so a scope cannot "
|
||||
"be a representation observer or target."
|
||||
),
|
||||
)
|
||||
|
||||
# Parse the session allowlist from filters (422 on unsupported keys/shapes,
|
||||
# and on a session_id the allowlist doesn't cover).
|
||||
session_allowlist = extract_session_allowlist(
|
||||
options.filters, must_include=options.session_id
|
||||
)
|
||||
|
||||
# Scoped reads: a single scope swaps the observer to the scope
|
||||
# peer; a list of scopes becomes a session allowlist over their union.
|
||||
observer = peer_id
|
||||
scope_session_union: list[str] | None = None
|
||||
if options.scope is not None:
|
||||
_validate_scope_option(
|
||||
filters=options.filters,
|
||||
session_id=options.session_id,
|
||||
jwt_params=jwt_params,
|
||||
)
|
||||
observer_override, scope_session_union = await _resolve_scope_option(
|
||||
workspace_id, options.scope, db_action="peers.representation.resolve_scope"
|
||||
)
|
||||
if observer_override is not None:
|
||||
observer = observer_override
|
||||
if scope_session_union is not None:
|
||||
session_allowlist = scope_session_union
|
||||
|
||||
try:
|
||||
embedding: list[float] | None = None
|
||||
if options.search_query:
|
||||
with (
|
||||
suppress(Exception),
|
||||
embedding_call_purpose(
|
||||
try:
|
||||
with embedding_call_purpose(
|
||||
EmbeddingCallPurpose.SEARCH_MEMORY.value,
|
||||
workspace_name=workspace_id,
|
||||
parent_category="api",
|
||||
),
|
||||
):
|
||||
embedding = await embedding_client.embed(options.search_query)
|
||||
):
|
||||
embedding = await embedding_client.embed(options.search_query)
|
||||
except Exception:
|
||||
# Swallowed on purpose (see include_semantic_query below), but not
|
||||
# silently: without this a provider outage degrades every search
|
||||
# request to derived+recent retrieval with no signal anywhere.
|
||||
logger.warning(
|
||||
"Representation search embedding failed for workspace %s,"
|
||||
+ " degrading to non-semantic retrieval",
|
||||
workspace_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# If no target specified, get global representation (omniscient Honcho perspective)
|
||||
representation = await crud.get_working_representation(
|
||||
workspace_id,
|
||||
observer=peer_id,
|
||||
observed=options.target if options.target is not None else peer_id,
|
||||
session_allowlist=[options.session_id]
|
||||
if options.session_id is not None
|
||||
else session_allowlist,
|
||||
include_semantic_query=options.search_query,
|
||||
embedding=embedding,
|
||||
semantic_search_top_k=options.search_top_k,
|
||||
semantic_search_max_distance=options.search_max_distance,
|
||||
include_most_derived=options.include_most_frequent
|
||||
if options.include_most_frequent is not None
|
||||
else False,
|
||||
max_observations=options.max_conclusions
|
||||
if options.max_conclusions is not None
|
||||
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
|
||||
parent_category="api",
|
||||
)
|
||||
observed = options.target if options.target is not None else peer_id
|
||||
# Re-check and read in one short session, opened only now — after the
|
||||
# embedding call above, so no connection is held across external work.
|
||||
# The early check ran in a session that has since closed and, being
|
||||
# name-based, also passed any reserved name that did not yet exist; a scope
|
||||
# created in between would otherwise be used here. Sharing the session with
|
||||
# the read means a scope committed after this check cannot have any
|
||||
# conclusions in the collection the read then examines.
|
||||
async with tracked_db(
|
||||
"peers.representation.read", read_only=True
|
||||
) as read_session:
|
||||
await crud.reject_scope_observed(
|
||||
read_session,
|
||||
workspace_id,
|
||||
{peer_id, observed},
|
||||
action=(
|
||||
"No representation is formed of a scope, so a scope cannot be"
|
||||
" a representation observer or target."
|
||||
),
|
||||
)
|
||||
# If no target specified, this is the global (omniscient) representation
|
||||
representation = await crud.get_working_representation(
|
||||
workspace_id,
|
||||
db=read_session,
|
||||
# a single `scope` swaps the observer to the scope peer
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
session_allowlist=[options.session_id]
|
||||
if options.session_id is not None
|
||||
else session_allowlist,
|
||||
# Only ask for the semantic branch when we actually have an
|
||||
# embedding. The precompute above is suppressed, and both
|
||||
# `RepresentationManager.get_working_representation` and
|
||||
# `crud.query_documents` fall back to embedding internally when a
|
||||
# query arrives without one — which would run an external call
|
||||
# inside this session, and the innermost fallback is unsuppressed
|
||||
# (a provider outage would surface as a 500). Degrading to
|
||||
# derived+recent retrieval keeps the session DB-only.
|
||||
include_semantic_query=options.search_query
|
||||
if embedding is not None
|
||||
else None,
|
||||
embedding=embedding,
|
||||
semantic_search_top_k=options.search_top_k,
|
||||
semantic_search_max_distance=options.search_max_distance,
|
||||
include_most_derived=options.include_most_frequent
|
||||
if options.include_most_frequent is not None
|
||||
else False,
|
||||
max_observations=options.max_conclusions
|
||||
if options.max_conclusions is not None
|
||||
else settings.DERIVER.WORKING_REPRESENTATION_MAX_OBSERVATIONS,
|
||||
parent_category="api",
|
||||
)
|
||||
return schemas.RepresentationResponse(
|
||||
representation=representation.format_as_markdown()
|
||||
)
|
||||
|
|
@ -421,6 +657,10 @@ async def set_peer_card(
|
|||
# If no target specified, set the observer's own card
|
||||
observed = target if target is not None else peer_id
|
||||
|
||||
# The scope guard lives in crud.set_peer_card, in the same transaction as the
|
||||
# JSONB write, so the Dreamer and agent-tool paths are covered too. Nothing
|
||||
# expensive happens between here and there, so a duplicate early check would
|
||||
# only cost an extra query.
|
||||
await crud.set_peer_card(
|
||||
db,
|
||||
workspace_id,
|
||||
|
|
@ -484,6 +724,25 @@ async def get_peer_context(
|
|||
This is useful for getting all the context needed about a peer without
|
||||
making multiple API calls.
|
||||
"""
|
||||
# Scope peers may not appear on the generic peer-context surface: no
|
||||
# representation is formed of a scope, and scoped reads go through the
|
||||
# `scope` option on chat/representation/session-context instead. Flag-based
|
||||
# rather than prefix-based, so a legacy peer merely occupying the reserved
|
||||
# name keeps working; strict on a reserved name that does not exist yet,
|
||||
# since nothing here creates it. Costs no query when no reserved name is
|
||||
# present, and runs before any embedding work.
|
||||
scope_candidates = [
|
||||
n for n in (peer_id, target) if n is not None and is_scope_peer_name(n)
|
||||
]
|
||||
if scope_candidates:
|
||||
async with tracked_db("peers.context.scope_check", read_only=True) as s_db:
|
||||
await crud.reject_scope_observed(
|
||||
s_db,
|
||||
workspace_id,
|
||||
scope_candidates,
|
||||
action="Use the `scope` option on the read routes instead.",
|
||||
)
|
||||
|
||||
# If no target specified, get the peer's own context (self-observation)
|
||||
observed = target if target is not None else peer_id
|
||||
context_started = perf_counter()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
"""FastAPI routes for scope resources.
|
||||
|
||||
A scope is a named grouping of sessions that provides a visibility boundary
|
||||
within a peer. Internally a scope is a peer named ``scope.<name>`` that
|
||||
observes its member sessions and never speaks; these routes are the facade
|
||||
that keeps the observer/observed mechanics hidden.
|
||||
|
||||
All scopes routes require a workspace-level (or admin) key: scopes are an
|
||||
app-level admin surface, so peer- and session-scoped keys are rejected.
|
||||
|
||||
Note: scope membership only affects messages ingested *after* the membership
|
||||
change. Conclusions already derived are neither backfilled on add nor
|
||||
reconciled on removal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
from src.dependencies import db, read_db
|
||||
from src.security import require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/workspaces/{workspace_id}/scopes",
|
||||
tags=["scopes"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=schemas.Scope,
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def get_or_create_scope(
|
||||
response: Response,
|
||||
workspace_id: str = Path(...),
|
||||
scope: schemas.ScopeCreate = Body(..., description="Scope creation parameters"),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""
|
||||
Get a Scope by ID or create a new Scope with the given ID.
|
||||
|
||||
Returns 201 when the scope is created and 200 when it already exists.
|
||||
A pre-existing peer occupying the scope's reserved internal name is never
|
||||
adopted; that conflict returns 409.
|
||||
"""
|
||||
result = await crud.get_or_create_scopes(db, workspace_id, [scope])
|
||||
await db.commit()
|
||||
await result.post_commit()
|
||||
response.status_code = 201 if result.created else 200
|
||||
return result.resource[0]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/list",
|
||||
response_model=Page[schemas.Scope],
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def get_scopes(
|
||||
workspace_id: str = Path(...),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""Get all Scopes for a Workspace. Results are paginated."""
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_scopes(workspace_name=workspace_id, reverse=reverse),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{scope_id}",
|
||||
response_model=schemas.Scope,
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def get_scope(
|
||||
workspace_id: str = Path(...),
|
||||
scope_id: str = Path(...),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""Get a single Scope by ID."""
|
||||
return await crud.get_scope_or_raise(db, workspace_id, scope_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{scope_id}/sessions",
|
||||
status_code=204,
|
||||
response_model=None,
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def add_sessions_to_scope(
|
||||
workspace_id: str = Path(...),
|
||||
scope_id: str = Path(...),
|
||||
body: schemas.ScopeSessionsAdd = Body(
|
||||
..., description="IDs of the sessions to add to the scope"
|
||||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""
|
||||
Add Sessions to a Scope.
|
||||
|
||||
All named sessions must already exist (404 otherwise). Adding a session that
|
||||
is already a member is a no-op. List the resulting membership with
|
||||
`POST /scopes/{scope_id}/sessions/list`.
|
||||
|
||||
Note: membership applies only to messages ingested after this call;
|
||||
conclusions already derived are not backfilled.
|
||||
"""
|
||||
await crud.add_sessions_to_scope(
|
||||
db,
|
||||
workspace_name=workspace_id,
|
||||
scope_name=scope_id,
|
||||
session_names=body.session_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{scope_id}/sessions/{session_id}",
|
||||
status_code=204,
|
||||
response_model=None,
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def remove_session_from_scope(
|
||||
workspace_id: str = Path(...),
|
||||
scope_id: str = Path(...),
|
||||
session_id: str = Path(...),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""
|
||||
Remove a Session from a Scope.
|
||||
|
||||
Note: conclusions already derived while the session was a member are left in
|
||||
place.
|
||||
"""
|
||||
await crud.remove_session_from_scope(
|
||||
db,
|
||||
workspace_name=workspace_id,
|
||||
scope_name=scope_id,
|
||||
session_name=session_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{scope_id}/sessions/list",
|
||||
response_model=Page[schemas.Session],
|
||||
dependencies=[Depends(require_auth(workspace_name="workspace_id"))],
|
||||
)
|
||||
async def get_scope_sessions(
|
||||
workspace_id: str = Path(...),
|
||||
scope_id: str = Path(...),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""Get the Sessions that are members of a Scope, paginated.
|
||||
|
||||
Ordered by how long each session has been a member: longest-standing member
|
||||
first, or most recently added first when `reverse` is true.
|
||||
"""
|
||||
# Distinguishes an empty scope from one that does not exist; the query itself
|
||||
# returns an empty page either way.
|
||||
await crud.get_scope_or_raise(db, workspace_id, scope_id)
|
||||
return await apaginate(
|
||||
db,
|
||||
await crud.get_scope_sessions(
|
||||
workspace_name=workspace_id, scope_name=scope_id, reverse=reverse
|
||||
),
|
||||
)
|
||||
|
|
@ -35,6 +35,15 @@ router = APIRouter(
|
|||
tags=["sessions"],
|
||||
)
|
||||
|
||||
# Guidance appended to guardrail errors when a scope peer is passed to the
|
||||
# generic session-peer surface. Scope membership is managed only through the
|
||||
# scopes facade so the observer mechanics stay internal.
|
||||
_SCOPES_ROUTE_GUIDANCE = (
|
||||
"Scope membership is managed via the scopes routes "
|
||||
"(/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions) or the `scopes` "
|
||||
"field at session creation."
|
||||
)
|
||||
|
||||
|
||||
async def _get_working_representation_task(
|
||||
db: AsyncSession,
|
||||
|
|
@ -309,6 +318,23 @@ async def get_or_create_session(
|
|||
)
|
||||
session.name = jwt_params.s
|
||||
|
||||
# The `scopes` field does what the scopes routes do — create scope peers and
|
||||
# attach memberships — so it needs their authorization: workspace-level or
|
||||
# admin only. Checked here rather than through `require_auth(...)` because
|
||||
# that closure only resolves path and query params, never the body, so a
|
||||
# declarative gate cannot see this field.
|
||||
if session.scopes and not (
|
||||
jwt_params.ad or (jwt_params.p is None and jwt_params.s is None)
|
||||
):
|
||||
raise AuthenticationException("Scope membership requires a workspace-level key")
|
||||
|
||||
# Scope peers may not be added through the generic peers mapping; use the
|
||||
# `scopes` field (which handles scope-peer creation and observer config).
|
||||
if session.peer_names:
|
||||
await crud.reject_scope_peers(
|
||||
db, workspace_id, session.peer_names.keys(), action=_SCOPES_ROUTE_GUIDANCE
|
||||
)
|
||||
|
||||
# Handle session creation with proper error handling
|
||||
try:
|
||||
result = await crud.get_or_create_session(
|
||||
|
|
@ -440,7 +466,13 @@ async def add_peers_to_session(
|
|||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Add Peers to a Session. If a Peer does not yet exist, it will be created automatically."""
|
||||
"""Add Peers to a Session. If a Peer does not yet exist, it will be created automatically.
|
||||
|
||||
Scope peers cannot be added here; scope membership is managed via the scopes routes.
|
||||
"""
|
||||
await crud.reject_scope_peers(
|
||||
db, workspace_id, peers.keys(), action=_SCOPES_ROUTE_GUIDANCE
|
||||
)
|
||||
try:
|
||||
result = await crud.get_or_create_session(
|
||||
db,
|
||||
|
|
@ -476,7 +508,12 @@ async def set_session_peers(
|
|||
Set the Peers in a Session. If a Peer does not yet exist, it will be created automatically.
|
||||
|
||||
This will fully replace the current set of Peers in the Session.
|
||||
|
||||
Scope peers cannot be set here; scope membership is managed via the scopes routes.
|
||||
"""
|
||||
await crud.reject_scope_peers(
|
||||
db, workspace_id, peers.keys(), action=_SCOPES_ROUTE_GUIDANCE
|
||||
)
|
||||
try:
|
||||
await crud.set_peers_for_session(
|
||||
db,
|
||||
|
|
@ -512,7 +549,13 @@ async def remove_peers_from_session(
|
|||
),
|
||||
db: AsyncSession = db,
|
||||
):
|
||||
"""Remove Peers by ID from a Session."""
|
||||
"""Remove Peers by ID from a Session.
|
||||
|
||||
Scope peers cannot be removed here; scope membership is managed via the scopes routes.
|
||||
"""
|
||||
await crud.reject_scope_peers(
|
||||
db, workspace_id, peers, action=_SCOPES_ROUTE_GUIDANCE
|
||||
)
|
||||
try:
|
||||
await crud.remove_peers_from_session(
|
||||
db,
|
||||
|
|
@ -632,19 +675,17 @@ async def get_session_peers(
|
|||
@router.get(
|
||||
"/{session_id}/context",
|
||||
response_model=schemas.SessionContext,
|
||||
dependencies=[
|
||||
Depends(
|
||||
require_auth(
|
||||
workspace_name="workspace_id",
|
||||
session_name="session_id",
|
||||
allow_member_read=True,
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
async def get_session_context(
|
||||
workspace_id: str = Path(...),
|
||||
session_id: str = Path(...),
|
||||
jwt_params: JWTParams = Depends(
|
||||
require_auth(
|
||||
workspace_name="workspace_id",
|
||||
session_name="session_id",
|
||||
allow_member_read=True,
|
||||
)
|
||||
),
|
||||
db: AsyncSession = read_db,
|
||||
tokens: int | None = Query(
|
||||
None,
|
||||
|
|
@ -669,6 +710,10 @@ async def get_session_context(
|
|||
None,
|
||||
description="A peer to get context for. If given, response will attempt to include representation and card from the perspective of that peer. Must be provided with `peer_target`.",
|
||||
),
|
||||
scope: str | None = Query(
|
||||
None,
|
||||
description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.",
|
||||
),
|
||||
limit_to_session: bool = Query(
|
||||
default=False,
|
||||
description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)",
|
||||
|
|
@ -712,6 +757,50 @@ async def get_session_context(
|
|||
"peer_target must be provided if peer_perspective is provided"
|
||||
)
|
||||
|
||||
# peer_target is the *observed* peer, and no representation or card is ever
|
||||
# formed of a scope. Strict variant: an observed position that creates
|
||||
# nothing, so a reserved name which does not exist yet must be refused too.
|
||||
if peer_target is not None:
|
||||
await crud.reject_scope_observed(
|
||||
db,
|
||||
workspace_id,
|
||||
[peer_target],
|
||||
action=(
|
||||
"No representation is formed of a scope, so a scope cannot be a"
|
||||
" context target."
|
||||
),
|
||||
)
|
||||
|
||||
# peer_perspective is an observer position, where a scope is mechanically
|
||||
# legitimate — but `scope` below is the supported way to ask for a scope's
|
||||
# perspective, and routing through it is what keeps the observer mechanics
|
||||
# hidden. Flag-based (not prefix-based) so a legacy peer merely occupying the
|
||||
# reserved name keeps working, same as everywhere else.
|
||||
if peer_perspective is not None:
|
||||
await crud.reject_scope_peers(
|
||||
db,
|
||||
workspace_id,
|
||||
[peer_perspective],
|
||||
action="Use the `scope` parameter instead.",
|
||||
)
|
||||
|
||||
if scope is not None:
|
||||
if peer_perspective:
|
||||
raise ValidationException(
|
||||
"`scope` and `peer_perspective` are mutually exclusive"
|
||||
)
|
||||
if not peer_target:
|
||||
raise ValidationException(
|
||||
"peer_target must be provided if scope is provided"
|
||||
)
|
||||
# A scope's perspective spans sessions beyond this one, so scoped reads
|
||||
# require a workspace- or admin-level key. 401, matching every other
|
||||
# scope surface (see _validate_scope_option in routers/peers.py).
|
||||
if jwt_params.p is not None or jwt_params.s is not None:
|
||||
raise AuthenticationException(
|
||||
"`scope` requires a workspace- or admin-level key"
|
||||
)
|
||||
|
||||
if not peer_target:
|
||||
# No representation or card needed
|
||||
summary, messages = await _get_session_context_task(
|
||||
|
|
@ -745,6 +834,24 @@ async def get_session_context(
|
|||
observer = peer_perspective or peer_target
|
||||
observed = peer_target
|
||||
|
||||
# Member-read lets a peer-scoped key reach this route, but membership grants
|
||||
# access to the *session*, not to a co-member's representation or peer card.
|
||||
# The observer is whose knowledge is being read, so a peer-scoped key may only
|
||||
# read from its own perspective — mirroring
|
||||
# `POST /peers/{peer_id}/representation`, where require_auth pins the observer
|
||||
# to the path peer and any `target` is that observer's own view. A bare
|
||||
# `peer_target` naming another peer is the omniscient view of them, which is
|
||||
# nobody's own perspective, so it is refused too. Workspace/admin and
|
||||
# session-scoped tokens are unaffected.
|
||||
if jwt_params.p is not None and jwt_params.p != observer:
|
||||
raise AuthenticationException("JWT not permissioned for this resource")
|
||||
|
||||
# A scope swaps the perspective source: the scope peer becomes the
|
||||
# observer for both the working representation and the peer card, so the
|
||||
# scoped collection and scoped card are read instead of the global ones.
|
||||
if scope is not None:
|
||||
[observer] = await crud.resolve_scope_peers(db, workspace_id, [scope])
|
||||
|
||||
# Pre-compute embedding outside the DB session (best-effort)
|
||||
embedding: list[float] | None = None
|
||||
if search_query:
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ from fastapi_pagination import Page
|
|||
from fastapi_pagination.ext.sqlalchemy import apaginate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, schemas
|
||||
from src import crud, models, schemas
|
||||
from src.config import settings
|
||||
from src.dependencies import db, read_db
|
||||
from src.crud.message import get_peer_session_names
|
||||
from src.dependencies import db, read_db, tracked_db
|
||||
from src.deriver.enqueue import enqueue_deletion, enqueue_dream
|
||||
from src.exceptions import AuthenticationException
|
||||
from src.exceptions import AuthenticationException, ValidationException
|
||||
from src.security import JWTParams, require_auth
|
||||
from src.utils.search import search
|
||||
|
||||
|
|
@ -141,16 +142,38 @@ async def delete_workspace(
|
|||
)
|
||||
async def search_workspace(
|
||||
workspace_id: str = Path(...),
|
||||
body: schemas.MessageSearchOptions = Body(
|
||||
body: schemas.WorkspaceMessageSearchOptions = Body(
|
||||
..., description="Message search parameters"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Search messages in a Workspace using optional filters. Use `limit` to control the number of
|
||||
results returned.
|
||||
|
||||
Pass `scope` to restrict the search to a scope's member sessions. A scope
|
||||
with no member sessions returns no results (fail-closed).
|
||||
"""
|
||||
# take user-provided filter and add workspace_id to it
|
||||
filters = body.filters or {}
|
||||
if body.scope is not None:
|
||||
if "session_id" in filters:
|
||||
raise ValidationException(
|
||||
"`scope` and a 'session_id' filter are mutually exclusive"
|
||||
)
|
||||
async with tracked_db(
|
||||
"workspaces.search.resolve_scope", read_only=True
|
||||
) as scope_db:
|
||||
[scope_peer] = await crud.resolve_scope_peers(
|
||||
scope_db, workspace_id, [body.scope]
|
||||
)
|
||||
scope_sessions = await get_peer_session_names(
|
||||
scope_db, workspace_id, scope_peer
|
||||
)
|
||||
if not scope_sessions:
|
||||
# A scope with no member sessions matches nothing, not everything.
|
||||
no_results: list[models.Message] = []
|
||||
return no_results
|
||||
filters["session_id"] = {"in": scope_sessions}
|
||||
filters["workspace_id"] = workspace_id
|
||||
return await search(body.query, filters=filters, limit=body.limit)
|
||||
|
||||
|
|
@ -225,6 +248,10 @@ async def schedule_dream(
|
|||
observed = request.observed if request.observed is not None else request.observer
|
||||
dream_type = request.dream_type
|
||||
|
||||
# The authoritative observed-position check lives in enqueue_dream, in the same
|
||||
# transaction as the queue insert. Nothing expensive happens before it here, so
|
||||
# no early duplicate is needed.
|
||||
|
||||
await enqueue_dream(
|
||||
workspace_id,
|
||||
observer=observer,
|
||||
|
|
|
|||
|
|
@ -31,10 +31,14 @@ from src.schemas.api import (
|
|||
PeerCreate,
|
||||
PeerGet,
|
||||
PeerRepresentationGet,
|
||||
PeerSpec,
|
||||
PeerUpdate,
|
||||
QueueStatus,
|
||||
RepresentationResponse,
|
||||
ScheduleDreamRequest,
|
||||
Scope,
|
||||
ScopeCreate,
|
||||
ScopeSessionsAdd,
|
||||
Session,
|
||||
SessionBase,
|
||||
SessionContext,
|
||||
|
|
@ -51,6 +55,7 @@ from src.schemas.api import (
|
|||
WorkspaceBase,
|
||||
WorkspaceCreate,
|
||||
WorkspaceGet,
|
||||
WorkspaceMessageSearchOptions,
|
||||
WorkspaceUpdate,
|
||||
)
|
||||
from src.schemas.configuration import (
|
||||
|
|
@ -125,12 +130,16 @@ __all__ = [
|
|||
"PeerContext",
|
||||
"PeerCreate",
|
||||
"PeerGet",
|
||||
"PeerSpec",
|
||||
"PeerRepresentationGet",
|
||||
"PeerUpdate",
|
||||
"QueueStatus",
|
||||
"RESOURCE_NAME_PATTERN",
|
||||
"RepresentationResponse",
|
||||
"ScheduleDreamRequest",
|
||||
"Scope",
|
||||
"ScopeCreate",
|
||||
"ScopeSessionsAdd",
|
||||
"Session",
|
||||
"SessionBase",
|
||||
"SessionContext",
|
||||
|
|
@ -147,6 +156,7 @@ __all__ = [
|
|||
"WorkspaceBase",
|
||||
"WorkspaceCreate",
|
||||
"WorkspaceGet",
|
||||
"WorkspaceMessageSearchOptions",
|
||||
"WorkspaceUpdate",
|
||||
# internal
|
||||
"DocumentBase",
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ API contract.
|
|||
|
||||
import datetime
|
||||
import ipaddress
|
||||
from typing import Annotated, Any, Self, cast
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Self, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import tiktoken
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
AliasChoices,
|
||||
BaseModel,
|
||||
BeforeValidator,
|
||||
|
|
@ -29,6 +31,11 @@ from src.schemas.configuration import (
|
|||
SessionPeerConfig,
|
||||
WorkspaceConfiguration,
|
||||
)
|
||||
from src.utils.scopes import (
|
||||
SCOPE_PEER_PREFIX,
|
||||
is_scope_peer_name,
|
||||
scope_name_from_peer,
|
||||
)
|
||||
from src.utils.types import DocumentLevel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -86,6 +93,44 @@ def _validate_metadata(v: Any) -> Any:
|
|||
|
||||
_SanitizedMetadata = Annotated[dict[str, Any], BeforeValidator(_validate_metadata)]
|
||||
|
||||
# Scope names are stored as peer names with the reserved prefix prepended, so
|
||||
# they must leave room for the prefix within the 512-character peer name limit.
|
||||
_SCOPE_NAME_MAX_LENGTH = 512 - len(SCOPE_PEER_PREFIX)
|
||||
|
||||
|
||||
def _validate_scope_name(name: str) -> str:
|
||||
"""Validate an unprefixed scope name."""
|
||||
if not 1 <= len(name) <= _SCOPE_NAME_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"Scope name must be between 1 and {_SCOPE_NAME_MAX_LENGTH} characters"
|
||||
)
|
||||
# Checked before the charset pattern: the reserved prefix is itself outside
|
||||
# RESOURCE_NAME_PATTERN, so the pattern would otherwise reject a
|
||||
# double-prefixed name first and report the charset instead of the real
|
||||
# mistake.
|
||||
if name.startswith(SCOPE_PEER_PREFIX):
|
||||
raise ValueError(
|
||||
"Scope name must not start with the reserved prefix "
|
||||
+ f"'{SCOPE_PEER_PREFIX}' (scope names are unprefixed)"
|
||||
)
|
||||
if not re.fullmatch(RESOURCE_NAME_PATTERN, name):
|
||||
raise ValueError(f"Scope name must match pattern {RESOURCE_NAME_PATTERN}")
|
||||
return name
|
||||
|
||||
|
||||
_ScopeName = Annotated[str, AfterValidator(_validate_scope_name)]
|
||||
|
||||
# The `scope` read option (chat / representation): one scope name, or a bounded
|
||||
# list of them. The length cap sits on the list member so it bounds the *list* —
|
||||
# a single name is already bounded by `_validate_scope_name`, and a union-level
|
||||
# `max_length` would cap that name's characters instead. The upper bound matches
|
||||
# `SessionCreate.scopes`; the lower one rejects `[]`, which would otherwise
|
||||
# resolve to an empty allowlist and silently recall nothing.
|
||||
_ScopeOption = (
|
||||
_ScopeName | Annotated[list[_ScopeName], Field(min_length=1, max_length=100)]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -139,19 +184,47 @@ class PeerBase(BaseModel):
|
|||
pass
|
||||
|
||||
|
||||
class PeerCreate(PeerBase):
|
||||
class PeerSpec(PeerBase):
|
||||
"""Peer identity plus optional updates, for callers that already have a name.
|
||||
|
||||
``PeerCreate`` narrows ``name`` with ``pattern=RESOURCE_NAME_PATTERN`` because it
|
||||
validates a *new, user-supplied* peer id at the API boundary. crud paths reach
|
||||
``get_or_create_peers`` with names that already exist — a path param, a message
|
||||
author, an existing row — including pre-``d429de0e5338`` legacy names containing
|
||||
'.' and every ``scope.``-prefixed peer name. Re-validating those turns a lookup
|
||||
into a raw pydantic ValidationError, i.e. an HTTP 500.
|
||||
|
||||
Carries **no** constraints at all, deliberately. Length limits here were the
|
||||
same trap as the charset pattern: request-bound peer names (message authors,
|
||||
session peer-map keys) have no length bound of their own, so an empty or
|
||||
over-long name reached ``PeerSpec(...)`` and raised internally — again a 500.
|
||||
Every rule for a *new* name lives in ``crud.peer._validate_new_peer_names``,
|
||||
which runs on the insert path only.
|
||||
"""
|
||||
|
||||
name: str
|
||||
metadata: _SanitizedMetadata | None = None
|
||||
configuration: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PeerCreate(PeerSpec):
|
||||
name: Annotated[
|
||||
str,
|
||||
Field(alias="id", min_length=1, max_length=512, pattern=RESOURCE_NAME_PATTERN),
|
||||
]
|
||||
metadata: _SanitizedMetadata | None = None
|
||||
configuration: dict[str, Any] | None = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
|
||||
|
||||
|
||||
class PeerGet(PeerBase):
|
||||
filters: dict[str, Any] | None = None
|
||||
kind: Literal["scope", "all"] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Which kinds of peers to list. Omitted (default): regular peers only "
|
||||
"(scope peers are excluded). 'scope': scope peers only. 'all': every peer."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PeerUpdate(PeerBase):
|
||||
|
|
@ -186,6 +259,19 @@ class PeerRepresentationGet(BaseModel):
|
|||
"must be included in the allowlist."
|
||||
),
|
||||
)
|
||||
scope: _ScopeOption | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional (unprefixed) scope name(s) to confine the representation. "
|
||||
"A single scope reads the scope's own representation of the target "
|
||||
"peer, formed only from the scope's member sessions. A list of "
|
||||
"scopes restricts the representation to conclusions from the union "
|
||||
"of the scopes' member sessions (explicit allowlist, fail-closed: "
|
||||
"an empty union yields an empty representation). Mutually "
|
||||
"exclusive with `filters` and `session_id`. Requires a workspace- "
|
||||
"or admin-level key."
|
||||
),
|
||||
)
|
||||
target: str | None = Field(
|
||||
None,
|
||||
description="Optional peer ID to get the representation for, from the perspective of this peer",
|
||||
|
|
@ -337,6 +423,25 @@ class SessionCreate(SessionBase):
|
|||
metadata: _SanitizedMetadata | None = None
|
||||
peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers")
|
||||
configuration: SessionConfiguration | None = None
|
||||
scopes: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description=(
|
||||
"Optional list of (unprefixed) scope names to add this session to. "
|
||||
"Each scope is created if it does not exist yet. Membership applies "
|
||||
"only to messages ingested after the session is added to the scope; "
|
||||
"conclusions already derived are not backfilled."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def validate_scopes(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is None:
|
||||
return v
|
||||
for scope_name in v:
|
||||
_validate_scope_name(scope_name)
|
||||
return v
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
|
||||
|
||||
|
|
@ -431,6 +536,61 @@ class SessionSummaries(SessionBase):
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScopeCreate(BaseModel):
|
||||
"""Schema for creating (or getting) a scope by its unprefixed name."""
|
||||
|
||||
name: Annotated[str, Field(alias="id", min_length=1)]
|
||||
metadata: _SanitizedMetadata | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
return _validate_scope_name(v)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
|
||||
|
||||
|
||||
class Scope(BaseModel):
|
||||
"""Scope response — external view of the peer backing a scope.
|
||||
|
||||
The ``id`` is the unprefixed scope name; the reserved peer-name prefix is
|
||||
an internal implementation detail and never surfaces here.
|
||||
"""
|
||||
|
||||
name: str = Field(serialization_alias="id")
|
||||
h_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, serialization_alias="metadata"
|
||||
)
|
||||
created_at: datetime.datetime
|
||||
|
||||
@field_validator("name", mode="after")
|
||||
@classmethod
|
||||
def strip_scope_prefix(cls, v: str) -> str:
|
||||
# Constructed from Peer ORM rows whose names carry the prefix; accept
|
||||
# already-unprefixed names too so manual construction works.
|
||||
return scope_name_from_peer(v) if is_scope_peer_name(v) else v
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
from_attributes=True, populate_by_name=True
|
||||
)
|
||||
|
||||
|
||||
class ScopeSessionsAdd(BaseModel):
|
||||
"""Schema for adding sessions to a scope."""
|
||||
|
||||
session_ids: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="IDs of existing sessions to add to the scope",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conclusion schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -562,6 +722,20 @@ class MessageSearchOptions(BaseModel):
|
|||
return v.replace("\x00", "")
|
||||
|
||||
|
||||
class WorkspaceMessageSearchOptions(MessageSearchOptions):
|
||||
"""Workspace-level message search options, extended with `scope`."""
|
||||
|
||||
scope: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional (unprefixed) scope name restricting search to the "
|
||||
"scope's member sessions. A scope with no member sessions returns "
|
||||
"no results. Mutually exclusive with a 'session_id' key in "
|
||||
"`filters`."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dialectic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -581,6 +755,19 @@ class DialecticOptions(BaseModel):
|
|||
"also set, it must be included in the allowlist."
|
||||
),
|
||||
)
|
||||
scope: _ScopeOption | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional (unprefixed) scope name(s) to confine recall. A single "
|
||||
"scope answers from the scope's own representation of the target "
|
||||
"peer: conclusion recall is confined to what the scope observed "
|
||||
"and message recall to the scope's member sessions. A list of "
|
||||
"scopes restricts recall to the union of the scopes' member "
|
||||
"sessions (explicit allowlist, fail-closed: an empty union "
|
||||
"recalls nothing). Mutually exclusive with `filters` and "
|
||||
"`session_id`. Requires a workspace- or admin-level key."
|
||||
),
|
||||
)
|
||||
target: str | None = Field(
|
||||
None,
|
||||
description="Optional peer to get the representation for, from the perspective of this peer",
|
||||
|
|
|
|||
|
|
@ -143,8 +143,8 @@ class TraceContentEvent(BaseEvent):
|
|||
# Tool calls in a unified {id, name, input} shape (provider-agnostic).
|
||||
tool_calls: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# Tags Honcho-authored content (system prompts, scaffold) so tenant-facing
|
||||
# views can withhold globally-shared content (the §6.3 access invariant —
|
||||
# dedup is global, the content store has no tenant column).
|
||||
# views can withhold globally-shared content: dedup is global, and the
|
||||
# content store has no tenant column.
|
||||
honcho_authored: bool = False
|
||||
|
||||
def get_resource_id(self) -> str:
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import datetime
|
||||
import re
|
||||
from collections.abc import Callable, Sequence
|
||||
from decimal import Decimal
|
||||
from logging import getLogger
|
||||
from typing import Any, TypeVar
|
||||
from typing import Any, TypeVar, get_args
|
||||
from typing import cast as typing_cast
|
||||
|
||||
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, or_
|
||||
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, or_
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.types import Numeric
|
||||
|
||||
from ..exceptions import FilterError
|
||||
from ..schemas.api import RESOURCE_NAME_PATTERN
|
||||
from .formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern, parse_datetime_iso
|
||||
from .types import DocumentLevel, VectorSyncState
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
|
@ -63,6 +68,157 @@ ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_DOCUMENTS = {
|
|||
|
||||
MAX_SESSION_ALLOWLIST_ENTRIES = 1000
|
||||
|
||||
# Columns whose values come from a closed set. Derived from the Literal types
|
||||
# themselves, so adding a level (e.g. "abduction") or a sync state updates
|
||||
# filter validation with no change here — an unlisted value is a 422 rather
|
||||
# than a filter that silently matches nothing.
|
||||
ENUM_COLUMN_VALUES: dict[str, frozenset[str]] = {
|
||||
"level": frozenset(get_args(DocumentLevel)),
|
||||
"sync_state": frozenset(get_args(VectorSyncState)),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_numeric(op_value: Any) -> float | Decimal:
|
||||
"""Validate a numeric operand without losing precision or overflowing.
|
||||
|
||||
Integers become Decimal rather than staying int. SQLAlchemy types the bind
|
||||
from the operand, so a plain int renders an ``::INTEGER`` cast and anything
|
||||
past int4 fails at execute time with "integer out of range" — even when the
|
||||
comparison itself is meaningful. Decimal renders no cast, matching what
|
||||
float() used to do, but keeps the value exact: float() rounds any int past
|
||||
2**53 and would silently compare against a different number.
|
||||
|
||||
bool is narrowed first because it subclasses int; binding it as a boolean
|
||||
against a numeric column produces SQL Postgres has no operator for.
|
||||
|
||||
Args:
|
||||
op_value: The operand to validate.
|
||||
|
||||
Returns:
|
||||
The operand as an exact numeric value that binds without a cast.
|
||||
|
||||
Raises:
|
||||
ValueError, TypeError: If the operand is not numeric. Callers convert
|
||||
these to FilterError.
|
||||
"""
|
||||
if isinstance(op_value, bool):
|
||||
return Decimal(int(op_value))
|
||||
if isinstance(op_value, float):
|
||||
return op_value
|
||||
if isinstance(op_value, int | Decimal):
|
||||
return Decimal(op_value)
|
||||
try:
|
||||
return Decimal(str(op_value))
|
||||
except ArithmeticError:
|
||||
raise ValueError(f"not a number: {op_value!r}") from None
|
||||
|
||||
|
||||
def _column_python_type(column: Any) -> type | None:
|
||||
"""Return a column's Python type, or None when it doesn't declare one.
|
||||
|
||||
pgvector's Vector raises NotImplementedError rather than returning a type,
|
||||
so this must not be called bare.
|
||||
"""
|
||||
try:
|
||||
return typing_cast("type | None", column.type.python_type)
|
||||
except (AttributeError, NotImplementedError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_operand(
|
||||
column: Any, column_name: str, value: Any, operator: str = ""
|
||||
) -> Any:
|
||||
"""Return ``value`` ready to bind against ``column``, or raise FilterError.
|
||||
|
||||
SQLAlchemy types a bind from the *operand*, not the column, and the psycopg
|
||||
dialect renders that type as an explicit cast. So an operand whose type
|
||||
doesn't match its column compiles into valid-looking SQL and then fails at
|
||||
execute time — ``operator does not exist: text = integer``. Postgres will
|
||||
not implicitly bridge these, so the mismatch has to be caught here.
|
||||
|
||||
Every operand passes through this one function, whatever the operator, so
|
||||
``eq``/``ne``/``gt``/``in`` cannot drift apart by construction. Callers
|
||||
handle None (a null check) and ``*`` (a wildcard) before calling.
|
||||
|
||||
Args:
|
||||
column: SQLAlchemy column object.
|
||||
column_name: Internal column name, for error messages.
|
||||
value: The operand to coerce.
|
||||
operator: The comparison operator, or "" for bare equality.
|
||||
|
||||
Returns:
|
||||
The operand, coerced where a lossless coercion exists.
|
||||
|
||||
Raises:
|
||||
FilterError: If the operand cannot be bound to this column.
|
||||
"""
|
||||
# JSONB keeps containment semantics: the operand is a JSON document, not a
|
||||
# scalar to compare. `jsonb >= 5` and `jsonb @> 'text'` have no operator.
|
||||
if isinstance(column.type, JSONB):
|
||||
if operator in ("", "contains") and isinstance(value, dict | list):
|
||||
return typing_cast("Any", value)
|
||||
raise FilterError(
|
||||
f"Invalid filter for column '{column_name}': a JSONB column takes an object, optionally under 'contains'"
|
||||
)
|
||||
|
||||
python_type = _column_python_type(column)
|
||||
if python_type is None:
|
||||
raise FilterError(f"Column '{column_name}' cannot be filtered on")
|
||||
|
||||
# contains/icontains build an ILIKE pattern, so the operand is stringified
|
||||
# and its own type doesn't matter — but the column must be text, or
|
||||
# Postgres has no `~~` operator for it.
|
||||
if operator in ("contains", "icontains"):
|
||||
if python_type is not str:
|
||||
raise FilterError(
|
||||
f"Operator '{operator}' requires a text column, but '{column_name}' is {python_type.__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
# bool is checked before the numeric branch: it subclasses int, so a boolean
|
||||
# column would otherwise have `true` coerced to 1, which Postgres rejects
|
||||
# against a boolean column ("operator does not exist: boolean <> integer").
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
raise FilterError(
|
||||
f"Invalid value for column '{column_name}': expected true or false, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
if issubclass(python_type, datetime.datetime | datetime.date):
|
||||
if isinstance(value, datetime.datetime | datetime.date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
validated = _validate_datetime_string(value)
|
||||
if validated is None:
|
||||
raise FilterError(f"Invalid datetime value: {value}")
|
||||
return validated
|
||||
raise FilterError(
|
||||
f"Invalid value for column '{column_name}': expected a datetime, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
if issubclass(python_type, int | float | Decimal):
|
||||
try:
|
||||
return _coerce_numeric(value)
|
||||
except (TypeError, ValueError):
|
||||
raise FilterError(
|
||||
f"Invalid numeric value: {value}. Expected a number, got {type(value).__name__}"
|
||||
) from None
|
||||
|
||||
if python_type is str:
|
||||
if not isinstance(value, str):
|
||||
raise FilterError(
|
||||
f"Invalid value for column '{column_name}': expected a string, got {type(value).__name__}"
|
||||
)
|
||||
allowed = ENUM_COLUMN_VALUES.get(column_name)
|
||||
if allowed is not None and value not in allowed:
|
||||
raise FilterError(
|
||||
f"Invalid value for column '{column_name}': {value!r}. Expected one of {sorted(allowed)}"
|
||||
)
|
||||
return value
|
||||
|
||||
raise FilterError(f"Column '{column_name}' cannot be filtered on")
|
||||
|
||||
|
||||
def extract_session_allowlist(
|
||||
filters: dict[str, Any] | None,
|
||||
|
|
@ -76,6 +232,11 @@ def extract_session_allowlist(
|
|||
FilterError (422) rather than being silently ignored — a dropped filter
|
||||
on these endpoints would widen recall scope.
|
||||
|
||||
Entries must be well-formed session ids. Wildcards are not part of this
|
||||
subset: the DSL treats ``*`` as "match everything" while the non-DSL
|
||||
consumers of the allowlist treat it as a literal name, so it is rejected
|
||||
rather than meaning two things at once.
|
||||
|
||||
Args:
|
||||
filters: The raw ``filters`` body, or None.
|
||||
must_include: A session id that must appear in the parsed allowlist —
|
||||
|
|
@ -128,6 +289,17 @@ def extract_session_allowlist(
|
|||
for entry in entries:
|
||||
if not isinstance(entry, str) or not entry:
|
||||
raise FilterError("filters.session_id entries must be non-empty strings")
|
||||
# Only names a session could actually have. The allowlist reaches
|
||||
# queries three ways — direct `IN`, the filter DSL, and a Python
|
||||
# membership test — and they don't agree on a value like "*", which the
|
||||
# DSL reads as "drop the condition" while the others treat as a literal.
|
||||
# Rejecting it here keeps the divergent value away from all three, and
|
||||
# matches this endpoint's documented contract (an id, a list of ids, or
|
||||
# {"in": [...]}) which never included wildcards.
|
||||
if not re.fullmatch(RESOURCE_NAME_PATTERN, entry):
|
||||
raise FilterError(
|
||||
f"Invalid session id in filters.session_id: {entry!r}. Session ids match {RESOURCE_NAME_PATTERN}"
|
||||
)
|
||||
if entry not in seen:
|
||||
seen.add(entry)
|
||||
allowlist.append(entry)
|
||||
|
|
@ -181,9 +353,26 @@ def apply_filter(
|
|||
if filters is None:
|
||||
return stmt
|
||||
|
||||
conditions = _build_filter_conditions(filters, model_class)
|
||||
if conditions is not None:
|
||||
stmt = stmt.where(conditions)
|
||||
# Fail closed. The filter body is arbitrary client JSON, so any shape the
|
||||
# DSL doesn't recognize must become a 422, not an unhandled 500 from
|
||||
# somewhere deep in SQLAlchemy. The exception is still logged in full so a
|
||||
# genuine bug in the builder stays visible rather than being swallowed.
|
||||
try:
|
||||
conditions = _build_filter_conditions(filters, model_class)
|
||||
if conditions is not None:
|
||||
stmt = stmt.where(conditions)
|
||||
except FilterError:
|
||||
raise
|
||||
except Exception:
|
||||
# Keys only, not the body: filter operands are client-supplied and carry
|
||||
# peer/session ids and free-text `contains` values. The traceback plus
|
||||
# the entry shape is what actually locates a builder bug.
|
||||
logger.exception(
|
||||
"Unexpected error building filter for %s; filter keys: %s",
|
||||
model_class.__name__,
|
||||
sorted(filters),
|
||||
)
|
||||
raise FilterError("Invalid filter configuration") from None
|
||||
|
||||
return stmt
|
||||
|
||||
|
|
@ -256,8 +445,15 @@ def _build_filter_conditions(
|
|||
_depth=_depth + 1,
|
||||
)
|
||||
if sub_condition is not None:
|
||||
# `IS NOT TRUE` rather than `NOT`: under SQL's three-valued
|
||||
# logic a comparison against a NULL column is NULL, and `NOT
|
||||
# NULL` is NULL, so plain negation drops rows whose column is
|
||||
# unset — even though an unset column does not match what is
|
||||
# being excluded. NOT [{"session_id": "abc"}] must include
|
||||
# documents that have no session, since those are not "abc".
|
||||
# Composes over compound sub-conditions: (a AND b) IS NOT TRUE.
|
||||
not_conditions.append(
|
||||
not_(sub_condition)
|
||||
sub_condition.is_not(True)
|
||||
) # Apply NOT to each condition individually
|
||||
if not_conditions:
|
||||
conditions.append(and_(*not_conditions)) # Then AND them together
|
||||
|
|
@ -327,6 +523,14 @@ def _build_field_condition(
|
|||
if value == "*":
|
||||
return None
|
||||
|
||||
# A null operand is a null check, not a value to compare. Every branch below
|
||||
# binds the operand against the column's type, and no type accepts None, so
|
||||
# this has to short-circuit or `{"col": null}` raises instead of matching the
|
||||
# rows it names. Keeps bare null agreeing with `{"ne": null}` (IS NOT NULL)
|
||||
# and with `NOT [{"col": null}]`.
|
||||
if value is None:
|
||||
return column.is_(None)
|
||||
|
||||
# Bare-list sugar on regular columns: {"session_id": ["a", "b"]} is
|
||||
# shorthand for {"session_id": {"in": ["a", "b"]}}. JSONB columns are
|
||||
# excluded — a bare list there keeps JSONB containment semantics.
|
||||
|
|
@ -345,13 +549,21 @@ def _build_field_condition(
|
|||
# For JSONB fields (metadata, configuration), check if it contains nested comparison operators
|
||||
if column_name in JSONB_COLUMNS:
|
||||
return _build_nested_metadata_conditions(column, value) # pyright: ignore
|
||||
elif not isinstance(column.type, JSONB):
|
||||
# A dict against a scalar column compiles fine but fails in
|
||||
# psycopg at execute time ("cannot adapt type 'dict'") as a 500.
|
||||
# Reject unknown operator dicts here as a 422 instead.
|
||||
keys = sorted(typing_cast("dict[str, Any]", value))
|
||||
raise FilterError(
|
||||
f"Invalid filter for column '{key}': unsupported operator(s) {keys}. Expected one of {sorted(COMPARISON_OPERATORS)} or a scalar value."
|
||||
)
|
||||
else:
|
||||
return column == value
|
||||
else:
|
||||
if column_name in JSONB_COLUMNS:
|
||||
return column.contains(value)
|
||||
return column.contains(_coerce_operand(column, column_name, value))
|
||||
else:
|
||||
return column == value
|
||||
return column == _coerce_operand(column, column_name, value)
|
||||
|
||||
|
||||
def _safe_numeric_cast(
|
||||
|
|
@ -552,11 +764,6 @@ def _build_comparison_conditions(
|
|||
"""
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
|
||||
# Check if this is a datetime column
|
||||
is_datetime_column = hasattr(column.type, "python_type") and issubclass(
|
||||
column.type.python_type, datetime.datetime
|
||||
)
|
||||
|
||||
for operator, op_value in comparisons.items():
|
||||
# Validate that the operator is supported
|
||||
if operator not in COMPARISON_OPERATORS:
|
||||
|
|
@ -566,29 +773,25 @@ def _build_comparison_conditions(
|
|||
if op_value == "*":
|
||||
continue
|
||||
|
||||
# A null operand is a null check, not a value comparison, on every
|
||||
# column type. Only `ne` is meaningful: {"col": None} covers IS NULL
|
||||
# via the null guard in _build_field_condition.
|
||||
if op_value is None:
|
||||
if operator != "ne":
|
||||
raise FilterError(
|
||||
f"Operator '{operator}' does not accept null. Use {{\"ne\": null}} for a not-null check, or null on its own for a null check."
|
||||
)
|
||||
conditions.append(column.is_not(None))
|
||||
continue
|
||||
|
||||
condition = None
|
||||
|
||||
# For datetime columns, cast string values to timestamp
|
||||
if is_datetime_column and isinstance(op_value, str):
|
||||
# Validate datetime string to prevent SQL injection
|
||||
validated_datetime = _validate_datetime_string(op_value)
|
||||
if validated_datetime is None:
|
||||
# Raise error if datetime validation fails
|
||||
raise FilterError(f"Invalid datetime value: {op_value}")
|
||||
|
||||
# Use the validated datetime object directly instead of string interpolation
|
||||
casted_value = validated_datetime
|
||||
else:
|
||||
# if the operator is a numeric operator, the value must cast to a number
|
||||
if operator in NUMERIC_OPERATORS:
|
||||
try:
|
||||
casted_value = float(op_value)
|
||||
except ValueError:
|
||||
raise FilterError(
|
||||
f"Invalid numeric value: {op_value}. Expected a number, got {type(op_value).__name__}"
|
||||
) from None
|
||||
else:
|
||||
casted_value = op_value
|
||||
# `in` coerces element-wise below; every other operator has one operand.
|
||||
casted_value = (
|
||||
op_value
|
||||
if operator == "in"
|
||||
else _coerce_operand(column, column_name, op_value, operator)
|
||||
)
|
||||
|
||||
if operator == "gte":
|
||||
condition = column >= casted_value
|
||||
|
|
@ -599,38 +802,39 @@ def _build_comparison_conditions(
|
|||
elif operator == "lt":
|
||||
condition = column < casted_value
|
||||
elif operator == "ne":
|
||||
condition = column != casted_value
|
||||
# IS DISTINCT FROM, not <>: `NULL <> 'abc'` is NULL, so plain
|
||||
# inequality drops rows whose column is unset. Identical to <>
|
||||
# whenever no NULL is involved, and keeps `ne` agreeing with the
|
||||
# NOT operator instead of quietly returning a different row set.
|
||||
condition = column.is_distinct_from(casted_value)
|
||||
elif operator == "in":
|
||||
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
|
||||
# Handle wildcard in iterable - if present, matches everything, so no condition needed
|
||||
if "*" in op_value:
|
||||
continue
|
||||
else:
|
||||
if is_datetime_column:
|
||||
# Validate and cast each datetime string value
|
||||
casted_values: list[str | datetime.datetime] = []
|
||||
for val in op_value:
|
||||
if isinstance(val, str):
|
||||
validated_datetime = _validate_datetime_string(val)
|
||||
if validated_datetime is None:
|
||||
raise FilterError(
|
||||
f"Invalid datetime value in list: {val}"
|
||||
)
|
||||
casted_values.append(validated_datetime)
|
||||
else:
|
||||
casted_values.append(val)
|
||||
if casted_values:
|
||||
condition = column.in_(casted_values)
|
||||
else:
|
||||
condition = column.in_(list(op_value))
|
||||
# Element-wise: one bad element poisons the whole IN, since
|
||||
# its type decides the cast rendered for that parameter.
|
||||
# An empty list is applied, not skipped: `in: []` must match
|
||||
# nothing. Dropping the condition would widen the query to
|
||||
# every row, and session scoping relies on an empty
|
||||
# allowlist failing closed (see extract_session_allowlist).
|
||||
condition = column.in_(
|
||||
[
|
||||
_coerce_operand(column, column_name, val, operator)
|
||||
for val in op_value
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise FilterError(
|
||||
f"Invalid value for 'in' operator: {op_value}. Expected an iterable (list, tuple, set), got {type(op_value).__name__}"
|
||||
)
|
||||
elif operator == "contains":
|
||||
if column_name == "h_metadata":
|
||||
# For JSONB columns, use JSONB contains
|
||||
condition = column.contains(op_value)
|
||||
if isinstance(column.type, JSONB):
|
||||
# Keyed on the column type, not the name: internal_metadata is
|
||||
# equally JSONB and was falling through to ILIKE, which
|
||||
# Postgres rejects as `jsonb ~~* text`.
|
||||
condition = column.contains(casted_value)
|
||||
else:
|
||||
# For text columns, use ILIKE with escaped pattern
|
||||
escaped_value = escape_ilike_pattern(str(op_value))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
"""Scope namespace helpers.
|
||||
|
||||
A *scope* is a named grouping of sessions that provides a visibility boundary
|
||||
within a peer. Under the hood a scope named ``therapy`` is a peer named
|
||||
``scope.therapy`` that observes its member sessions and never speaks.
|
||||
Developers manage scopes exclusively through the ``/scopes`` routes (and the
|
||||
``scopes`` field on session creation) and never see the observer/observed
|
||||
mechanics.
|
||||
|
||||
This module is the single source of truth for the reserved name prefix and the
|
||||
``kind`` flag. Being a scope requires **both**: the reserved name prefix (the
|
||||
namespace) and ``{"kind": "scope"}`` in the peer's ``internal_metadata`` JSONB
|
||||
(the authoritative marker). Neither half is forgeable — the prefix sits outside
|
||||
``RESOURCE_NAME_PATTERN``, and ``internal_metadata`` appears in no API schema —
|
||||
so requiring both means a peer that merely occupies the namespace, or merely
|
||||
carries a look-alike ``configuration``, is not a scope.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from src.exceptions import ValidationException
|
||||
|
||||
# Reserved peer-name prefix for scope peers. User-created peers may not use it.
|
||||
#
|
||||
# The '.' is load-bearing: it is outside RESOURCE_NAME_PATTERN
|
||||
# (^[a-zA-Z0-9_-]+$), the charset every peer name created through the API must
|
||||
# match. No peer created through the validated API can therefore occupy this
|
||||
# namespace. (Peers carried over by the users->peers rename in
|
||||
# d429de0e5338 predate that pattern and were never charset-validated, so the
|
||||
# legacy-collision path in crud/scope.py stays as a backstop.)
|
||||
SCOPE_PEER_PREFIX = "scope."
|
||||
|
||||
# Value of the `kind` configuration flag carried by scope peers.
|
||||
SCOPE_KIND = "scope"
|
||||
|
||||
|
||||
def scope_peer_name(scope_name: str) -> str:
|
||||
"""Return the peer name backing the given (unprefixed) scope name."""
|
||||
return f"{SCOPE_PEER_PREFIX}{scope_name}"
|
||||
|
||||
|
||||
def is_scope_peer_name(name: str) -> bool:
|
||||
"""Return whether a peer name lives in the reserved scope namespace."""
|
||||
return name.startswith(SCOPE_PEER_PREFIX)
|
||||
|
||||
|
||||
def scope_name_from_peer(peer_name: str) -> str:
|
||||
"""Return the unprefixed scope name for a scope peer name.
|
||||
|
||||
Raises:
|
||||
ValueError: If the peer name is not in the scope namespace.
|
||||
"""
|
||||
if not is_scope_peer_name(peer_name):
|
||||
raise ValueError(f"{peer_name} is not a scope peer name")
|
||||
return peer_name[len(SCOPE_PEER_PREFIX) :]
|
||||
|
||||
|
||||
def is_scope_peer(name: str, internal_metadata: dict[str, Any] | None) -> bool:
|
||||
"""Authoritative scope test: reserved name AND the internal kind flag.
|
||||
|
||||
Takes ``(name, internal_metadata)`` rather than a ``Peer`` so it is callable
|
||||
from an ORM instance, the cached plain dict built by ``crud.peer._fetch_peer``,
|
||||
or a raw row.
|
||||
"""
|
||||
return (
|
||||
is_scope_peer_name(name)
|
||||
and bool(internal_metadata)
|
||||
and internal_metadata.get("kind") == SCOPE_KIND
|
||||
)
|
||||
|
||||
|
||||
def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None:
|
||||
"""Reject any peer name that uses the reserved scope namespace.
|
||||
|
||||
Args:
|
||||
names: Peer names to check.
|
||||
action: Human-readable guidance appended to the error, directing the
|
||||
caller to the supported path (e.g. the ``/scopes`` routes).
|
||||
|
||||
Raises:
|
||||
ValidationException: If any name starts with the reserved prefix.
|
||||
"""
|
||||
offenders = sorted({name for name in names if is_scope_peer_name(name)})
|
||||
if offenders:
|
||||
raise ValidationException(
|
||||
f"Peer name(s) {offenders} use the reserved scope prefix "
|
||||
+ f"'{SCOPE_PEER_PREFIX}'. {action}"
|
||||
)
|
||||
|
|
@ -207,9 +207,9 @@ def _create_store_by_type(store_type: str) -> VectorStore:
|
|||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"VECTOR_STORE.TYPE is set to 'lancedb', but the 'lancedb' package "
|
||||
+ "is not installed (for example on macOS Intel, where it is omitted "
|
||||
+ "from dependencies because PyPI has no wheel). "
|
||||
+ "Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. "
|
||||
+ "could not be imported. Install Honcho's 'lancedb' extra "
|
||||
+ "(for example, `uv sync --extra lancedb`; unavailable on Intel "
|
||||
+ "macOS), or use TYPE 'pgvector' or 'turbopuffer'. "
|
||||
+ f"Original import error: {exc}"
|
||||
) from exc
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import jwt
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cashews.backends.interface import ControlMixin
|
||||
from cashews.picklers import PicklerType
|
||||
from fakeredis import FakeAsyncRedis
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -385,54 +383,32 @@ async def db_session(db_engine: AsyncEngine):
|
|||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def fake_cache_session():
|
||||
"""Set up fakeredis for caching once per test session."""
|
||||
"""Set up a taskless in-memory cache once per test session.
|
||||
|
||||
Cashews' normal memory backend starts a periodic expiry task on whichever
|
||||
event loop first uses it. Tests use both pytest-asyncio loops and TestClient
|
||||
portal loops, so that task can be cancelled when its originating loop closes
|
||||
and then leak a CancelledError into the next app startup. Disabling the
|
||||
periodic sweep keeps the backend loop-agnostic; expired entries are still
|
||||
discarded lazily when read.
|
||||
"""
|
||||
# Store original settings
|
||||
original_enabled = settings.CACHE.ENABLED
|
||||
original_url = settings.CACHE.URL
|
||||
|
||||
# Create a fake redis instance that persists for the session
|
||||
fake_redis = FakeAsyncRedis(decode_responses=True)
|
||||
|
||||
# Patch redis creation to use fakeredis
|
||||
# Cashews uses redis.asyncio.from_url to create connections
|
||||
def fake_redis_from_url(*_args: Any, **_kwargs: Any):
|
||||
return fake_redis
|
||||
|
||||
# Patch the cashews backend's _disable property to avoid ContextVar issues
|
||||
# This works around cashews' ContextVar not being properly initialized in TestClient context
|
||||
|
||||
original_disable_property = ControlMixin._disable # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@property # type: ignore
|
||||
def patched_disable_property(self): # pyright: ignore
|
||||
try:
|
||||
return original_disable_property.fget(self) # pyright: ignore[reportOptionalCall]
|
||||
except LookupError:
|
||||
# Return empty set as default if ContextVar not set in current context
|
||||
return set() # pyright: ignore
|
||||
|
||||
# Start patching
|
||||
redis_patch = patch("redis.asyncio.from_url", fake_redis_from_url)
|
||||
redis_patch.start()
|
||||
ControlMixin._disable = patched_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
|
||||
|
||||
try:
|
||||
# Enable caching and set URL for tests
|
||||
# Use the same backend from pytest-asyncio and TestClient event loops.
|
||||
settings.CACHE.ENABLED = True
|
||||
settings.CACHE.URL = "redis://fake-redis:6379/0"
|
||||
|
||||
# Setup cache for tests that don't use TestClient (direct CRUD tests)
|
||||
# For TestClient tests, the app's lifespan handler will also call cache.setup()
|
||||
# The ContextVar patch above handles any context issues
|
||||
settings.CACHE.URL = "mem://?check_interval=0"
|
||||
cache.setup(
|
||||
"redis://fake-redis:6379/0", pickle_type=PicklerType.SQLALCHEMY, enable=True
|
||||
settings.CACHE.URL,
|
||||
pickle_type=PicklerType.SQLALCHEMY,
|
||||
enable=True,
|
||||
)
|
||||
|
||||
yield fake_redis
|
||||
yield cache
|
||||
finally:
|
||||
# Stop the patches
|
||||
redis_patch.stop()
|
||||
ControlMixin._disable = original_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
|
||||
await cache.close()
|
||||
|
||||
# Restore original settings
|
||||
settings.CACHE.ENABLED = original_enabled
|
||||
|
|
@ -440,21 +416,21 @@ async def fake_cache_session():
|
|||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function", autouse=True)
|
||||
async def fake_cache(fake_cache_session: FakeAsyncRedis):
|
||||
async def fake_cache(fake_cache_session: Any): # pyright: ignore[reportUnusedParameter]
|
||||
"""Clear cache between tests."""
|
||||
# Clear cache before each test
|
||||
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
|
||||
await cache.clear()
|
||||
|
||||
yield cache
|
||||
|
||||
# Clear cache after each test
|
||||
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
|
||||
await cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def client(
|
||||
db_session: AsyncSession,
|
||||
fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter]
|
||||
fake_cache_session: Any, # pyright: ignore[reportUnusedParameter]
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> AsyncGenerator[TestClient, Any]:
|
||||
"""Create a FastAPI TestClient for the scope of a single test function"""
|
||||
|
|
@ -966,6 +942,7 @@ def mock_tracked_db(request: pytest.FixtureRequest):
|
|||
"src.deriver.consumer.tracked_db",
|
||||
"src.deriver.enqueue.tracked_db",
|
||||
"src.routers.peers.tracked_db",
|
||||
"src.routers.workspaces.tracked_db",
|
||||
"src.crud.representation.tracked_db",
|
||||
"src.dreamer.orchestrator.tracked_db",
|
||||
"src.dreamer.dream_scheduler.tracked_db",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
"""Regression tests for cache invalidation across the get_or_create retry path.
|
||||
|
||||
`get_or_create_peers` / `get_or_create_scopes` mutate existing rows, then insert
|
||||
new ones inside `db.begin_nested()`. A concurrent writer that creates one of those
|
||||
rows first makes the insert raise `IntegrityError`, and the function retries.
|
||||
|
||||
The subtlety: `begin_nested()` autoflushes the pending mutations *before* opening
|
||||
the savepoint, so the rollback neither undoes them nor expires the ORM state. A
|
||||
retry that recomputed "what changed" from that state would see no change and skip
|
||||
the cache purge — while the row change still commits anyway, leaving the cache
|
||||
stale until TTL. These tests pin the purge.
|
||||
|
||||
The race is real (a second session committing a real row, producing a real
|
||||
IntegrityError from the database); only its *timing* is made deterministic, by
|
||||
hooking the one point that sits between the SELECT and the INSERT.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
AsyncSessionTransaction,
|
||||
async_sessionmaker,
|
||||
)
|
||||
|
||||
from src import crud, models, schemas
|
||||
from src.crud.peer import peer_cache_key
|
||||
from src.crud.scope import SCOPE_PEER_CONFIGURATION, SCOPE_PEER_INTERNAL_METADATA
|
||||
from src.utils.scopes import scope_peer_name
|
||||
|
||||
|
||||
class _RaceOnBeginNested:
|
||||
"""Commit a racing row on entry to `begin_nested()`, then delegate.
|
||||
|
||||
That entry point is after the function's SELECT and metadata mutation but
|
||||
before its INSERT flushes — precisely the window a real concurrent writer
|
||||
has to slip through to trigger the IntegrityError retry.
|
||||
"""
|
||||
|
||||
_db: AsyncSession
|
||||
_engine: AsyncEngine
|
||||
_rows: list[models.Peer]
|
||||
_real: AsyncSessionTransaction | None
|
||||
fired: bool
|
||||
|
||||
def __init__(self, db: AsyncSession, engine: AsyncEngine, rows: list[models.Peer]):
|
||||
self._db = db
|
||||
self._engine = engine
|
||||
self._rows = rows
|
||||
self._real = None
|
||||
self.fired = False
|
||||
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
if self._rows:
|
||||
Session = async_sessionmaker(bind=self._engine, expire_on_commit=False)
|
||||
async with Session() as other:
|
||||
other.add_all(self._rows)
|
||||
await other.commit()
|
||||
self._rows = [] # race only once; the retry must succeed
|
||||
self.fired = True
|
||||
self._real = AsyncSession.begin_nested(self._db)
|
||||
return await self._real.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc_info: object):
|
||||
assert self._real is not None
|
||||
return await self._real.__aexit__(*exc_info)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_retry_still_invalidates_mutated_peer(
|
||||
db_session: AsyncSession,
|
||||
db_engine: AsyncEngine,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""A peer mutated before a losing race still gets its cache key purged."""
|
||||
test_workspace, existing_peer = sample_data
|
||||
racer_name = str(generate_nanoid())
|
||||
|
||||
# Give the existing peer metadata we will then change, so it is a real update.
|
||||
existing_peer.h_metadata = {"v": "old"}
|
||||
await db_session.commit()
|
||||
|
||||
race = _RaceOnBeginNested(
|
||||
db_session,
|
||||
db_engine,
|
||||
[models.Peer(name=racer_name, workspace_name=test_workspace.name)],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("src.crud.peer.safe_cache_delete", new=AsyncMock()) as mock_delete,
|
||||
patch.object(db_session, "begin_nested", race),
|
||||
):
|
||||
result = await crud.get_or_create_peers(
|
||||
db_session,
|
||||
test_workspace.name,
|
||||
[
|
||||
schemas.PeerCreate(name=existing_peer.name, metadata={"v": "new"}),
|
||||
schemas.PeerCreate(name=racer_name),
|
||||
],
|
||||
)
|
||||
await db_session.commit()
|
||||
await result.post_commit()
|
||||
|
||||
assert race.fired, "the race must actually have fired"
|
||||
|
||||
purged = {call.args[0] for call in mock_delete.await_args_list}
|
||||
assert (
|
||||
peer_cache_key(test_workspace.name, existing_peer.name) in purged
|
||||
), "the mutated peer's cache key must still be purged after the retry"
|
||||
|
||||
# The mutation really did land — which is what makes a missed purge stale.
|
||||
await db_session.refresh(existing_peer)
|
||||
assert existing_peer.h_metadata == {"v": "new"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_retry_still_invalidates_mutated_scope(
|
||||
db_session: AsyncSession,
|
||||
db_engine: AsyncEngine,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Same guarantee for the scopes facade, which mirrors get_or_create_peers."""
|
||||
test_workspace, _ = sample_data
|
||||
kept_scope, racing_scope = str(generate_nanoid()), str(generate_nanoid())
|
||||
|
||||
seeded = await crud.get_or_create_scopes(
|
||||
db_session,
|
||||
test_workspace.name,
|
||||
[schemas.ScopeCreate(name=kept_scope, metadata={"v": "old"})],
|
||||
)
|
||||
await db_session.commit()
|
||||
await seeded.post_commit()
|
||||
|
||||
# The racer creates the second scope's backing peer — as a *valid* scope peer,
|
||||
# so the flow reaches the insert rather than tripping the legacy-collision 409.
|
||||
race = _RaceOnBeginNested(
|
||||
db_session,
|
||||
db_engine,
|
||||
[
|
||||
models.Peer(
|
||||
name=scope_peer_name(racing_scope),
|
||||
workspace_name=test_workspace.name,
|
||||
internal_metadata=dict(SCOPE_PEER_INTERNAL_METADATA),
|
||||
configuration=dict(SCOPE_PEER_CONFIGURATION),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("src.crud.scope.safe_cache_delete", new=AsyncMock()) as mock_delete,
|
||||
patch.object(db_session, "begin_nested", race),
|
||||
):
|
||||
result = await crud.get_or_create_scopes(
|
||||
db_session,
|
||||
test_workspace.name,
|
||||
[
|
||||
schemas.ScopeCreate(name=kept_scope, metadata={"v": "new"}),
|
||||
schemas.ScopeCreate(name=racing_scope),
|
||||
],
|
||||
)
|
||||
await db_session.commit()
|
||||
await result.post_commit()
|
||||
|
||||
assert race.fired, "the race must actually have fired"
|
||||
|
||||
purged = {call.args[0] for call in mock_delete.await_args_list}
|
||||
assert (
|
||||
peer_cache_key(test_workspace.name, scope_peer_name(kept_scope)) in purged
|
||||
), "the mutated scope peer's cache key must still be purged after the retry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_no_race_does_not_invalidate_unchanged_peer(
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[models.Workspace, models.Peer],
|
||||
):
|
||||
"""Baseline: with no race, an unchanged peer is not purged."""
|
||||
test_workspace, existing_peer = sample_data
|
||||
existing_peer.h_metadata = {"v": "same"}
|
||||
await db_session.commit()
|
||||
|
||||
with patch("src.crud.peer.safe_cache_delete", new=AsyncMock()) as mock_delete:
|
||||
result = await crud.get_or_create_peers(
|
||||
db_session,
|
||||
test_workspace.name,
|
||||
[schemas.PeerCreate(name=existing_peer.name, metadata={"v": "same"})],
|
||||
)
|
||||
await db_session.commit()
|
||||
await result.post_commit()
|
||||
|
||||
assert mock_delete.await_count == 0, "an unchanged peer must not be purged"
|
||||
|
|
@ -236,7 +236,7 @@ class TestRepresentationManagerSoftDelete:
|
|||
class TestRepresentationManagerSessionScoping:
|
||||
"""Tests that the session allowlist is applied uniformly to every query path.
|
||||
|
||||
Regression for DEV-1994: session_name used to be applied only to the
|
||||
Regression: session_name used to be applied only to the
|
||||
recent-documents query; the semantic and most-derived paths ignored it,
|
||||
so limit_to_session leaked cross-session conclusions.
|
||||
"""
|
||||
|
|
@ -368,7 +368,7 @@ class TestRepresentationManagerSessionScoping:
|
|||
assert mock_query.await_args.kwargs["filters"] == {
|
||||
"session_name": {"in": [session_a.name]},
|
||||
# Scoped recall serves only levels with a trustworthy session
|
||||
# stamp (ALLOWLIST_SAFE_LEVELS / DEV-2201).
|
||||
# stamp (ALLOWLIST_SAFE_LEVELS).
|
||||
"level": {"in": ["explicit"]},
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +445,7 @@ class TestRepresentationManagerSessionScoping:
|
|||
)
|
||||
|
||||
# Scoping also narrows to levels whose session stamp is trustworthy
|
||||
# (see ALLOWLIST_SAFE_LEVELS / DEV-2201).
|
||||
# (see ALLOWLIST_SAFE_LEVELS).
|
||||
assert manager._build_filter_conditions(session_allowlist=[]) == { # pyright: ignore[reportPrivateUsage]
|
||||
"session_name": {"in": []},
|
||||
"level": {"in": ["explicit"]},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
"""The scope guard inside the dialectic entry points.
|
||||
|
||||
The route tests mock `agentic_chat` wholesale (`mock_llm_call_functions` in
|
||||
tests/conftest.py), so the preflight *inside* it has no coverage there — which is
|
||||
how a guard that rejected every scoped chat went unnoticed. These call it
|
||||
directly with the agent stubbed, so no LLM work happens.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.dialectic.chat import agentic_chat
|
||||
from src.exceptions import ValidationException
|
||||
from src.models import Peer, Workspace
|
||||
from src.utils.scopes import scope_peer_name
|
||||
|
||||
|
||||
async def _create_scope(
|
||||
client: TestClient, db_session: AsyncSession, workspace_name: str
|
||||
) -> str:
|
||||
"""Create a scope and commit it — the preflight opens its own connection."""
|
||||
scope_name = str(generate_nanoid())
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
await db_session.commit()
|
||||
return scope_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_observer_reaches_the_agent(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""A single `scope` swaps the observer to the scope peer, so the preflight must
|
||||
let a scope through in the observer position — otherwise every scoped chat 422s."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = await _create_scope(client, db_session, workspace.name)
|
||||
|
||||
with patch("src.dialectic.chat.DialecticAgent") as agent_cls:
|
||||
agent_cls.return_value.answer = AsyncMock(return_value="answered")
|
||||
answer = await agentic_chat(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
query="what do you know?",
|
||||
observer=scope_peer_name(scope_name),
|
||||
observed=peer.name,
|
||||
)
|
||||
|
||||
assert answer == "answered"
|
||||
assert agent_cls.call_args.kwargs["observer"] == scope_peer_name(scope_name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_observed_still_rejected(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""The invariant the guard exists for: no representation is formed of a scope,
|
||||
so it can never be the subject — even if the route's name check was raced."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = await _create_scope(client, db_session, workspace.name)
|
||||
|
||||
with (
|
||||
patch("src.dialectic.chat.DialecticAgent") as agent_cls,
|
||||
pytest.raises(ValidationException, match=scope_peer_name(scope_name)),
|
||||
):
|
||||
await agentic_chat(
|
||||
workspace_name=workspace.name,
|
||||
session_name=None,
|
||||
query="what do you know?",
|
||||
observer=peer.name,
|
||||
observed=scope_peer_name(scope_name),
|
||||
)
|
||||
agent_cls.assert_not_called()
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Tests for the card_refresh dream type (DEV-2000, Scopes RFC prerequisite).
|
||||
"""Tests for the card_refresh dream type.
|
||||
|
||||
Covers:
|
||||
- queue plumbing: payload roundtrip, work-unit key isolation from omni,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ async def test_openai_embedding_client_uses_configured_model_and_dimensions(
|
|||
self.base_url: str | None = base_url
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -108,7 +108,7 @@ async def test_openai_embedding_client_rejects_dimension_mismatch(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -157,7 +157,7 @@ async def test_gemini_embedding_client_uses_output_dimensionality(
|
|||
self.http_options: Any = http_options
|
||||
self.aio: Any = SimpleNamespace(models=FakeGeminiModels())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -202,7 +202,7 @@ async def test_gemini_embedding_client_keeps_timeout_without_base_url(
|
|||
self.http_options: Any = http_options
|
||||
self.aio: Any = SimpleNamespace(models=SimpleNamespace())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -239,7 +239,7 @@ def _build_openai_client(
|
|||
self.base_url: str | None = base_url
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -385,7 +385,7 @@ async def test_gemini_simple_batch_embed_respects_configured_max_batch_size(
|
|||
def __init__(self, *, api_key: str | None, http_options: Any) -> None:
|
||||
self.aio: Any = SimpleNamespace(models=FakeGeminiModels())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -433,7 +433,7 @@ async def test_gemini_simple_batch_embed_defaults_to_100_when_unset(
|
|||
def __init__(self, *, api_key: str | None, http_options: Any) -> None:
|
||||
self.aio: Any = SimpleNamespace(models=FakeGeminiModels())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -711,7 +711,7 @@ async def test_simple_batch_embed_respects_token_budget_per_request(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
# max_input_tokens=100 per single input; max_tokens_per_request=120 total,
|
||||
# so two ~80-token inputs must end up in *separate* requests.
|
||||
|
|
@ -749,7 +749,7 @@ async def test_simple_batch_embed_rejects_oversized_input(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -780,7 +780,7 @@ async def test_simple_batch_embed_truncates_oversize_when_requested(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -822,7 +822,7 @@ async def test_simple_batch_embed_truncate_reencodes_until_under_cap(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -904,7 +904,7 @@ def test_prepare_chunks_returns_ordered_chunks(
|
|||
def __init__(self, *, api_key: str | None, base_url: str | None) -> None:
|
||||
self.embeddings: FakeOpenAIEmbeddingsAPI = fake_embeddings
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.AsyncOpenAI", FakeOpenAIClient)
|
||||
monkeypatch.setattr("openai.AsyncOpenAI", FakeOpenAIClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
@ -970,7 +970,7 @@ async def test_gemini_process_batch_wraps_contents_as_content_part(
|
|||
self.http_options: Any = http_options
|
||||
self.aio: Any = SimpleNamespace(models=FakeGeminiModels())
|
||||
|
||||
monkeypatch.setattr("src.embedding_client.genai.Client", FakeGeminiClient)
|
||||
monkeypatch.setattr("google.genai.Client", FakeGeminiClient)
|
||||
|
||||
client = _EmbeddingClient(
|
||||
EmbeddingModelConfig(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def test_get_gemini_client_sets_http_timeout(monkeypatch: pytest.MonkeyPatch) ->
|
|||
"""Default Gemini client must carry an HttpOptions timeout, not None."""
|
||||
monkeypatch.setattr(app_config.settings.LLM, "GEMINI_BASE_URL", None)
|
||||
|
||||
with patch("src.llm.registry.genai.Client") as mock_client:
|
||||
with patch("google.genai.Client") as mock_client:
|
||||
registry_module.get_gemini_client()
|
||||
|
||||
assert mock_client.call_count == 1
|
||||
|
|
@ -68,7 +68,7 @@ def test_get_gemini_client_preserves_custom_base_url(
|
|||
app_config.settings.LLM, "GEMINI_BASE_URL", "https://gemini-proxy.example.com"
|
||||
)
|
||||
|
||||
with patch("src.llm.registry.genai.Client") as mock_client:
|
||||
with patch("google.genai.Client") as mock_client:
|
||||
registry_module.get_gemini_client()
|
||||
|
||||
http_options = mock_client.call_args.kwargs["http_options"]
|
||||
|
|
@ -80,7 +80,7 @@ def test_get_gemini_client_preserves_custom_base_url(
|
|||
@pytest.mark.usefixtures("fresh_lru_caches")
|
||||
def test_get_gemini_override_client_sets_http_timeout() -> None:
|
||||
"""Override Gemini client must also carry a timeout."""
|
||||
with patch("src.llm.registry.genai.Client") as mock_client:
|
||||
with patch("google.genai.Client") as mock_client:
|
||||
registry_module.get_gemini_override_client(
|
||||
"https://gemini-proxy.example.com", "sk-override"
|
||||
)
|
||||
|
|
@ -94,7 +94,7 @@ def test_get_gemini_override_client_sets_http_timeout() -> None:
|
|||
@pytest.mark.usefixtures("fresh_lru_caches")
|
||||
def test_get_gemini_override_client_handles_missing_base_url() -> None:
|
||||
"""Override Gemini client with no base URL still carries a timeout."""
|
||||
with patch("src.llm.registry.genai.Client") as mock_client:
|
||||
with patch("google.genai.Client") as mock_client:
|
||||
registry_module.get_gemini_override_client(None, "sk-override")
|
||||
|
||||
http_options = mock_client.call_args.kwargs["http_options"]
|
||||
|
|
@ -109,7 +109,7 @@ def test_get_anthropic_client_keeps_600s_timeout(
|
|||
"""Anthropic timeout is the established behavior — lock it."""
|
||||
monkeypatch.setattr(app_config.settings.LLM, "ANTHROPIC_BASE_URL", None)
|
||||
|
||||
with patch("src.llm.registry.AsyncAnthropic") as mock_anthropic:
|
||||
with patch("anthropic.AsyncAnthropic") as mock_anthropic:
|
||||
registry_module.get_anthropic_client()
|
||||
|
||||
assert mock_anthropic.call_args.kwargs["timeout"] == _ANTHROPIC_TIMEOUT_S
|
||||
|
|
@ -118,7 +118,7 @@ def test_get_anthropic_client_keeps_600s_timeout(
|
|||
@pytest.mark.usefixtures("fresh_lru_caches")
|
||||
def test_get_anthropic_override_client_keeps_600s_timeout() -> None:
|
||||
"""Override Anthropic client also keeps the 600s timeout."""
|
||||
with patch("src.llm.registry.AsyncAnthropic") as mock_anthropic:
|
||||
with patch("anthropic.AsyncAnthropic") as mock_anthropic:
|
||||
registry_module.get_anthropic_override_client(None, "sk-override")
|
||||
|
||||
assert mock_anthropic.call_args.kwargs["timeout"] == _ANTHROPIC_TIMEOUT_S
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ class TestExecutorEndToEnd:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_path_emits_one_event(self):
|
||||
from src.llm import executor
|
||||
from src.llm import executor, registry
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
result = BackendCompletionResult(
|
||||
|
|
@ -285,7 +285,7 @@ class TestExecutorEndToEnd:
|
|||
)
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(
|
||||
executor,
|
||||
"backend_for_provider",
|
||||
|
|
@ -326,7 +326,7 @@ class TestExecutorEndToEnd:
|
|||
'error' — client disconnects / shutdowns must not pollute error rates."""
|
||||
import asyncio
|
||||
|
||||
from src.llm import executor
|
||||
from src.llm import executor, registry
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
|
||||
|
|
@ -334,7 +334,7 @@ class TestExecutorEndToEnd:
|
|||
raise asyncio.CancelledError()
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(executor, "backend_for_provider", return_value=object()),
|
||||
patch.object(executor, "execute_completion", new=_cancel),
|
||||
patch(
|
||||
|
|
@ -364,7 +364,7 @@ class TestExecutorEndToEnd:
|
|||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from src.llm import executor
|
||||
from src.llm import executor, registry
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ class TestExecutorEndToEnd:
|
|||
return _cancelling_stream()
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(executor, "backend_for_provider", return_value=object()),
|
||||
patch.object(executor, "execute_stream", new=_setup_stream),
|
||||
patch.object(
|
||||
|
|
@ -418,7 +418,7 @@ class TestExecutorEndToEnd:
|
|||
generator without awaiting `execute_stream`, hiding setup failures
|
||||
from tenacity.
|
||||
"""
|
||||
from src.llm import executor
|
||||
from src.llm import executor, registry
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
|
||||
|
|
@ -426,7 +426,7 @@ class TestExecutorEndToEnd:
|
|||
raise RuntimeError("rate limited")
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(executor, "backend_for_provider", return_value=object()),
|
||||
patch.object(executor, "execute_stream", new=_setup_explodes),
|
||||
patch(
|
||||
|
|
@ -455,7 +455,7 @@ class TestExecutorEndToEnd:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_path_still_emits_via_finally(self):
|
||||
from src.llm import executor
|
||||
from src.llm import executor, registry
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ class TestExecutorEndToEnd:
|
|||
raise RuntimeError("backend exploded")
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(
|
||||
executor,
|
||||
"backend_for_provider",
|
||||
|
|
@ -570,7 +570,7 @@ class TestStreamFinalResponseRetryAttempt:
|
|||
async def test_attempt_index_bumps_across_retries(self):
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from src.llm import executor, tool_loop
|
||||
from src.llm import executor, registry, tool_loop
|
||||
|
||||
emitted: list[BaseEvent] = []
|
||||
|
||||
|
|
@ -607,7 +607,7 @@ class TestStreamFinalResponseRetryAttempt:
|
|||
)
|
||||
|
||||
with (
|
||||
patch.object(executor, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(registry, "CLIENTS", {"anthropic": object()}),
|
||||
patch.object(executor, "backend_for_provider", return_value=object()),
|
||||
patch.object(executor, "execute_stream", new=_flaky_setup),
|
||||
patch(
|
||||
|
|
|
|||
|
|
@ -1381,3 +1381,81 @@ class TestConclusionRoutes:
|
|||
# Verify the conclusion has null session_id
|
||||
conclusion = next(c for c in data["items"] if c["id"] == created_id)
|
||||
assert conclusion["session_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negation_includes_conclusions_with_no_session(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Negation must not silently drop workspace-level conclusions.
|
||||
|
||||
A conclusion with no session is not "some other session", so excluding
|
||||
that session has to leave it in the result. Under SQL's three-valued
|
||||
logic a comparison against NULL is NULL, which would drop the row.
|
||||
|
||||
This is only visible by counting returned rows — the filter builds and
|
||||
executes cleanly either way.
|
||||
"""
|
||||
test_workspace, test_peer = sample_data
|
||||
|
||||
test_peer2 = models.Peer(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add(test_peer2)
|
||||
await db_session.flush()
|
||||
|
||||
test_session = models.Session(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
other_session = models.Session(
|
||||
name=str(generate_nanoid()), workspace_name=test_workspace.name
|
||||
)
|
||||
db_session.add_all([test_session, other_session])
|
||||
await db_session.commit()
|
||||
|
||||
await self._create_collection(
|
||||
db_session, test_workspace.name, test_peer.name, test_peer2.name
|
||||
)
|
||||
|
||||
scoped = models.Document(
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
content="Scoped to a session",
|
||||
session_name=test_session.name,
|
||||
)
|
||||
workspace_level = models.Document(
|
||||
workspace_name=test_workspace.name,
|
||||
observer=test_peer.name,
|
||||
observed=test_peer2.name,
|
||||
content="Not scoped to any session",
|
||||
session_name=None,
|
||||
)
|
||||
db_session.add_all([scoped, workspace_level])
|
||||
await db_session.commit()
|
||||
|
||||
def contents(filters: dict[str, object]) -> set[str]:
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/conclusions/list",
|
||||
json={"filters": filters},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return {item["content"] for item in response.json()["items"]}
|
||||
|
||||
both = {"Scoped to a session", "Not scoped to any session"}
|
||||
|
||||
# NOT and ne agree, and both keep the session-less conclusion.
|
||||
assert contents({"NOT": [{"session_id": other_session.name}]}) == both
|
||||
assert contents({"session_id": {"ne": other_session.name}}) == both
|
||||
|
||||
# Requiring the field to be set is how you narrow to sessioned rows.
|
||||
assert contents(
|
||||
{
|
||||
"AND": [
|
||||
{"session_id": {"ne": other_session.name}},
|
||||
{"session_id": {"ne": None}},
|
||||
]
|
||||
}
|
||||
) == {"Scoped to a session"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,750 @@
|
|||
"""Tests for the `scope` option on the read routes.
|
||||
|
||||
A single scope swaps the observer to the scope peer, so recall is confined to
|
||||
the (scope, observed) collection and the scope's member sessions by existing
|
||||
observer semantics. A list of scopes keeps the path peer as observer and
|
||||
restricts recall to the union of the scopes' member sessions (the
|
||||
session-allowlist arm).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import crud, models
|
||||
from src.config import settings
|
||||
from src.models import Peer, Workspace
|
||||
from src.security import JWTParams, create_jwt
|
||||
from src.utils.scopes import scope_peer_name
|
||||
|
||||
|
||||
def _create_scope(client: TestClient, workspace_name: str, scope_name: str):
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
return response
|
||||
|
||||
|
||||
def _create_session(
|
||||
client: TestClient,
|
||||
workspace_name: str,
|
||||
session_name: str | None = None,
|
||||
**extra: Any,
|
||||
) -> str:
|
||||
session_name = session_name or str(generate_nanoid())
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/sessions",
|
||||
json={"id": session_name, **extra},
|
||||
)
|
||||
assert response.status_code in [200, 201]
|
||||
return session_name
|
||||
|
||||
|
||||
def _add_sessions_to_scope(
|
||||
client: TestClient, workspace_name: str, scope_name: str, session_names: list[str]
|
||||
) -> None:
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions",
|
||||
json={"session_ids": session_names},
|
||||
)
|
||||
assert response.status_code == 204, response.text
|
||||
|
||||
|
||||
async def _seed_documents(
|
||||
db_session: AsyncSession,
|
||||
workspace_name: str,
|
||||
*,
|
||||
observer: str,
|
||||
observed: str,
|
||||
contents: list[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Seed a collection plus documents for an (observer, observed) pair.
|
||||
|
||||
``contents`` is a list of (content, session_name) tuples.
|
||||
"""
|
||||
collection = models.Collection(
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
)
|
||||
db_session.add(collection)
|
||||
await db_session.flush()
|
||||
db_session.add_all(
|
||||
[
|
||||
models.Document(
|
||||
workspace_name=workspace_name,
|
||||
observer=observer,
|
||||
observed=observed,
|
||||
content=content,
|
||||
session_name=session_name,
|
||||
)
|
||||
for content, session_name in contents
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _seed_legacy_collision_peer(
|
||||
db_session: AsyncSession, workspace_name: str, scope_name: str
|
||||
) -> None:
|
||||
"""Create a plain peer squatting on a scope's reserved internal name."""
|
||||
db_session.add(
|
||||
models.Peer(
|
||||
workspace_name=workspace_name,
|
||||
name=scope_peer_name(scope_name),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
class TestScopeReadValidation:
|
||||
"""4xx paths shared by chat and representation.
|
||||
|
||||
Chat validation happens before any LLM work, so these are safe to exercise.
|
||||
"""
|
||||
|
||||
def _chat(
|
||||
self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any]
|
||||
):
|
||||
return client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
|
||||
json={"query": "what do you know?", **body},
|
||||
)
|
||||
|
||||
def _representation(
|
||||
self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any]
|
||||
):
|
||||
return client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
|
||||
json=body,
|
||||
)
|
||||
|
||||
def test_unknown_scope_404(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
unknown = str(generate_nanoid())
|
||||
assert (
|
||||
self._chat(client, workspace, peer, {"scope": unknown}).status_code == 404
|
||||
)
|
||||
assert (
|
||||
self._representation(
|
||||
client, workspace, peer, {"scope": unknown}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_unknown_scope_in_list_404(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
resp = self._representation(
|
||||
client, workspace, peer, {"scope": [scope_name, str(generate_nanoid())]}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_non_scope_peer_as_scope_422(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""A peer squatting on the reserved name without the kind flag is not a scope."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
await _seed_legacy_collision_peer(db_session, workspace.name, scope_name)
|
||||
|
||||
assert (
|
||||
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
self._representation(
|
||||
client, workspace, peer, {"scope": scope_name}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
def test_scope_plus_filters_422(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
body = {"scope": scope_name, "filters": {"session_id": ["s1"]}}
|
||||
assert self._chat(client, workspace, peer, body).status_code == 422
|
||||
assert self._representation(client, workspace, peer, body).status_code == 422
|
||||
|
||||
def test_scope_plus_session_id_422(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
body = {"scope": scope_name, "session_id": "s1"}
|
||||
assert self._chat(client, workspace, peer, body).status_code == 422
|
||||
assert self._representation(client, workspace, peer, body).status_code == 422
|
||||
|
||||
def test_peer_scoped_jwt_401(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A scope's sessions may exceed the peer's own membership: workspace/admin only.
|
||||
|
||||
401, matching every other scope surface — see _validate_scope_option.
|
||||
"""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
|
||||
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
|
||||
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}"
|
||||
)
|
||||
|
||||
assert (
|
||||
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
self._representation(
|
||||
client, workspace, peer, {"scope": scope_name}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
# A session-scoped key gets the same answer, but from `require_auth`
|
||||
# rather than from `_validate_scope_option`: these routes declare
|
||||
# `peer_name` and no `session_name`, so an `s` token never reaches the
|
||||
# handler at all. Asserted here so the handler's peer-only check stays
|
||||
# sufficient — if either route ever starts declaring a session, this
|
||||
# fails and the check needs the `s` arm the session-context route has.
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name, s='any-session'))}"
|
||||
)
|
||||
assert (
|
||||
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
|
||||
== 401
|
||||
)
|
||||
assert (
|
||||
self._representation(
|
||||
client, workspace, peer, {"scope": scope_name}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
|
||||
# A workspace-level key is allowed through validation (404 here only
|
||||
# if the scope were unknown; representation of an empty scope is 200).
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name))}"
|
||||
)
|
||||
assert (
|
||||
self._representation(
|
||||
client, workspace, peer, {"scope": scope_name}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
def test_scope_union_cap_422(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_a = _create_session(client, workspace.name)
|
||||
session_b = _create_session(client, workspace.name)
|
||||
_add_sessions_to_scope(
|
||||
client, workspace.name, scope_name, [session_a, session_b]
|
||||
)
|
||||
|
||||
monkeypatch.setattr("src.routers.peers.MAX_SESSION_ALLOWLIST_ENTRIES", 1)
|
||||
resp = self._representation(client, workspace, peer, {"scope": [scope_name]})
|
||||
assert resp.status_code == 422
|
||||
assert "maximum" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scope",
|
||||
[
|
||||
pytest.param([], id="empty-list"),
|
||||
pytest.param(["s"] * 101, id="over-list-cap"),
|
||||
pytest.param([scope_peer_name("already-prefixed")], id="double-prefixed"),
|
||||
pytest.param(["ok", "not a name!"], id="bad-charset-element"),
|
||||
pytest.param(scope_peer_name("already-prefixed"), id="single-prefixed"),
|
||||
],
|
||||
)
|
||||
def test_scope_option_bounds_422(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
scope: str | list[str],
|
||||
):
|
||||
"""Schema-level bounds on `scope`, before any scope is resolved: the list is
|
||||
bounded at both ends, and every element is validated as an unprefixed scope
|
||||
name (so a double-prefixed one is a 422, not a 404 for `scope.scope.x`)."""
|
||||
workspace, peer = sample_data
|
||||
assert self._chat(client, workspace, peer, {"scope": scope}).status_code == 422
|
||||
assert (
|
||||
self._representation(client, workspace, peer, {"scope": scope}).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
class TestRepresentationWithScope:
|
||||
async def test_single_scope_reads_scope_collection(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""A single scope swaps the observer: only the (scope, peer) collection is read."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_a = _create_session(client, workspace.name)
|
||||
session_b = _create_session(client, workspace.name)
|
||||
_add_sessions_to_scope(client, workspace.name, scope_name, [session_a])
|
||||
|
||||
# Conclusions the scope observed (session A) ...
|
||||
await _seed_documents(
|
||||
db_session,
|
||||
workspace.name,
|
||||
observer=scope_peer_name(scope_name),
|
||||
observed=peer.name,
|
||||
contents=[("scoped fact about hiking", session_a)],
|
||||
)
|
||||
# ... and global self-observations from another session
|
||||
await _seed_documents(
|
||||
db_session,
|
||||
workspace.name,
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
contents=[("global fact about cooking", session_b)],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
|
||||
json={"scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
representation = resp.json()["representation"]
|
||||
assert "scoped fact about hiking" in representation
|
||||
assert "global fact about cooking" not in representation
|
||||
|
||||
async def test_scope_list_unions_member_sessions(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""A scope list keeps the global observer and applies the union allowlist."""
|
||||
workspace, peer = sample_data
|
||||
scope_a = str(generate_nanoid())
|
||||
scope_b = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_a)
|
||||
_create_scope(client, workspace.name, scope_b)
|
||||
session_a = _create_session(client, workspace.name)
|
||||
session_b = _create_session(client, workspace.name)
|
||||
session_c = _create_session(client, workspace.name)
|
||||
_add_sessions_to_scope(client, workspace.name, scope_a, [session_a])
|
||||
_add_sessions_to_scope(client, workspace.name, scope_b, [session_b])
|
||||
|
||||
# All conclusions live in the GLOBAL (peer, peer) collection: only the
|
||||
# union session-allowlist can explain the filtering below (this is the
|
||||
# dynamic session-allowlist arm, not the observer swap).
|
||||
await _seed_documents(
|
||||
db_session,
|
||||
workspace.name,
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
contents=[
|
||||
("fact from session a", session_a),
|
||||
("fact from session b", session_b),
|
||||
("fact from session c", session_c),
|
||||
("sessionless dream fact", None),
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
|
||||
json={"scope": [scope_a, scope_b]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
representation = resp.json()["representation"]
|
||||
assert "fact from session a" in representation
|
||||
assert "fact from session b" in representation
|
||||
assert "fact from session c" not in representation
|
||||
assert "sessionless dream fact" not in representation
|
||||
|
||||
def test_empty_scope_list_fails_closed(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""A scope with no member sessions yields an empty representation."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
|
||||
json={"scope": [scope_name]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "fact" not in resp.json()["representation"]
|
||||
|
||||
|
||||
class TestChatWithScope:
|
||||
"""Verify what the chat route hands the dialectic, without real LLM work.
|
||||
|
||||
``agentic_chat`` is mocked in conftest (``mock_llm_call_functions``); the
|
||||
scoped peer-card fetch happens inside it and is covered end-to-end by the
|
||||
session-context test. Here we assert the route passes the right observer /
|
||||
observed / session_names — the wiring that keys the card fetch.
|
||||
"""
|
||||
|
||||
def test_single_scope_swaps_observer(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
mock_llm_call_functions: dict[str, Any],
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
|
||||
json={"query": "what do you know?", "scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs
|
||||
# The scope peer is the observer; the path peer stays the observed
|
||||
assert kwargs["observer"] == scope_peer_name(scope_name)
|
||||
assert kwargs["observed"] == peer.name
|
||||
# Single-scope confinement rides on observer semantics, not an allowlist
|
||||
assert kwargs["session_allowlist"] is None
|
||||
|
||||
def test_scope_list_passes_union_allowlist(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
mock_llm_call_functions: dict[str, Any],
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_a = str(generate_nanoid())
|
||||
scope_b = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_a)
|
||||
_create_scope(client, workspace.name, scope_b)
|
||||
session_a = _create_session(client, workspace.name)
|
||||
session_b = _create_session(client, workspace.name)
|
||||
_add_sessions_to_scope(client, workspace.name, scope_a, [session_a])
|
||||
_add_sessions_to_scope(client, workspace.name, scope_b, [session_b])
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
|
||||
json={"query": "what do you know?", "scope": [scope_a, scope_b]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs
|
||||
# Union path: the path peer stays the observer, the allowlist is the union
|
||||
assert kwargs["observer"] == peer.name
|
||||
assert kwargs["observed"] == peer.name
|
||||
assert set(kwargs["session_allowlist"]) == {session_a, session_b}
|
||||
|
||||
|
||||
class TestWorkspaceSearchWithScope:
|
||||
def _seed_message(
|
||||
self, client: TestClient, workspace_name: str, session_name: str, peer: Peer
|
||||
) -> None:
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages",
|
||||
json={
|
||||
"messages": [{"peer_id": peer.name, "content": "needle in haystack"}]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_search_restricted_to_scope_sessions(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_a = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
session_b = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
_add_sessions_to_scope(client, workspace.name, scope_name, [session_a])
|
||||
self._seed_message(client, workspace.name, session_a, peer)
|
||||
self._seed_message(client, workspace.name, session_b, peer)
|
||||
|
||||
# Unscoped: both sessions' messages match
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/search",
|
||||
json={"query": "needle"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert {m["session_id"] for m in resp.json()} == {session_a, session_b}
|
||||
|
||||
# Scoped: only the scope's member session
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/search",
|
||||
json={"query": "needle", "scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = resp.json()
|
||||
assert results
|
||||
assert {m["session_id"] for m in results} == {session_a}
|
||||
|
||||
def test_empty_scope_returns_no_results(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_a = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
self._seed_message(client, workspace.name, session_a, peer)
|
||||
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/search",
|
||||
json={"query": "needle", "scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_unknown_scope_404(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, _ = sample_data
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/search",
|
||||
json={"query": "needle", "scope": str(generate_nanoid())},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_scope_plus_session_id_filter_422(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, _ = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
resp = client.post(
|
||||
f"/v3/workspaces/{workspace.name}/search",
|
||||
json={
|
||||
"query": "needle",
|
||||
"scope": scope_name,
|
||||
"filters": {"session_id": "s1"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestSessionContextWithScope:
|
||||
async def test_scope_swaps_perspective_source(
|
||||
self,
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""`scope` reads the scope's collection and the scoped peer card."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
_add_sessions_to_scope(client, workspace.name, scope_name, [session_name])
|
||||
|
||||
await _seed_documents(
|
||||
db_session,
|
||||
workspace.name,
|
||||
observer=scope_peer_name(scope_name),
|
||||
observed=peer.name,
|
||||
contents=[("scoped fact about hiking", session_name)],
|
||||
)
|
||||
await _seed_documents(
|
||||
db_session,
|
||||
workspace.name,
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
contents=[("global fact about cooking", session_name)],
|
||||
)
|
||||
await crud.set_peer_card(
|
||||
db_session,
|
||||
workspace.name,
|
||||
peer_card=["SCOPED CARD"],
|
||||
observer=scope_peer_name(scope_name),
|
||||
observed=peer.name,
|
||||
)
|
||||
await crud.set_peer_card(
|
||||
db_session,
|
||||
workspace.name,
|
||||
peer_card=["GLOBAL CARD"],
|
||||
observer=peer.name,
|
||||
observed=peer.name,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
# Without scope: the global (self) perspective
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={"peer_target": peer.name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "global fact about cooking" in data["peer_representation"]
|
||||
assert data["peer_card"] == ["GLOBAL CARD"]
|
||||
|
||||
# With scope: the scope's perspective
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={"peer_target": peer.name, "scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "scoped fact about hiking" in data["peer_representation"]
|
||||
assert "global fact about cooking" not in data["peer_representation"]
|
||||
assert data["peer_card"] == ["SCOPED CARD"]
|
||||
|
||||
def test_scope_requires_peer_target(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={"scope": scope_name},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_scope_and_peer_perspective_mutually_exclusive(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={
|
||||
"peer_target": peer.name,
|
||||
"peer_perspective": peer.name,
|
||||
"scope": scope_name,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_unknown_scope_404(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={"peer_target": peer.name, "scope": str(generate_nanoid())},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_narrow_keys_rejected_401(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Peer- and session-scoped keys may not widen reads through a scope."""
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
_add_sessions_to_scope(client, workspace.name, scope_name, [session_name])
|
||||
|
||||
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
|
||||
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
|
||||
url = f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context"
|
||||
params = {"peer_target": peer.name, "scope": scope_name}
|
||||
|
||||
# Peer-scoped key (member read grants access to the route, not to scope)
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}"
|
||||
)
|
||||
assert client.get(url, params=params).status_code == 401
|
||||
|
||||
# Session-scoped key
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name, s=session_name))}"
|
||||
)
|
||||
assert client.get(url, params=params).status_code == 401
|
||||
|
||||
# Workspace-scoped key is allowed
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=workspace.name))}"
|
||||
)
|
||||
assert client.get(url, params=params).status_code == 200
|
||||
|
||||
|
||||
class TestScopePeerGuardrailClosure:
|
||||
"""Scope peers are rejected on the generic perspective/context surfaces."""
|
||||
|
||||
def test_session_context_rejects_scope_peer_target(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={"peer_target": scope_peer_name(scope_name)},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_session_context_rejects_scope_peer_perspective(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
|
||||
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
|
||||
params={
|
||||
"peer_target": peer.name,
|
||||
"peer_perspective": scope_peer_name(scope_name),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_peer_context_rejects_scope_peer(
|
||||
self, client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
workspace, peer = sample_data
|
||||
scope_name = str(generate_nanoid())
|
||||
_create_scope(client, workspace.name, scope_name)
|
||||
|
||||
# As the path-level peer
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{scope_peer_name(scope_name)}/context"
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# As the target
|
||||
resp = client.get(
|
||||
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/context",
|
||||
params={"target": scope_peer_name(scope_name)},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
|
@ -0,0 +1,960 @@
|
|||
"""Route-policy enumeration for the scopes facade, per peer *position*.
|
||||
|
||||
Four review passes over the scopes work each found the same class of defect: a
|
||||
place nobody had checked, rather than logic that was subtly wrong. The first
|
||||
version of this module enumerated routes and classified each one guarded or
|
||||
exempt — and that model was itself the fifth defect. A binary per-route verdict
|
||||
cannot express the actual invariant, which is positional:
|
||||
|
||||
A scope may be an OBSERVER. A scope may never be OBSERVED.
|
||||
|
||||
`POST /conclusions` is the case that proves it: a scope as `observer_id` is how
|
||||
scoped conclusions are stored and must work, while a scope as `observed_id`
|
||||
persisted a conclusion about something that carries ``observe_me=false``. One
|
||||
route, two positions, opposite verdicts. The same split applies to
|
||||
`schedule_dream`, the peer-card routes, and session context.
|
||||
|
||||
One refinement, added with the `scope` read option: on the *read* routes an
|
||||
observer position is refused too, even though a scope there is mechanically
|
||||
legitimate. Asking for a scope's perspective is what `scope` is for, and routing
|
||||
through it is what keeps the observer mechanics hidden — so `peer_perspective`,
|
||||
`GET /peers/{peer_id}/context`, chat and representation all refuse a raw scope
|
||||
peer name and point at `scope` instead. The invariant above still governs the
|
||||
storage side, where `observer_id` / `observer` remain ALLOW: a scope observing is
|
||||
the entire mechanism. Read "OBSERVER" as "may observe", not "may be named as one
|
||||
on any route".
|
||||
|
||||
So classification here is keyed by ``(method, path, position)``, where position is
|
||||
the request parameter carrying the peer name. Every derived triple must appear in
|
||||
`POLICY` as either REFUSE or ALLOW-with-a-reason; a new one fails
|
||||
`test_every_peer_position_is_classified` until someone classifies it.
|
||||
|
||||
Each REFUSE case is then asserted behaviorally — by calling the route, because the
|
||||
guards deliberately live in crud (which is what makes `messages/upload` guarded
|
||||
for free via `crud.create_messages`) — in both directions:
|
||||
|
||||
1. a real scope is refused, and the rejection must actually name it, so an
|
||||
unrelated 422 cannot pass the assertion;
|
||||
2. an *unflagged* peer merely occupying the reserved namespace is NOT a scope and
|
||||
is unaffected. That half regressed once already when `update_peer` keyed off
|
||||
the name prefix.
|
||||
|
||||
Known limitation: this covers the HTTP surface only. Peer names also reach the
|
||||
system through the deriver, dreamer, and queue, which have no route table to
|
||||
enumerate; a gap there would not be caught here.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import Response
|
||||
from nanoid import generate as generate_nanoid
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.main import app
|
||||
from src.models import Peer, Workspace
|
||||
from src.utils.scopes import scope_peer_name
|
||||
|
||||
# Request parameters and model fields that carry a peer name, in any position.
|
||||
_PEER_PARAM_NAMES = {
|
||||
"peer_id",
|
||||
"peer_name",
|
||||
"peer_names",
|
||||
"observer",
|
||||
"observer_id",
|
||||
"observed",
|
||||
"observed_id",
|
||||
"sender_id",
|
||||
"target",
|
||||
"peer_target",
|
||||
"peer_perspective",
|
||||
}
|
||||
|
||||
# Peer names arriving as dict keys or an aliased body field are invisible to
|
||||
# parameter-name detection, so these paths are matched by shape instead. The
|
||||
# position recorded for them is the body field or key role.
|
||||
_KEY_POSITION = "body_peer_keys"
|
||||
|
||||
# The scopes router *is* the facade; scope peers are its whole subject.
|
||||
_SCOPES_PREFIX = "/v3/workspaces/{workspace_id}/scopes"
|
||||
|
||||
# A builder places `peer` into one position of one route and returns the response.
|
||||
Builder = Callable[[TestClient, str, str, str], Response]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Case:
|
||||
"""Policy for one peer position on one route."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
position: str
|
||||
refuse: bool
|
||||
reason: str = ""
|
||||
build: Builder | None = None
|
||||
# REFUSE cases only. Whether a reserved name that does NOT YET EXIST is also
|
||||
# refused — the third axis, and the one that is not derivable from `refuse`.
|
||||
# It follows from the guard the call site picked:
|
||||
#
|
||||
# validate_no_scope_peer_names name-only, no DB refuses missing
|
||||
# reject_scope_observed strict on the observed refuses missing
|
||||
# reject_scope_peers flag-based, permissive allows missing
|
||||
#
|
||||
# Permissive is correct where something downstream still stops it (the create
|
||||
# path validates new names) or where the name simply resolves to nothing (404
|
||||
# before any guard runs). Each False therefore needs `missing_reason`.
|
||||
refuse_missing: bool | None = None
|
||||
missing_reason: str = ""
|
||||
# Required when refuse_missing is False: the exact status(es) a missing
|
||||
# reserved name may receive. Deliberately not a bare `!= 422` — that passes on
|
||||
# a 5xx too, which is the same hole the squatter assertion below had.
|
||||
missing_status: tuple[int, ...] = ()
|
||||
# Set when the 422 legitimately comes from request-schema validation rather
|
||||
# than a scope guard, so the detail is pydantic's rather than ours.
|
||||
schema_level: bool = False
|
||||
# Set when the squatter direction cannot be asserted here, with why.
|
||||
skip_squatter: str = ""
|
||||
# ALLOW cases only: builder plus the status a real scope must receive, so the
|
||||
# suite proves legitimate observer positions keep working.
|
||||
allow_status: tuple[int, ...] = ()
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, str, str]:
|
||||
return (self.method, self.path, self.position)
|
||||
|
||||
|
||||
_W = "/v3/workspaces/{workspace_id}"
|
||||
|
||||
|
||||
def _b_create_peer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(f"/v3/workspaces/{ws}/peers", json={"id": p})
|
||||
|
||||
|
||||
def _b_update_peer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.put(f"/v3/workspaces/{ws}/peers/{p}", json={"metadata": {"k": "v"}})
|
||||
|
||||
|
||||
def _b_chat_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(f"/v3/workspaces/{ws}/peers/{p}/chat", json={"query": "hi"})
|
||||
|
||||
|
||||
def _b_chat_target(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/peers/{_OTHER}/chat", json={"query": "hi", "target": p}
|
||||
)
|
||||
|
||||
|
||||
def _b_repr_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(f"/v3/workspaces/{ws}/peers/{p}/representation", json={})
|
||||
|
||||
|
||||
def _b_repr_target(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/peers/{_OTHER}/representation", json={"target": p}
|
||||
)
|
||||
|
||||
|
||||
def _b_card_target(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.put(
|
||||
f"/v3/workspaces/{ws}/peers/{_OTHER}/card?target={p}",
|
||||
json={"peer_card": ["note"]},
|
||||
)
|
||||
|
||||
|
||||
def _b_conclusion_observed(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/conclusions",
|
||||
json={
|
||||
"conclusions": [
|
||||
{
|
||||
"observer_id": _OTHER,
|
||||
"observed_id": p,
|
||||
"content": "something",
|
||||
"level": "explicit",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _b_dream_observed(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/schedule_dream",
|
||||
json={"observer": _OTHER, "observed": p, "dream_type": "omni"},
|
||||
)
|
||||
|
||||
|
||||
def _b_session_create(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/sessions",
|
||||
json={"id": str(generate_nanoid()), "peers": {p: {}}},
|
||||
)
|
||||
|
||||
|
||||
def _b_session_context_target(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context?peer_target={p}")
|
||||
|
||||
|
||||
def _b_message(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/sessions/{s}/messages",
|
||||
json={"messages": [{"peer_id": p, "content": "hello"}]},
|
||||
)
|
||||
|
||||
|
||||
def _b_upload(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/sessions/{s}/messages/upload",
|
||||
data={"peer_id": p},
|
||||
files={"file": ("note.txt", b"hello there", "text/plain")},
|
||||
)
|
||||
|
||||
|
||||
def _b_add_peers(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.post(f"/v3/workspaces/{ws}/sessions/{s}/peers", json={p: {}})
|
||||
|
||||
|
||||
def _b_set_peers(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.put(f"/v3/workspaces/{ws}/sessions/{s}/peers", json={p: {}})
|
||||
|
||||
|
||||
def _b_remove_peers(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.request("DELETE", f"/v3/workspaces/{ws}/sessions/{s}/peers", json=[p])
|
||||
|
||||
|
||||
def _b_conclusion_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/conclusions",
|
||||
json={
|
||||
"conclusions": [
|
||||
{
|
||||
"observer_id": p,
|
||||
"observed_id": _OTHER,
|
||||
"content": "something",
|
||||
"level": "explicit",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _b_dream_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.post(
|
||||
f"/v3/workspaces/{ws}/schedule_dream",
|
||||
json={"observer": p, "observed": _OTHER, "dream_type": "omni"},
|
||||
)
|
||||
|
||||
|
||||
def _b_card_observer_put(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.put(
|
||||
f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}",
|
||||
json={"peer_card": ["note"]},
|
||||
)
|
||||
|
||||
|
||||
def _b_card_observer_get(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}")
|
||||
|
||||
|
||||
def _b_peer_context_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/peers/{p}/context")
|
||||
|
||||
|
||||
def _b_peer_context_target(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/peers/{_OTHER}/context?target={p}")
|
||||
|
||||
|
||||
def _b_context_perspective(c: TestClient, ws: str, s: str, p: str):
|
||||
query = f"?peer_perspective={p}&peer_target={_OTHER}"
|
||||
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context{query}")
|
||||
|
||||
|
||||
def _b_queue_status_observer(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/queue/status?observer_id={p}")
|
||||
|
||||
|
||||
def _b_queue_status_sender(c: TestClient, ws: str, _s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/queue/status?sender_id={p}")
|
||||
|
||||
|
||||
def _b_peer_config(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.put(
|
||||
f"/v3/workspaces/{ws}/sessions/{s}/peers/{p}/config",
|
||||
json={"observe_others": False, "observe_me": True},
|
||||
)
|
||||
|
||||
|
||||
def _b_peer_config_get(c: TestClient, ws: str, s: str, p: str):
|
||||
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/peers/{p}/config")
|
||||
|
||||
|
||||
# A plain peer used for the *other* side of two-position routes, so the position
|
||||
# under test is the only scope in the request. Created by the fixtures below.
|
||||
_OTHER = "policy-counterparty"
|
||||
|
||||
_OBSERVER_OK = (
|
||||
"Observer position. A scope observing others is the entire mechanism scopes "
|
||||
"are built on, so this must keep working."
|
||||
)
|
||||
_READ_ONLY_OK = (
|
||||
"Read-only. Returns nothing meaningful for a scope rather than creating or "
|
||||
"mutating knowledge about one."
|
||||
)
|
||||
|
||||
POLICY: tuple[Case, ...] = (
|
||||
# ---- observed position: a scope must never be the subject ----
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/conclusions",
|
||||
"observed_id",
|
||||
True,
|
||||
refuse_missing=False,
|
||||
missing_reason=(
|
||||
"Every observer and observed peer is resolved before the scope check, "
|
||||
"so a name that does not exist is a 404 and no conclusion is written. "
|
||||
"The guard is still the strict variant, for if that ever changes."
|
||||
),
|
||||
missing_status=(404,),
|
||||
build=_b_conclusion_observed,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/schedule_dream",
|
||||
"observed",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_dream_observed,
|
||||
),
|
||||
Case(
|
||||
"PUT",
|
||||
f"{_W}/peers/{{peer_id}}/card",
|
||||
"target",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_card_target,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers/{{peer_id}}/chat",
|
||||
"target",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_chat_target,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers/{{peer_id}}/representation",
|
||||
"target",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_repr_target,
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/sessions/{{session_id}}/context",
|
||||
"peer_target",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_session_context_target,
|
||||
),
|
||||
# ---- observer position: legitimately a scope ----
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/conclusions",
|
||||
"observer_id",
|
||||
False,
|
||||
reason=_OBSERVER_OK,
|
||||
build=_b_conclusion_observer,
|
||||
allow_status=(200, 201),
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/schedule_dream",
|
||||
"observer",
|
||||
False,
|
||||
reason=_OBSERVER_OK,
|
||||
build=_b_dream_observer,
|
||||
allow_status=(204,),
|
||||
),
|
||||
Case(
|
||||
"PUT",
|
||||
f"{_W}/peers/{{peer_id}}/card",
|
||||
"peer_id",
|
||||
False,
|
||||
reason=_OBSERVER_OK,
|
||||
build=_b_card_observer_put,
|
||||
allow_status=(200,),
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/peers/{{peer_id}}/card",
|
||||
"peer_id",
|
||||
False,
|
||||
reason=_OBSERVER_OK,
|
||||
build=_b_card_observer_get,
|
||||
allow_status=(200,),
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/sessions/{{session_id}}/context",
|
||||
"peer_perspective",
|
||||
True,
|
||||
refuse_missing=False,
|
||||
missing_reason=(
|
||||
"The perspective peer is resolved before the flag-based guard runs, so a "
|
||||
"reserved name that does not exist yet is a 404 — the same answer any "
|
||||
"absent peer gets here — and nothing on this path creates it."
|
||||
),
|
||||
missing_status=(404,),
|
||||
build=_b_context_perspective,
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/queue/status",
|
||||
"observer_id",
|
||||
False,
|
||||
reason=_OBSERVER_OK,
|
||||
build=_b_queue_status_observer,
|
||||
allow_status=(200,),
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/queue/status",
|
||||
"sender_id",
|
||||
False,
|
||||
reason=(
|
||||
"Filter only. `sender_id` reaches CRUD as `observed`, but it selects "
|
||||
"existing queue rows rather than creating knowledge about a peer."
|
||||
),
|
||||
build=_b_queue_status_sender,
|
||||
allow_status=(200,),
|
||||
),
|
||||
# ---- peer identity / membership mutation: never a scope ----
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers",
|
||||
_KEY_POSITION,
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_create_peer,
|
||||
schema_level=True,
|
||||
skip_squatter=(
|
||||
"Creating any name in the reserved namespace is refused whether flagged "
|
||||
"or not — that is what reserving it means. Covered by "
|
||||
"test_scopes.py::test_peer_create_rejects_reserved_prefix."
|
||||
),
|
||||
),
|
||||
Case(
|
||||
"PUT",
|
||||
f"{_W}/peers/{{peer_id}}",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_update_peer,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/sessions",
|
||||
"peer_names",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_session_create,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/sessions/{{session_id}}/messages",
|
||||
"peer_name",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_message,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/sessions/{{session_id}}/messages/upload",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_upload,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/sessions/{{session_id}}/peers",
|
||||
_KEY_POSITION,
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_add_peers,
|
||||
),
|
||||
Case(
|
||||
"PUT",
|
||||
f"{_W}/sessions/{{session_id}}/peers",
|
||||
_KEY_POSITION,
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_set_peers,
|
||||
),
|
||||
Case(
|
||||
"DELETE",
|
||||
f"{_W}/sessions/{{session_id}}/peers",
|
||||
_KEY_POSITION,
|
||||
True,
|
||||
refuse_missing=False,
|
||||
missing_reason=(
|
||||
"Removal creates nothing and a name that does not exist has no "
|
||||
"membership row, so the request is a no-op. Refusing here would give a "
|
||||
"reserved name a different removal result than any other absent peer."
|
||||
),
|
||||
missing_status=(200,),
|
||||
build=_b_remove_peers,
|
||||
),
|
||||
Case(
|
||||
"PUT",
|
||||
f"{_W}/sessions/{{session_id}}/peers/{{peer_id}}/config",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=False,
|
||||
missing_reason=(
|
||||
"The peer is resolved before the scope check, so a name that does not "
|
||||
"exist is a 404 and never reaches the guard. Nothing is created, so "
|
||||
"there is no window for the name to be claimed here."
|
||||
),
|
||||
missing_status=(404,),
|
||||
build=_b_peer_config,
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/sessions/{{session_id}}/peers/{{peer_id}}/config",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=False,
|
||||
missing_reason=(
|
||||
"Same resolution order as the write side of this route: an absent peer "
|
||||
"is a 404 before the scope check, and a read creates nothing."
|
||||
),
|
||||
missing_status=(404,),
|
||||
build=_b_peer_config_get,
|
||||
),
|
||||
# ---- path peer on the dialectic surface ----
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers/{{peer_id}}/chat",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_chat_observer,
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers/{{peer_id}}/representation",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_repr_observer,
|
||||
),
|
||||
# ---- reads that neither create nor mutate knowledge about a scope ----
|
||||
Case(
|
||||
"POST", f"{_W}/peers/{{peer_id}}/search", "peer_id", False, reason=_READ_ONLY_OK
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
f"{_W}/peers/{{peer_id}}/sessions",
|
||||
"peer_id",
|
||||
False,
|
||||
reason=(
|
||||
"Read-only. A scope legitimately has member sessions; this is the "
|
||||
"observer-mechanics view of POST /scopes/{scope_id}/sessions/list."
|
||||
),
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/peers/{{peer_id}}/context",
|
||||
"peer_id",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_peer_context_observer,
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/peers/{{peer_id}}/context",
|
||||
"target",
|
||||
True,
|
||||
refuse_missing=True,
|
||||
build=_b_peer_context_target,
|
||||
),
|
||||
Case(
|
||||
"GET",
|
||||
f"{_W}/peers/{{peer_id}}/card",
|
||||
"target",
|
||||
False,
|
||||
reason=(
|
||||
"Read-only. The write side (PUT with target) IS refused, so this can only "
|
||||
"return pre-existing rows, never create them."
|
||||
),
|
||||
),
|
||||
Case(
|
||||
"POST",
|
||||
"/v3/keys",
|
||||
"peer_id",
|
||||
False,
|
||||
reason=(
|
||||
"Mints a scoped JWT rather than touching a peer, so no peer row is read "
|
||||
"or written. Keys cannot be bound to a scope yet."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_BY_KEY = {case.key: case for case in POLICY}
|
||||
|
||||
# Routes whose peer names arrive as dict keys or an aliased body field, invisible
|
||||
# to parameter-name detection and therefore matched by path shape.
|
||||
_KEY_POSITION_PATHS = {
|
||||
("POST", f"{_W}/peers"),
|
||||
("POST", f"{_W}/sessions/{{session_id}}/peers"),
|
||||
("PUT", f"{_W}/sessions/{{session_id}}/peers"),
|
||||
("DELETE", f"{_W}/sessions/{{session_id}}/peers"),
|
||||
}
|
||||
|
||||
|
||||
def _nested_models(annotation: object, seen: set[object]) -> Iterator[type[BaseModel]]:
|
||||
"""Yield `annotation` and every pydantic model nested inside it."""
|
||||
if (
|
||||
not isinstance(annotation, type)
|
||||
or not issubclass(annotation, BaseModel)
|
||||
or annotation in seen
|
||||
):
|
||||
return
|
||||
model: type[BaseModel] = annotation
|
||||
seen.add(model)
|
||||
yield model
|
||||
fields: dict[str, FieldInfo] = model.model_fields
|
||||
for f in fields.values():
|
||||
stack = [f.annotation]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
yield from _nested_models(current, seen)
|
||||
stack.extend(getattr(current, "__args__", ()) or ())
|
||||
|
||||
|
||||
def _peer_positions(route: APIRoute) -> set[str]:
|
||||
"""Peer-name-carrying parameter names anywhere in a route's dependant tree.
|
||||
|
||||
Walks sub-dependencies so `Form(...)` params behind a parser dependency are
|
||||
seen — this is how `messages/upload` takes its `peer_id` — and descends into
|
||||
request-body models so `MessageCreate.peer_name` is seen too.
|
||||
"""
|
||||
found: set[str] = set()
|
||||
seen: set[object] = set()
|
||||
stack = [route.dependant]
|
||||
while stack:
|
||||
dependant = stack.pop()
|
||||
params = (
|
||||
dependant.path_params
|
||||
+ dependant.query_params
|
||||
+ dependant.header_params
|
||||
+ dependant.body_params
|
||||
)
|
||||
for param in params:
|
||||
if param.name in _PEER_PARAM_NAMES:
|
||||
found.add(param.name)
|
||||
annotations = [param.field_info.annotation]
|
||||
while annotations:
|
||||
annotation = annotations.pop()
|
||||
for model in _nested_models(annotation, seen):
|
||||
found |= set(model.model_fields) & _PEER_PARAM_NAMES
|
||||
annotations.extend(getattr(annotation, "__args__", ()) or ())
|
||||
stack.extend(dependant.dependencies)
|
||||
return found
|
||||
|
||||
|
||||
def _derived_positions() -> set[tuple[str, str, str]]:
|
||||
"""Every (method, path, position) through which a peer name can be supplied."""
|
||||
found: set[tuple[str, str, str]] = set()
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
path = route.path.rstrip("/") or route.path
|
||||
if path.startswith(_SCOPES_PREFIX):
|
||||
continue
|
||||
positions = _peer_positions(route)
|
||||
for method in route.methods or set():
|
||||
if method in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
if (method, path) in _KEY_POSITION_PATHS:
|
||||
found.add((method, path, _KEY_POSITION))
|
||||
for position in positions:
|
||||
found.add((method, path, position))
|
||||
return found
|
||||
|
||||
|
||||
def test_every_peer_position_is_classified():
|
||||
"""Each (route, peer position) pair has an explicit scope policy.
|
||||
|
||||
A new one fails here until classified. Decide whether a scope in that
|
||||
*position* is harmful — the rule is that a scope may be an observer but never
|
||||
observed — then add a Case with `refuse=True` and a builder, or `refuse=False`
|
||||
and a reason.
|
||||
"""
|
||||
derived = _derived_positions()
|
||||
classified = set(_BY_KEY)
|
||||
|
||||
# _peer_positions walks FastAPI/Pydantic internals (route.dependant, its
|
||||
# *_params lists, field_info.annotation). An upgrade that reshapes any of them
|
||||
# would make derivation silently return nothing, and every assertion below
|
||||
# would then pass vacuously. Anchor on a position that must always be found.
|
||||
assert (
|
||||
"POST",
|
||||
f"{_W}/sessions/{{session_id}}/messages",
|
||||
"peer_name",
|
||||
) in derived, (
|
||||
"derived no peer positions for a route that certainly has one — the "
|
||||
"FastAPI internals _peer_positions() traverses have probably changed shape"
|
||||
)
|
||||
|
||||
unclassified = derived - classified
|
||||
assert not unclassified, (
|
||||
"peer positions with no scope policy: "
|
||||
+ f"{sorted(unclassified)} — classify each as refuse or allow"
|
||||
)
|
||||
|
||||
stale = classified - derived
|
||||
assert not stale, f"classified positions that no longer exist: {sorted(stale)}"
|
||||
|
||||
|
||||
def test_policy_entries_are_well_formed():
|
||||
assert len(_BY_KEY) == len(POLICY), "duplicate (method, path, position) in POLICY"
|
||||
for case in POLICY:
|
||||
if case.refuse:
|
||||
assert case.build is not None, f"{case.key} refuses but has no builder"
|
||||
assert not case.reason, f"{case.key} refuses; reason is for allow cases"
|
||||
# The missing-name axis is not derivable from `refuse`, so it must be
|
||||
# stated rather than defaulted — that gap is what this field closes.
|
||||
assert case.refuse_missing is not None, (
|
||||
f"{case.key} refuses a real scope but does not say whether a "
|
||||
"reserved name that does not exist yet is also refused"
|
||||
)
|
||||
if case.refuse_missing:
|
||||
assert not case.missing_status, (
|
||||
f"{case.key} refuses a missing reserved name, so the expected "
|
||||
"status is 422 — missing_status is for the permissive cases"
|
||||
)
|
||||
else:
|
||||
assert (
|
||||
len(case.missing_reason.strip()) > 30
|
||||
), f"{case.key} tolerates a missing reserved name; say why"
|
||||
assert case.missing_status, (
|
||||
f"{case.key} tolerates a missing reserved name; name the exact "
|
||||
"status(es) it should get, so a 5xx cannot satisfy the case"
|
||||
)
|
||||
else:
|
||||
assert len(case.reason.strip()) > 30, f"{case.key} needs a real reason"
|
||||
assert bool(case.build) == bool(case.allow_status), (
|
||||
f"{case.key}: an allow case needs a builder and an expected "
|
||||
"allow_status together, or neither"
|
||||
)
|
||||
assert (
|
||||
case.refuse_missing is None
|
||||
), f"{case.key}: refuse_missing applies to REFUSE cases only"
|
||||
|
||||
|
||||
_REFUSING = tuple(case for case in POLICY if case.refuse)
|
||||
# Allow cases that additionally prove, behaviorally, that a real scope works here.
|
||||
_ALLOWING_EXERCISED = tuple(
|
||||
case for case in POLICY if not case.refuse and case.build is not None
|
||||
)
|
||||
|
||||
|
||||
def _setup(client: TestClient, workspace: str) -> tuple[str, str]:
|
||||
"""Create the counterparty peer and a session, returning (session, scope name)."""
|
||||
assert client.post(
|
||||
f"/v3/workspaces/{workspace}/peers", json={"id": _OTHER}
|
||||
).status_code in (200, 201)
|
||||
session_name = str(generate_nanoid())
|
||||
assert client.post(
|
||||
f"/v3/workspaces/{workspace}/sessions", json={"id": session_name}
|
||||
).status_code in (200, 201)
|
||||
return session_name, str(generate_nanoid())
|
||||
|
||||
|
||||
def _real_scope(
|
||||
client: TestClient, workspace: str, session_name: str, scope_name: str
|
||||
) -> str:
|
||||
"""Create a scope, attach the session to it, and return its backing peer name."""
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{workspace}/scopes", json={"id": scope_name}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{workspace}/scopes/{scope_name}/sessions",
|
||||
json={"session_ids": [session_name]},
|
||||
).status_code
|
||||
== 204
|
||||
)
|
||||
return scope_peer_name(scope_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _REFUSING, ids=lambda c: f"{c.method}:{c.position}")
|
||||
def test_refusing_position_rejects_a_real_scope(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
case: Case,
|
||||
):
|
||||
"""A real scope is refused in every position marked REFUSE."""
|
||||
test_workspace, _ = sample_data
|
||||
session_name, scope_name = _setup(client, test_workspace.name)
|
||||
backing = _real_scope(client, test_workspace.name, session_name, scope_name)
|
||||
|
||||
assert case.build is not None
|
||||
result = case.build(client, test_workspace.name, session_name, backing)
|
||||
status = result.status_code
|
||||
assert status == 422, (
|
||||
f"{case.method} {case.path} accepted a scope in position "
|
||||
f"{case.position!r} (got {status})"
|
||||
)
|
||||
|
||||
# A 422 alone proves nothing — a malformed body would also produce one.
|
||||
detail = result.text
|
||||
if case.schema_level:
|
||||
assert (
|
||||
"pattern" in detail
|
||||
), f"{case.key} expected a schema-level refusal; detail: {detail[:200]}"
|
||||
else:
|
||||
assert "scope" in detail.lower() and backing in detail, (
|
||||
f"{case.key} returned 422 but not because of the scope; "
|
||||
f"detail: {detail[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case", _ALLOWING_EXERCISED, ids=lambda c: f"{c.method}:{c.position}"
|
||||
)
|
||||
def test_allowing_position_accepts_a_real_scope(
|
||||
client: TestClient,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
case: Case,
|
||||
):
|
||||
"""A real scope works in every position marked ALLOW.
|
||||
|
||||
The other half of the contract. Refusal tests alone would be satisfied by a
|
||||
guard that rejected scopes everywhere, which would break the feature: scoped
|
||||
conclusions, scoped dreams and scoped peer cards all require a scope in the
|
||||
observer position.
|
||||
"""
|
||||
test_workspace, _ = sample_data
|
||||
session_name, scope_name = _setup(client, test_workspace.name)
|
||||
backing = _real_scope(client, test_workspace.name, session_name, scope_name)
|
||||
|
||||
assert case.build is not None
|
||||
result = case.build(client, test_workspace.name, session_name, backing)
|
||||
assert result.status_code in case.allow_status, (
|
||||
f"{case.method} {case.path} refused a scope in the legitimate position "
|
||||
f"{case.position!r}: expected {case.allow_status}, got "
|
||||
f"{result.status_code} — {result.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
tuple(c for c in _REFUSING if not c.skip_squatter),
|
||||
ids=lambda c: f"{c.method}:{c.position}",
|
||||
)
|
||||
async def test_refusing_position_allows_unflagged_squatter(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
case: Case,
|
||||
):
|
||||
"""A peer merely occupying the reserved namespace is not a scope.
|
||||
|
||||
Peer names were length-validated only before migration d429de0e5338, so
|
||||
`scope.production` is a possible real user name. Such a peer has only the name
|
||||
half of the invariant and must keep working — a guard keying off the prefix
|
||||
alone locks a tenant out of its own data.
|
||||
"""
|
||||
test_workspace, _ = sample_data
|
||||
session_name, _ = _setup(client, test_workspace.name)
|
||||
squatter = scope_peer_name(str(generate_nanoid()))
|
||||
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
|
||||
await db_session.commit()
|
||||
|
||||
# Give it a membership so config and removal have a row to act on.
|
||||
assert (
|
||||
client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
|
||||
json={squatter: {}},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
assert case.build is not None
|
||||
result = case.build(client, test_workspace.name, session_name, squatter)
|
||||
# Deliberately not `!= 422`: that also passes on a 5xx, so a guard regressing
|
||||
# into an unhandled error (the psycopg DataError path this feature defends
|
||||
# against) would keep this green.
|
||||
assert result.status_code < 400, (
|
||||
f"{case.method} {case.path} did not accept an unflagged squatter in "
|
||||
f"position {case.position!r} (got {result.status_code}) — a 422 means the "
|
||||
"guard is keying off the name prefix rather than the scope flag; anything "
|
||||
f"else means the request blew up. Body: {result.text[:200]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _REFUSING, ids=lambda c: f"{c.method}:{c.position}")
|
||||
async def test_refusing_position_and_a_missing_reserved_name(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
case: Case,
|
||||
):
|
||||
"""The third axis: a reserved name that does not exist yet.
|
||||
|
||||
Neither of the other two tests reaches it — both resolve an existing subject.
|
||||
A permissive guard here is sometimes correct (the create path refuses the name
|
||||
itself, or it simply resolves to nothing), which is why the expected verdict is
|
||||
declared per case rather than assumed.
|
||||
|
||||
What is NOT negotiable in either direction is that the request must not MINT
|
||||
the reserved name. Minting it would let any caller squat a scope name before
|
||||
the workspace owner can create it, and would leave a peer that the facade can
|
||||
never adopt.
|
||||
"""
|
||||
test_workspace, _ = sample_data
|
||||
session_name, _ = _setup(client, test_workspace.name)
|
||||
missing = scope_peer_name(str(generate_nanoid()))
|
||||
|
||||
assert case.build is not None
|
||||
result = case.build(client, test_workspace.name, session_name, missing)
|
||||
|
||||
if case.refuse_missing:
|
||||
assert result.status_code == 422, (
|
||||
f"{case.method} {case.path} accepted a not-yet-existing reserved name "
|
||||
f"in position {case.position!r} (got {result.status_code}) — it could "
|
||||
f"become a scope later. Body: {result.text[:200]}"
|
||||
)
|
||||
else:
|
||||
# Exact, not `!= 422`: a permissive position still has one correct answer,
|
||||
# and a 5xx must not read as tolerance.
|
||||
assert result.status_code in case.missing_status, (
|
||||
f"{case.method} {case.path} gave {result.status_code} for a missing "
|
||||
f"reserved name in position {case.position!r}; the policy expects "
|
||||
f"{case.missing_status} because {case.missing_reason!r} — update the "
|
||||
f"policy or the guard. Body: {result.text[:200]}"
|
||||
)
|
||||
|
||||
minted = await db_session.scalar(
|
||||
select(models.Peer)
|
||||
.where(models.Peer.workspace_name == test_workspace.name)
|
||||
.where(models.Peer.name == missing)
|
||||
)
|
||||
assert minted is None, (
|
||||
f"{case.method} {case.path} minted the reserved name {missing!r} from "
|
||||
f"position {case.position!r} — the scope namespace is now squatted"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,7 +7,9 @@ from nanoid import generate as generate_nanoid
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
from src.config import settings
|
||||
from src.models import Peer, Workspace
|
||||
from src.security import JWTParams, create_jwt
|
||||
|
||||
|
||||
def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
||||
|
|
@ -1284,6 +1286,59 @@ def test_get_session_context_with_peer_perspective(
|
|||
assert "peer_card" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_context_peer_key_denied_for_co_member_perspective(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""`allow_member_read` gets a peer-scoped key onto this route, but it may only
|
||||
read from its OWN perspective. A co-member's representation and peer card are
|
||||
not session data, so membership must not hand them over."""
|
||||
test_workspace, alice = sample_data
|
||||
bob = str(generate_nanoid())
|
||||
client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/peers",
|
||||
json={"name": bob, "metadata": {}},
|
||||
)
|
||||
session_id = str(generate_nanoid())
|
||||
client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_id, "peer_names": {alice.name: {}, bob: {}}},
|
||||
)
|
||||
# Membership is read on a separate committed-only connection by the auth
|
||||
# dependency, so it must be committed before a member-scoped read.
|
||||
await db_session.commit()
|
||||
|
||||
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
|
||||
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
|
||||
client.headers["Authorization"] = (
|
||||
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}"
|
||||
)
|
||||
url = f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context"
|
||||
|
||||
# Bob's view of alice — alice is not the observer.
|
||||
assert (
|
||||
client.get(
|
||||
url, params={"peer_target": alice.name, "peer_perspective": bob}
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
# The omniscient view of bob — nobody's own perspective.
|
||||
assert client.get(url, params={"peer_target": bob}).status_code == 401
|
||||
# Alice's own perspective on bob is hers to read, as is her own global view.
|
||||
assert (
|
||||
client.get(
|
||||
url, params={"peer_target": bob, "peer_perspective": alice.name}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get(url, params={"peer_target": alice.name}).status_code == 200
|
||||
# Session data itself is still readable by any member.
|
||||
assert client.get(url).status_code == 200
|
||||
|
||||
|
||||
def test_get_session_context_peer_perspective_without_target_fails(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from src.cache.client import _redact_cache_url # pyright: ignore[reportPrivateUsage]
|
||||
from src.cache.client import (
|
||||
_redact_cache_url, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
|
||||
class TestRedactCacheUrl:
|
||||
|
|
|
|||
|
|
@ -62,6 +62,38 @@ class TestExtractSessionAllowlist:
|
|||
with pytest.raises(FilterError):
|
||||
extract_session_allowlist({"session_id": bad})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filters",
|
||||
[
|
||||
{"session_id": "*"},
|
||||
{"session_id": ["s1", "*"]},
|
||||
{"session_id": {"in": ["*"]}},
|
||||
],
|
||||
)
|
||||
def test_wildcard_rejected(self, filters: dict[str, Any]):
|
||||
"""A wildcard means two different things depending on which consumer
|
||||
receives the allowlist: the filter DSL drops the condition entirely
|
||||
(matching every session), while the direct `IN` and Python membership
|
||||
paths treat "*" as a literal session name (matching none). It is not
|
||||
part of this endpoint's contract, so it is rejected outright.
|
||||
|
||||
The mixed list is the case that matters most — it looks narrowed.
|
||||
"""
|
||||
with pytest.raises(FilterError, match="Invalid session id"):
|
||||
extract_session_allowlist(filters)
|
||||
|
||||
@pytest.mark.parametrize("name", ["a b", "a/b", "a.b", "a%b", "s1;drop"])
|
||||
def test_malformed_session_ids_rejected(self, name: str):
|
||||
with pytest.raises(FilterError, match="Invalid session id"):
|
||||
extract_session_allowlist({"session_id": name})
|
||||
|
||||
def test_valid_id_characters_still_accepted(self):
|
||||
"""The pattern must not be stricter than the ids the API actually
|
||||
issues, which include underscores and hyphens."""
|
||||
assert extract_session_allowlist({"session_id": "Valid_name-123"}) == [
|
||||
"Valid_name-123"
|
||||
]
|
||||
|
||||
def test_cap_enforced(self):
|
||||
too_many = [f"s{i}" for i in range(MAX_SESSION_ALLOWLIST_ENTRIES + 1)]
|
||||
with pytest.raises(FilterError, match="at most"):
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ Tests are defined in JSON files. A test definition consists of a name, optional
|
|||
* `create_session`: Create a new session, optionally with peers and config.
|
||||
* `add_message`: Add a single message.
|
||||
* `add_messages`: Add multiple messages.
|
||||
* `create_scope`: Create a scope and optionally add member sessions. Add the
|
||||
sessions *before* the messages you want in scope — membership only affects
|
||||
messages ingested after a session joins.
|
||||
|
||||
3. **Waiting**:
|
||||
* `wait`: Wait for duration or "queue_empty".
|
||||
|
|
@ -46,6 +49,18 @@ Tests are defined in JSON files. A test definition consists of a name, optional
|
|||
4. **Querying & Assertions**:
|
||||
* `query`: Perform an action and assert on the result.
|
||||
* `target`: "chat", "get_context", "get_peer_card", "get_representation"
|
||||
* `scope`: confine the read to a scope (or, for chat/representation, to
|
||||
the union of several). Valid for "chat", "get_representation" and
|
||||
"get_context"; the latter takes a single scope and requires
|
||||
`observed_peer_id`.
|
||||
|
||||
### Raw HTTP vs the SDK
|
||||
|
||||
Most steps drive the Honcho Python SDK. `create_scope` and any query carrying
|
||||
`scope` go over raw HTTP instead, because the published SDK trails the API and
|
||||
exposes neither. Calling the API directly also tests the contract the SDK is
|
||||
generated from, so a wrong status code or response shape surfaces here rather
|
||||
than being masked by client-side validation.
|
||||
|
||||
### Assertions
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from tests.unified.schema import (
|
|||
AddMessageAction,
|
||||
AddMessagesAction,
|
||||
ContainsAssertion,
|
||||
CreateScopeAction,
|
||||
CreateSessionAction,
|
||||
ExactMatchAssertion,
|
||||
JsonMatchAssertion,
|
||||
|
|
@ -215,6 +216,38 @@ class UnifiedTestExecutor:
|
|||
self.client: Honcho = honcho_client
|
||||
self.anthropic: AsyncAnthropic | None = anthropic_client
|
||||
|
||||
# --- raw HTTP -----------------------------------------------------------
|
||||
# Some surfaces (scopes, the `scope` read option) exist in the API before the
|
||||
# published SDK exposes them. Calling them directly also tests the contract
|
||||
# the SDK is generated from, so a wrong status or shape surfaces here instead
|
||||
# of being masked by client-side validation.
|
||||
|
||||
@property
|
||||
def workspace_id(self) -> str:
|
||||
workspace_id = getattr(self.client, "workspace_id", None)
|
||||
if not workspace_id:
|
||||
raise ValueError("Honcho client has no workspace_id")
|
||||
return str(workspace_id)
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
"""Call a /v3 workspace-scoped path directly, raising on error status."""
|
||||
url = f"{str(self.client.base_url).rstrip('/')}/v3/workspaces/{self.workspace_id}{path}"
|
||||
# Carry the same credential the SDK resolved (from `HONCHO_API_KEY`, unless
|
||||
# passed explicitly). The harness sets no AUTH vars of its own, so auth is
|
||||
# off by default — but it inherits `AUTH_USE_AUTH` from the environment,
|
||||
# and these raw calls are the only ones here that would not be authorized.
|
||||
headers: dict[str, str] = dict(kwargs.pop("headers", None) or {})
|
||||
api_key = getattr(getattr(self.client, "_http", None), "api_key", None)
|
||||
if api_key:
|
||||
headers.setdefault("Authorization", f"Bearer {api_key}")
|
||||
async with httpx.AsyncClient(timeout=120.0) as raw:
|
||||
response = await raw.request(method, url, headers=headers, **kwargs)
|
||||
if response.is_error:
|
||||
raise AssertionError(
|
||||
f"{method} {path} failed: {response.status_code} {response.text[:400]}"
|
||||
)
|
||||
return response
|
||||
|
||||
async def execute(self, test_def: TestDefinition, test_name: str) -> bool:
|
||||
logger.info(f"Starting test: {test_name}")
|
||||
|
||||
|
|
@ -311,6 +344,15 @@ class UnifiedTestExecutor:
|
|||
)
|
||||
await session.aio.add_messages(msgs)
|
||||
|
||||
elif isinstance(step, CreateScopeAction):
|
||||
await self._request("POST", "/scopes", json={"id": step.scope_id})
|
||||
if step.session_ids:
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/scopes/{step.scope_id}/sessions",
|
||||
json={"session_ids": step.session_ids},
|
||||
)
|
||||
|
||||
elif isinstance(step, WaitAction):
|
||||
if step.duration:
|
||||
await asyncio.sleep(step.duration)
|
||||
|
|
@ -344,6 +386,9 @@ class UnifiedTestExecutor:
|
|||
raise TimeoutError("Deriver queue did not empty within timeout")
|
||||
|
||||
async def perform_query(self, step: QueryAction) -> Any:
|
||||
if step.scope is not None:
|
||||
return await self._perform_scoped_query(step)
|
||||
|
||||
if step.target == "chat":
|
||||
if not step.observer_peer_id:
|
||||
raise ValueError("observer_peer_id required for chat")
|
||||
|
|
@ -395,6 +440,60 @@ class UnifiedTestExecutor:
|
|||
|
||||
return None
|
||||
|
||||
async def _perform_scoped_query(self, step: QueryAction) -> Any:
|
||||
"""Run a `scope`-confined read over raw HTTP (no SDK parameter for it)."""
|
||||
if step.target == "chat":
|
||||
if not step.observer_peer_id:
|
||||
raise ValueError("observer_peer_id required for chat")
|
||||
if step.input is None:
|
||||
raise ValueError("input required for chat")
|
||||
body: dict[str, Any] = {"query": step.input, "scope": step.scope}
|
||||
if step.session_id:
|
||||
body["session_id"] = step.session_id
|
||||
if step.observed_peer_id:
|
||||
body["target"] = step.observed_peer_id
|
||||
if step.reasoning_level:
|
||||
body["reasoning_level"] = step.reasoning_level
|
||||
response = await self._request(
|
||||
"POST", f"/peers/{step.observer_peer_id}/chat", json=body
|
||||
)
|
||||
return response.json()["content"]
|
||||
|
||||
if step.target == "get_representation":
|
||||
if not step.observer_peer_id:
|
||||
raise ValueError("observer_peer_id required for get_representation")
|
||||
body = {"scope": step.scope}
|
||||
if step.observed_peer_id:
|
||||
body["target"] = step.observed_peer_id
|
||||
if step.input:
|
||||
body["search_query"] = step.input
|
||||
response = await self._request(
|
||||
"POST", f"/peers/{step.observer_peer_id}/representation", json=body
|
||||
)
|
||||
return response.json()["representation"]
|
||||
|
||||
if step.target == "get_context":
|
||||
if not step.session_id:
|
||||
raise ValueError("session_id required for get_context")
|
||||
if not step.observed_peer_id:
|
||||
raise ValueError("observed_peer_id required for a scoped get_context")
|
||||
# `scope` on session context takes a single scope name.
|
||||
if isinstance(step.scope, list):
|
||||
raise ValueError("get_context accepts a single scope, not a list")
|
||||
params: dict[str, Any] = {
|
||||
"scope": step.scope,
|
||||
"peer_target": step.observed_peer_id,
|
||||
"summary": str(step.summary).lower(),
|
||||
}
|
||||
if step.max_tokens is not None:
|
||||
params["tokens"] = step.max_tokens
|
||||
response = await self._request(
|
||||
"GET", f"/sessions/{step.session_id}/context", params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
raise ValueError(f"`scope` is not supported for target {step.target!r}")
|
||||
|
||||
async def check_assertion(self, result: Any, assertion: Any):
|
||||
result_str = str(result)
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,22 @@ class AddMessagesAction(TestStep):
|
|||
messages: list[MessageItem]
|
||||
|
||||
|
||||
class CreateScopeAction(TestStep):
|
||||
"""Create a scope and optionally add member sessions.
|
||||
|
||||
Driven over raw HTTP rather than the SDK: scopes are a new API surface the
|
||||
published SDK does not expose yet, and gating coverage on an SDK release
|
||||
would leave the feature untested at exactly the point it needs testing.
|
||||
"""
|
||||
|
||||
step_type: Literal["create_scope"] = "create_scope"
|
||||
scope_id: str = Field(..., description="Unprefixed scope name")
|
||||
session_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Existing sessions to add as members of the scope",
|
||||
)
|
||||
|
||||
|
||||
# --- Wait Actions ---
|
||||
|
||||
|
||||
|
|
@ -152,6 +168,11 @@ class QueryAction(TestStep):
|
|||
# for chat - optional JSON Schema the response must conform to
|
||||
response_format: dict[str, Any] | None = None
|
||||
|
||||
# Confine the read to one scope (observer swap) or to the union of several
|
||||
# scopes' member sessions. Forces the raw-HTTP path, since the SDK has no
|
||||
# `scope` parameter. Valid for chat, get_representation and get_context.
|
||||
scope: str | list[str] | None = None
|
||||
|
||||
assertions: list[
|
||||
LLMJudgeAssertion
|
||||
| ContainsAssertion
|
||||
|
|
@ -174,6 +195,7 @@ class TestDefinition(BaseModel):
|
|||
| CreateSessionAction
|
||||
| AddMessageAction
|
||||
| AddMessagesAction
|
||||
| CreateScopeAction
|
||||
| WaitAction
|
||||
| ScheduleDreamAction
|
||||
| QueryAction,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
"description": "A scope confines recall to its member sessions. Alice states one fact in a session that belongs to the 'work' scope and a different, contradictory-sounding fact in a session outside it. A scoped read must surface only the in-scope fact; the unscoped read sees both. This is the observer swap: the scope peer is the observer, so conclusion recall comes from the (scope, alice) collection and message recall from the scope's membership.",
|
||||
"steps": [
|
||||
{
|
||||
"step_type": "create_session",
|
||||
"session_id": "work_session",
|
||||
"description": "In-scope session",
|
||||
"peer_configs": {
|
||||
"alice": { "observe_me": true },
|
||||
"assistant": { "observe_others": true }
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_type": "create_session",
|
||||
"session_id": "personal_session",
|
||||
"description": "Out-of-scope session — must never leak into a scoped read",
|
||||
"peer_configs": {
|
||||
"alice": { "observe_me": true },
|
||||
"assistant": { "observe_others": true }
|
||||
}
|
||||
},
|
||||
{
|
||||
"step_type": "create_scope",
|
||||
"scope_id": "work",
|
||||
"session_ids": ["work_session"],
|
||||
"description": "Scope covers only work_session. Membership must precede the messages: it only affects messages ingested after the session joins."
|
||||
},
|
||||
{
|
||||
"step_type": "add_messages",
|
||||
"session_id": "work_session",
|
||||
"messages": [
|
||||
{
|
||||
"peer_id": "alice",
|
||||
"content": "I'm a staff platform engineer and I work primarily in Rust."
|
||||
},
|
||||
{
|
||||
"peer_id": "alice",
|
||||
"content": "My current project is migrating our billing service off Postgres triggers."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_type": "add_messages",
|
||||
"session_id": "personal_session",
|
||||
"messages": [
|
||||
{
|
||||
"peer_id": "alice",
|
||||
"content": "Outside work I'm training for a marathon in Chicago this October."
|
||||
},
|
||||
{
|
||||
"peer_id": "alice",
|
||||
"content": "I've been learning to play the upright bass on weekends."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_type": "wait",
|
||||
"target": "queue_empty",
|
||||
"flush": true
|
||||
},
|
||||
{
|
||||
"step_type": "query",
|
||||
"target": "get_representation",
|
||||
"observer_peer_id": "assistant",
|
||||
"observed_peer_id": "alice",
|
||||
"scope": "work",
|
||||
"description": "Scoped representation: only what the scope observed.",
|
||||
"assertions": [
|
||||
{
|
||||
"assertion_type": "llm_judge",
|
||||
"prompt": "Does this text describe Alice's professional life (engineering, Rust, or the billing/Postgres project) WITHOUT mentioning marathon running, Chicago, or the upright bass? Answer true only if the professional material is present and the personal material is entirely absent.",
|
||||
"pass_if": true
|
||||
},
|
||||
{
|
||||
"assertion_type": "not_contains",
|
||||
"text": "marathon"
|
||||
},
|
||||
{
|
||||
"assertion_type": "not_contains",
|
||||
"text": "bass"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_type": "query",
|
||||
"target": "chat",
|
||||
"observer_peer_id": "assistant",
|
||||
"observed_peer_id": "alice",
|
||||
"scope": "work",
|
||||
"input": "What do you know about Alice's hobbies outside of work?",
|
||||
"description": "A scoped chat cannot answer from out-of-scope sessions, so it should report not knowing rather than surfacing the marathon or the bass.",
|
||||
"assertions": [
|
||||
{
|
||||
"assertion_type": "llm_judge",
|
||||
"prompt": "Does this response indicate that it does not know about Alice's hobbies outside work, or only discuss her professional life? Answer false if it mentions marathon running, Chicago, or playing the bass.",
|
||||
"pass_if": true
|
||||
},
|
||||
{
|
||||
"assertion_type": "not_contains",
|
||||
"text": "marathon"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step_type": "query",
|
||||
"target": "chat",
|
||||
"observer_peer_id": "assistant",
|
||||
"observed_peer_id": "alice",
|
||||
"input": "What do you know about Alice's hobbies outside of work?",
|
||||
"description": "Control: the same question unscoped. Proves the scoped result above is the scope working, not the deriver simply having failed to record the personal session.",
|
||||
"assertions": [
|
||||
{
|
||||
"assertion_type": "llm_judge",
|
||||
"prompt": "Does this response mention marathon running, Chicago, or playing the upright bass? Answer true if at least one of Alice's out-of-work hobbies is described.",
|
||||
"pass_if": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,412 @@
|
|||
"""Unit tests for filter condition building."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, cast, get_args
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import psycopg as psycopg_dialect
|
||||
|
||||
from src.exceptions import FilterError
|
||||
from src.models import Document, Message, Peer, Session
|
||||
from src.utils.filter import apply_filter
|
||||
from src.utils.types import DocumentLevel
|
||||
|
||||
|
||||
def test_unknown_operator_dict_on_scalar_column_raises():
|
||||
"""An unrecognized operator dict must 422, not reach the driver as a 500.
|
||||
|
||||
Regression: {"session_id": {"operator": "null"}} compiled to
|
||||
`session_name = %(param)s` with a dict bind, which psycopg rejected with
|
||||
"cannot adapt type 'dict'" -> unhandled 500.
|
||||
"""
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Document), Document, {"session_id": {"operator": "null"}})
|
||||
|
||||
|
||||
def test_known_operator_dict_on_scalar_column_still_works():
|
||||
stmt = apply_filter(
|
||||
select(Document), Document, {"session_id": {"in": ["s1", "s2"]}}
|
||||
)
|
||||
assert "session_name IN" in str(stmt).replace("documents.", "")
|
||||
|
||||
|
||||
def test_dict_on_jsonb_column_still_works():
|
||||
stmt = apply_filter(select(Document), Document, {"metadata": {"kind": "note"}})
|
||||
# Assert on the WHERE clause specifically: internal_metadata is in the
|
||||
# SELECT projection either way, so checking the whole statement passes even
|
||||
# when no condition was applied at all.
|
||||
assert stmt.whereclause is not None
|
||||
assert "internal_metadata" in str(stmt.whereclause)
|
||||
compiled = stmt.compile(dialect=postgresql.dialect())
|
||||
assert {"kind": "note"} in [bind.value for bind in compiled.binds.values()]
|
||||
|
||||
|
||||
def test_numeric_operand_keeps_integer_precision():
|
||||
"""float() rounds anything past 2**53, silently shifting the comparison."""
|
||||
big = 2**53 + 1
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"gt": big}})
|
||||
compiled = stmt.compile(dialect=postgresql.dialect())
|
||||
assert big in [bind.value for bind in compiled.binds.values()]
|
||||
|
||||
|
||||
def test_fractional_operand_on_integer_column_is_not_truncated():
|
||||
"""Coercing to the column's int type would turn `lt 5.5` into `lt 5`."""
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"lt": 5.5}})
|
||||
compiled = stmt.compile(dialect=postgresql.dialect())
|
||||
assert 5.5 in [bind.value for bind in compiled.binds.values()]
|
||||
|
||||
|
||||
def test_integer_operand_binds_without_an_integer_cast():
|
||||
"""A plain int bind renders `::INTEGER`, so any value past int4 fails at
|
||||
execute time with "integer out of range" even when the comparison is
|
||||
meaningful. Decimal renders no cast, which is what float() used to do."""
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"gt": 2**31}})
|
||||
assert "::INTEGER" not in str(stmt.compile(dialect=psycopg_dialect.dialect()))
|
||||
|
||||
|
||||
def test_in_list_on_numeric_column_handles_out_of_range_values():
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"in": [1, 2**31]}})
|
||||
assert "::INTEGER" not in str(stmt.compile(dialect=psycopg_dialect.dialect()))
|
||||
|
||||
|
||||
def test_in_list_on_numeric_column_rejects_garbage():
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Message), Message, {"token_count": {"in": [1, "nope"]}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filters",
|
||||
[
|
||||
{"is_active": True},
|
||||
{"is_active": False},
|
||||
{"is_active": {"ne": True}},
|
||||
{"is_active": {"in": [True, False]}},
|
||||
],
|
||||
)
|
||||
def test_bool_column_accepts_native_booleans(filters: dict[str, Any]):
|
||||
"""bool subclasses int, so a boolean column would otherwise be coerced to 1
|
||||
and rejected by Postgres as `boolean <> integer`."""
|
||||
stmt = apply_filter(select(Session), Session, filters)
|
||||
assert stmt.whereclause is not None
|
||||
assert "is_active" in str(stmt.whereclause)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filters",
|
||||
[
|
||||
{"is_active": "true"},
|
||||
{"is_active": "false"},
|
||||
{"is_active": 1},
|
||||
{"is_active": {"ne": "true"}},
|
||||
],
|
||||
)
|
||||
def test_bool_column_rejects_non_boolean_operands(filters: dict[str, Any]):
|
||||
"""These bind as VARCHAR/INTEGER against a boolean column, which Postgres
|
||||
rejects at execute time — a 422 is the honest answer, not a 500."""
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Session), Session, filters)
|
||||
|
||||
|
||||
def test_numeric_string_operand_stays_exact():
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"gt": "5"}})
|
||||
compiled = stmt.compile(dialect=postgresql.dialect())
|
||||
assert 5 in [bind.value for bind in compiled.binds.values()]
|
||||
|
||||
|
||||
def test_ne_none_on_scalar_column_is_not_null():
|
||||
"""Regression: float(None) raised TypeError, which the ValueError handler
|
||||
missed -> unhandled 500."""
|
||||
stmt = apply_filter(select(Document), Document, {"session_id": {"ne": None}})
|
||||
assert "session_name IS NOT NULL" in str(stmt)
|
||||
|
||||
|
||||
def test_ne_string_on_text_column_compares_as_string():
|
||||
"""Regression: numeric operators float()-cast on every column type, so a
|
||||
string inequality on a text column was rejected as a bad number."""
|
||||
stmt = apply_filter(select(Document), Document, {"session_id": {"ne": "abc"}})
|
||||
assert "session_name IS DISTINCT FROM" in str(stmt)
|
||||
|
||||
|
||||
def test_numeric_operator_still_validates_on_numeric_column():
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Message), Message, {"token_count": {"gt": "nope"}})
|
||||
|
||||
|
||||
def test_ne_none_on_numeric_column_is_not_null():
|
||||
stmt = apply_filter(select(Message), Message, {"token_count": {"ne": None}})
|
||||
assert "token_count IS NOT NULL" in str(stmt)
|
||||
|
||||
|
||||
def test_null_operand_on_non_ne_operator_raises():
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Message), Message, {"token_count": {"gt": None}})
|
||||
|
||||
|
||||
def test_enum_column_rejects_an_unknown_value():
|
||||
"""An invalid level silently matched nothing, which reads as "no results"
|
||||
rather than "you sent a value that cannot exist"."""
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(Document), Document, {"level": "banana"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", get_args(DocumentLevel))
|
||||
def test_enum_column_accepts_every_declared_level(level: str):
|
||||
"""Derived from the Literal, so a new level (e.g. "abduction") is covered
|
||||
here the moment it is declared — no second list to keep in sync."""
|
||||
stmt = apply_filter(select(Document), Document, {"level": level})
|
||||
assert stmt.whereclause is not None
|
||||
|
||||
|
||||
def test_empty_in_list_matches_nothing_rather_than_everything():
|
||||
"""Dropping an empty IN would widen the query to every row. Session scoping
|
||||
relies on an empty allowlist failing closed."""
|
||||
stmt = apply_filter(select(Document), Document, {"session_id": {"in": []}})
|
||||
assert stmt.whereclause is not None
|
||||
assert "IN" in str(stmt.whereclause)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filters",
|
||||
[
|
||||
{"created_at": "2026-01-01"},
|
||||
{"created_at": {"gte": "2026-01-01"}},
|
||||
{"token_count": "5"},
|
||||
{"token_count": {"gt": "5"}},
|
||||
],
|
||||
)
|
||||
def test_equality_and_comparison_paths_coerce_alike(filters: dict[str, Any]):
|
||||
"""The two paths had different rules: comparison operators parsed datetimes
|
||||
and coerced numbers, bare equality bound the raw string and 500'd on
|
||||
`timestamp with time zone = character varying`."""
|
||||
stmt = apply_filter(select(Message), Message, filters)
|
||||
assert stmt.whereclause is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "filters"),
|
||||
[
|
||||
(Message, {"token_count": {"contains": 5}}), # integer ~~* text
|
||||
(Message, {"created_at": {"contains": "x"}}), # timestamptz ~~* text
|
||||
(Document, {"metadata": {"gte": 5}}), # jsonb >= integer
|
||||
(Document, {"metadata": {"contains": "x"}}), # jsonb ~~* text
|
||||
(Document, {"source_ids": "abc"}), # jsonb = character varying
|
||||
(Document, {"session_id": 5}), # text = integer
|
||||
(Document, {"session_id": True}), # text = boolean
|
||||
(Message, {"created_at": 5}), # timestamptz = integer
|
||||
(Document, {"embedding": 5}), # no python_type at all
|
||||
],
|
||||
)
|
||||
def test_incompatible_operand_types_are_rejected(model: Any, filters: dict[str, Any]):
|
||||
"""Each of these compiled cleanly and failed in Postgres as
|
||||
`operator does not exist: <coltype> <op> <operandtype>`."""
|
||||
with pytest.raises(FilterError):
|
||||
apply_filter(select(model), model, filters)
|
||||
|
||||
|
||||
def _where(model: Any, filters: dict[str, Any]) -> str:
|
||||
stmt = apply_filter(select(model), model, filters)
|
||||
# Without this, a dropped condition makes split("WHERE") return the whole
|
||||
# statement, so a filter that silently widened could still pass the asserts.
|
||||
assert stmt.whereclause is not None
|
||||
return str(stmt.compile(dialect=psycopg_dialect.dialect())).split("WHERE")[-1]
|
||||
|
||||
|
||||
def test_not_is_null_safe():
|
||||
"""`NOT (col = v)` is NULL when col is NULL, so plain negation drops rows
|
||||
whose column is unset — even though an unset column is not `v`."""
|
||||
where = _where(Document, {"NOT": [{"session_id": "abc"}]})
|
||||
assert "IS NOT true" in where
|
||||
|
||||
|
||||
def test_ne_is_null_safe():
|
||||
where = _where(Document, {"session_id": {"ne": "abc"}})
|
||||
assert "IS DISTINCT FROM" in where
|
||||
|
||||
|
||||
def test_not_is_null_safe_over_a_compound_condition():
|
||||
"""Negation has to survive nesting, not just single comparisons."""
|
||||
where = _where(
|
||||
Document, {"NOT": [{"AND": [{"session_id": "a"}, {"level": "explicit"}]}]}
|
||||
)
|
||||
assert "IS NOT true" in where
|
||||
assert "AND" in where
|
||||
|
||||
|
||||
def test_not_is_null_safe_over_contains():
|
||||
where = _where(Document, {"NOT": [{"session_id": {"contains": "x"}}]})
|
||||
assert "ILIKE" in where
|
||||
assert "IS NOT true" in where
|
||||
|
||||
|
||||
def test_ne_null_still_renders_is_not_null():
|
||||
"""The null operand is intercepted before the operator dispatch, so this
|
||||
path is unchanged by null-safe `ne`."""
|
||||
assert "IS NOT NULL" in _where(Document, {"session_id": {"ne": None}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "filters"),
|
||||
[
|
||||
(Document, {"session_id": None}), # text
|
||||
(Message, {"token_count": None}), # numeric
|
||||
(Session, {"is_active": None}), # boolean
|
||||
(Message, {"created_at": None}), # datetime
|
||||
(Document, {"metadata": None}), # JSONB
|
||||
(Document, {"source_ids": None}), # JSONB via the raw-key fallback
|
||||
],
|
||||
)
|
||||
def test_bare_null_is_a_null_check(model: Any, filters: dict[str, Any]):
|
||||
"""Regression: every operand routes through _coerce_operand, which accepts
|
||||
no None on any column type, so bare null raised FilterError instead of
|
||||
matching the unset rows it names."""
|
||||
assert "IS NULL" in _where(model, filters)
|
||||
|
||||
|
||||
def test_bare_null_agrees_with_negation():
|
||||
"""`NOT [{col: null}]` and `{col: {ne: null}}` must select the same rows.
|
||||
`x IS NULL` never evaluates to NULL, so `(x IS NULL) IS NOT true` is exactly
|
||||
`x IS NOT NULL` — the three forms have to stay in agreement."""
|
||||
assert "IS NOT NULL" in _where(Document, {"session_id": {"ne": None}})
|
||||
negated = _where(Document, {"NOT": [{"session_id": None}]})
|
||||
assert "IS NULL" in negated
|
||||
assert "IS NOT true" in negated
|
||||
|
||||
|
||||
def test_dict_on_raw_key_jsonb_column_is_equality():
|
||||
"""Document falls back to raw column names, so a JSONB column outside
|
||||
JSONB_COLUMNS reaches _build_field_condition's dict branch. It compares as
|
||||
equality rather than containment — unlike `metadata`."""
|
||||
where = _where(Document, {"source_ids": {"kind": "note"}})
|
||||
assert "source_ids =" in where
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filters", "expected"),
|
||||
[
|
||||
({"session_id": "abc"}, "session_name ="),
|
||||
({"session_id": {"contains": "x"}}, "ILIKE"),
|
||||
],
|
||||
)
|
||||
def test_positive_predicates_are_unchanged(filters: dict[str, Any], expected: str):
|
||||
"""A NULL column does not equal or contain anything, so positive predicates
|
||||
correctly exclude those rows and must keep their plain operators."""
|
||||
where = _where(Document, filters)
|
||||
assert expected in where
|
||||
assert "IS NOT true" not in where
|
||||
assert "IS DISTINCT FROM" not in where
|
||||
|
||||
|
||||
# --- Invariants over the whole DSL -------------------------------------------
|
||||
#
|
||||
# The filter body is arbitrary client JSON. Enumerating bad shapes one at a time
|
||||
# is endless, so these two tests assert the properties that make any unhandled
|
||||
# shape a 422 instead of a 500, and fail on the next shape nobody thought of.
|
||||
|
||||
_OPERANDS: list[Any] = [
|
||||
None,
|
||||
True,
|
||||
False,
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
"",
|
||||
"abc",
|
||||
"*",
|
||||
[],
|
||||
[None],
|
||||
[[1]],
|
||||
[{"a": 1}],
|
||||
{},
|
||||
{"operator": "null"},
|
||||
{"ne": None},
|
||||
{"ne": {"a": 1}},
|
||||
{"ne": [1]},
|
||||
{"in": None},
|
||||
{"in": "abc"},
|
||||
{"in": [{"a": 1}]},
|
||||
{"in": [[1]]},
|
||||
{"gt": {}},
|
||||
{"gt": []},
|
||||
{"gt": True},
|
||||
{"contains": None},
|
||||
{"contains": {"a": 1}},
|
||||
{"lt": [1, 2]},
|
||||
]
|
||||
|
||||
_COLUMNS: dict[Any, list[str]] = {
|
||||
Document: ["session_id", "workspace_id", "metadata", "level", "source_ids", "id"],
|
||||
Message: ["session_id", "peer_id", "token_count", "created_at", "metadata"],
|
||||
Session: ["id", "is_active", "created_at", "configuration"],
|
||||
Peer: ["id", "created_at", "metadata"],
|
||||
}
|
||||
|
||||
_MALFORMED: list[dict[str, Any]] = [
|
||||
{"AND": "notalist"},
|
||||
{"AND": [None]},
|
||||
{"AND": [[]]},
|
||||
{"AND": [1]},
|
||||
{"OR": [None]},
|
||||
{"OR": [1]},
|
||||
{"NOT": None},
|
||||
{"NOT": [None]},
|
||||
{"unknown_column": 1},
|
||||
]
|
||||
|
||||
|
||||
def _filter_shapes() -> list[tuple[Any, dict[str, Any]]]:
|
||||
shapes: list[tuple[Any, dict[str, Any]]] = []
|
||||
for model, columns in _COLUMNS.items():
|
||||
for column in columns:
|
||||
for operand in _OPERANDS:
|
||||
leaf = {column: operand}
|
||||
shapes.append((model, leaf))
|
||||
shapes.append((model, {"AND": [leaf]}))
|
||||
shapes.append((model, {"NOT": [leaf]}))
|
||||
shapes.extend((model, bad) for bad in _MALFORMED)
|
||||
return shapes
|
||||
|
||||
|
||||
def test_every_filter_shape_either_compiles_or_raises_filter_error():
|
||||
"""No filter body may escape as anything other than a compiled statement or
|
||||
a FilterError. Anything else reaches the client as an unhandled 500."""
|
||||
escaped: list[tuple[str, dict[str, Any], str]] = []
|
||||
for model, filters in _filter_shapes():
|
||||
try:
|
||||
str(apply_filter(select(model), model, filters))
|
||||
except FilterError:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - failure path
|
||||
escaped.append((model.__name__, filters, type(exc).__name__))
|
||||
assert not escaped, f"non-FilterError escapes: {escaped[:10]}"
|
||||
|
||||
|
||||
def test_no_non_scalar_value_is_bound_to_a_scalar_column():
|
||||
"""A dict or list bound to a non-JSONB parameter compiles cleanly and then
|
||||
fails in psycopg at execute time — the original 500. Nothing may reach that
|
||||
state, including non-scalars nested inside an `in` list."""
|
||||
offenders: list[tuple[str, dict[str, Any], str]] = []
|
||||
for model, filters in _filter_shapes():
|
||||
try:
|
||||
stmt = apply_filter(select(model), model, filters)
|
||||
except FilterError:
|
||||
continue
|
||||
compiled = stmt.compile(dialect=postgresql.dialect())
|
||||
for bind in compiled.binds.values():
|
||||
if isinstance(bind.type, JSONB):
|
||||
continue
|
||||
value: Any = bind.value
|
||||
# An expanding IN bind holds the list itself; check its elements.
|
||||
elements = cast(
|
||||
"Sequence[Any]", value if isinstance(value, list | tuple) else [value]
|
||||
)
|
||||
for element in elements:
|
||||
if element is not None and not isinstance(
|
||||
element, str | bool | int | float | Decimal | datetime
|
||||
):
|
||||
offenders.append((model.__name__, filters, repr(element)[:40]))
|
||||
assert not offenders, f"non-scalar bound to scalar column: {offenders[:10]}"
|
||||
271
uv.lock
271
uv.lock
|
|
@ -8,7 +8,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-08-05T17:35:29.589887Z"
|
||||
exclude-newer = "2026-08-07T23:49:08.393963Z"
|
||||
exclude-newer-span = "P5D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -730,19 +730,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fakeredis"
|
||||
version = "2.35.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
|
|
@ -760,10 +747,9 @@ wheels = [
|
|||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
standard-no-fastapi-cloud-cli = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi-cli", extra = ["standard"] },
|
||||
{ name = "fastar" },
|
||||
{ name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic-extra-types" },
|
||||
|
|
@ -787,30 +773,10 @@ wheels = [
|
|||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "fastapi-cloud-cli" },
|
||||
standard-no-fastapi-cloud-cli = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cloud-cli"
|
||||
version = "0.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastar" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "rignore" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/57/cee8e91b83f39e75ae5562a2237261442a8179dcb3b631c7398113157398/fastapi_cloud_cli-0.17.1.tar.gz", hash = "sha256:0baece208fa88063bec46dccb5fb512f3199162092165e57654b44e64adbc44d", size = 47409, upload-time = "2026-04-27T13:38:07.094Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a0/e252b68cf155409afabea037ab2971f41509481838847f6503fe890884ea/fastapi_cloud_cli-0.17.1-py3-none-any.whl", hash = "sha256:325e0199bdac7cb86f5df4f4a1d2070054095588088ef7b923a60cec458dcd63", size = 34046, upload-time = "2026-04-27T13:38:08.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-pagination"
|
||||
version = "0.15.12"
|
||||
|
|
@ -825,107 +791,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d2/2f/644fd77ecac100da965221751ae4f7604e149c58c46c1d96c37e828bb5f7/fastapi_pagination-0.15.12-py3-none-any.whl", hash = "sha256:758e21157b2844feecb2409072f1433e24f2dc9526ae7906aa1a1b28622a970a", size = 60921, upload-time = "2026-03-28T12:51:04.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastar"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7a/fb367bdaf4efa2c7952a45aeab2e87a564293ecffe150af673ec8edfda46/fastar-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b82fd6f996e65a86f67a6bd64dd22ef3e8ae2dcaed0ae3b550e71f7e1bbb1df5", size = 709869, upload-time = "2026-04-13T17:09:55.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ff/b87efb0dcfd081c62c7c7601d7681dabe63103cd51fc16f8d57a1ab45961/fastar-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27eed386fd0558e6daa29211111bbd7b740f7c7e881197f8a00ac7c0f3cdb1d7", size = 631668, upload-time = "2026-04-13T17:09:40.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/7c/0ed6dd38b9adc04b3a8ec3b7045908e7c2170ba0ff6e6d2c51bc9fc770f3/fastar-0.11.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a6931bebc1d8e95ddeef55732c195449e6b44ef33aa31b325505097ed3b4d6aa", size = 869663, upload-time = "2026-04-13T17:09:09.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ce/8b7fb3f23855accebaaf2d2637eac7f261a7a5d936f861a172079f1ef511/fastar-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f72ce42a5e28a74fbd4d5fbf1a3ac1a1163d13cbc200cbd005fb0fabc54bd", size = 762938, upload-time = "2026-04-13T17:07:54.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/cc/5491e2b677bb841f768e3aba052d0344338a5c78aa5d4c18b443831a8e8d/fastar-0.11.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b83c1f61f7017d6e1498568038f8745440cfc16ca2f697ec81bac83050108f6", size = 759232, upload-time = "2026-04-13T17:08:08.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/643630bdbd179e41e9fae31c03b4cf6061dbf4d6fbbae8425d16eb12545d/fastar-0.11.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db73a9b765a516e73983b25341e7b5e0189733878279e278b2295131b0e3a21e", size = 926271, upload-time = "2026-04-13T17:08:23.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/5d/37ade50003b4540e0a53ef100f6692d7ab2ac1122d5acf39920cc09a3e8b/fastar-0.11.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:625827d52eb4e8fec942e0233f125ff8010fcf6a67c0a974a8e5f4666b771e3c", size = 818634, upload-time = "2026-04-13T17:08:54.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/ff/135d177de32cc1e837c99019e4643e6e79352bde49544d4ece5b5eebf56b/fastar-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7f5fd8fa21ec0a88296a38dc5d7fc35efd3b26d46a17b8b7c73c5563925ca15", size = 822755, upload-time = "2026-04-13T17:09:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/cb/b835dbe76ceac7fa6105851468c259ffd06830eb9c029402e499d0ec153b/fastar-0.11.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8c15af91b8cd87ddf23ea55355ae513c1de3ab67178f26dad017c9e9c0af6096", size = 887101, upload-time = "2026-04-13T17:08:39.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/54/aa8289eb57fc550535470397cb051f5a58a7c89ca4de31d5502b916dd894/fastar-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a112395a8b0bff251423bd1564c012f0cc058ad8b6bd8fba96f3d7fc117e44", size = 973606, upload-time = "2026-04-13T17:10:10.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/fd/776d50a0897c01dc6bfd0926772ee913436fdae91b9affaf0a0cbd09f0a1/fastar-0.11.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2994bb8f5f8c11eb12beae1e6e77a907173c9819236b8a4c8f0573652ceccce", size = 1036696, upload-time = "2026-04-13T17:10:28.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/cf0f9b499fb37ac065c8a01ec642f96a3c5eb849c38ae983b59f3b3245e0/fastar-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dcf99e4b5973d842c7f19c776c3a83cdc0977d505edce6206438505c0456b517", size = 1078182, upload-time = "2026-04-13T17:10:45.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/9e/21e4701aec4a1123d4dc4d31578dc18875582b5710e4725f7ceb752a248b/fastar-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29c9c386dc0d5dda78845a8e6b1480d26ab861c1e0b68f42ae5735cb70ca07f1", size = 1032336, upload-time = "2026-04-13T17:11:02.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e2/5872b28c72c27ec1a00760eace6ff35f714f41ebbd5208cf016b12e29250/fastar-0.11.0-cp311-cp311-win32.whl", hash = "sha256:030b2580fc394f2c9b7890b6735810404e9b9ed5e0344db150b945965b5482b7", size = 457368, upload-time = "2026-04-13T17:11:43.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/6e/ce6832a16193eb4466f4108be8809c249b51cb1f89dd7894545700d079d5/fastar-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:83ab57ae067969cd0b483ac3b6dccc4b595fc77f5c820760998648d4c42822b5", size = 488605, upload-time = "2026-04-13T17:11:29.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5a/9cfb80661cf38fd7b0889224beb7d2746784d4ade2a931ed9775a18d8602/fastar-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:27b1a4cee2298b704de8151d310462ee7335ed036011ca9aa6e784b30b6c73a9", size = 464580, upload-time = "2026-04-13T17:11:18.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/5c/9bbeffbf1905391446dd98aa520422ce7affde5c9a7c22d757cc5d7c1397/fastar-0.11.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1266d6a004f427b0d61bd6c7b544d84cc964691b2232c2f4d635a1b75f2f6d5e", size = 711644, upload-time = "2026-04-13T17:10:07.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/af/ae5cf39d4fb82d0c592705f5ec6db1b065be5265c151b108f86126ee8773/fastar-0.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:298a827ec04ade43733f6ca960d0faec38706aa1494175869ea7ea17f5bad5d3", size = 634371, upload-time = "2026-04-13T17:09:52.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/36/8d4569e26473c72ccb02d1c5df3ed710073f1c06eca09c26d52ea79fd815/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8800e2387e463a0e5799416a1cbe72dd0fde7270a20e4bde684145e7878f6516", size = 870850, upload-time = "2026-04-13T17:09:21.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/46/724dc796e1756d3977970f820d30d59bb8cab8e3671b285f1d82ab513aec/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7496def0a2befd82d429cb004ef7ca831585cc887947bd6b9abb68a5ef852b0b", size = 764469, upload-time = "2026-04-13T17:08:05.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e3/74d6859e632e8fb9339a14f652fb9f800c2bd6aa53071e311c0be3fbab8b/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:878eaf15463eb572e3538af7ca3a8534e5e279cf8196db902d24e5725c4af86e", size = 761375, upload-time = "2026-04-13T17:08:20.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e7/cc70e2be5ef8731a7525552b1c35c1448cf9eae6a62cb3a56f12c1bf27ea/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0324ed1d1ef0186e1bbd843b17807d6d837d0906899d4c99378b02c5d86bdd9c", size = 928189, upload-time = "2026-04-13T17:08:35.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/33/c9a969e78dca323547276a6fee5f4f9588f7cd5ab45acec3778c67399589/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdf9bd863205590beaf8ef6e66f315310196632180dceaf674985d01a876cac3", size = 820864, upload-time = "2026-04-13T17:09:06.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/bd/6b9434b541fe55c125b5f2e017a565596a2d215aa09207e4555e4585064f/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59af8dbb683b24b90fb5b506de080faeab0a17a908e6c2a5d93a97260ed75d7b", size = 824060, upload-time = "2026-04-13T17:09:37.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/8d/871d5f8cf4c6f13987119fb0a9ae8be131e34f2756c2524e9974adf33824/fastar-0.11.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:9f3df73a3c4292cfe15696cdf59cdb6c309ab59d30b34c733be13c6e32d9a264", size = 889217, upload-time = "2026-04-13T17:08:50.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/26/cca0fd2704f3ed20165e5613ed911549aef3aaf3b0b5b02fee0e8e23e6cc/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa3762cbb16e41a76b61f4a6914937a71aab3a7b6c2d82ca233bc686ebaf756b", size = 975418, upload-time = "2026-04-13T17:10:24.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/94/8bbb0b13f5b6cbe2492f0b7cbba5103e6163976a3331466d010e781fa189/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:a8c7bc8ac74cb359bb546b199288c83236372d094b402e557c197e85527495cd", size = 1038492, upload-time = "2026-04-13T17:10:41.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/d3/5b7df222a30eac2822ffd00f82fd4c2ce84fba4b369d1e1a03732fd177fc/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:587cbd060a2699c5f66281081395bb4657b2b1e0eef5c206b1aabf740019d670", size = 1080210, upload-time = "2026-04-13T17:10:58.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/56ef943ea524784598c035ccbd42e564e937da0438ae3f55f0e76cb95571/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a1c56957ac82408be37a3f63594bc83e0919e8760492a4475e542f9f1828778", size = 1034886, upload-time = "2026-04-13T17:11:15.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.0"
|
||||
|
|
@ -1165,13 +1030,12 @@ dependencies = [
|
|||
{ name = "alembic" },
|
||||
{ name = "cashews", extra = ["redis"] },
|
||||
{ name = "cloudevents" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
|
||||
{ name = "fastapi-pagination" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "httpx" },
|
||||
{ name = "json-repair" },
|
||||
{ name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" },
|
||||
{ name = "langfuse" },
|
||||
{ name = "nanoid" },
|
||||
{ name = "openai" },
|
||||
|
|
@ -1179,7 +1043,6 @@ dependencies = [
|
|||
{ name = "pgvector" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
|
|
@ -1195,12 +1058,17 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
lancedb = [
|
||||
{ name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" },
|
||||
{ name = "pyarrow" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "basedpyright" },
|
||||
{ name = "boto3" },
|
||||
{ name = "coverage" },
|
||||
{ name = "fakeredis" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "interrogate" },
|
||||
{ name = "pre-commit" },
|
||||
|
|
@ -1219,13 +1087,13 @@ requires-dist = [
|
|||
{ name = "alembic", specifier = ">=1.14.0" },
|
||||
{ name = "cashews", extras = ["redis"], specifier = "==7.5.0" },
|
||||
{ name = "cloudevents", specifier = ">=1.12.0,<2.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.131.0" },
|
||||
{ name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.131.0" },
|
||||
{ name = "fastapi-pagination", specifier = ">=0.14.2" },
|
||||
{ name = "google-genai", specifier = ">=1.32.0" },
|
||||
{ name = "greenlet", specifier = ">=3.0.3" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "json-repair", specifier = ">=0.49.0" },
|
||||
{ name = "lancedb", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=0.25.3" },
|
||||
{ name = "lancedb", marker = "(platform_machine != 'x86_64' and extra == 'lancedb') or (sys_platform != 'darwin' and extra == 'lancedb')", specifier = ">=0.25.3" },
|
||||
{ name = "langfuse", specifier = ">=3.3.2" },
|
||||
{ name = "nanoid", specifier = ">=2.0.0" },
|
||||
{ name = "openai", specifier = ">=1.99.7" },
|
||||
|
|
@ -1233,7 +1101,7 @@ requires-dist = [
|
|||
{ name = "pgvector", specifier = ">=0.2.5" },
|
||||
{ name = "prometheus-client", specifier = ">=0.21.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.19" },
|
||||
{ name = "pyarrow", specifier = ">=19.0.0" },
|
||||
{ name = "pyarrow", marker = "extra == 'lancedb'", specifier = ">=19.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.0" },
|
||||
|
|
@ -1248,13 +1116,13 @@ requires-dist = [
|
|||
{ name = "turbopuffer", specifier = ">=1.8.1" },
|
||||
{ name = "typing-extensions", specifier = ">=4.11.0" },
|
||||
]
|
||||
provides-extras = ["lancedb"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "basedpyright", specifier = ">=1.29.4" },
|
||||
{ name = "boto3", specifier = ">=1.42.5" },
|
||||
{ name = "coverage", specifier = ">=7.6.0" },
|
||||
{ name = "fakeredis", specifier = ">=2.32.0" },
|
||||
{ name = "honcho-ai", editable = "sdks/python" },
|
||||
{ name = "interrogate", specifier = ">=1.7.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
||||
|
|
@ -2802,11 +2670,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
|
|
@ -3307,101 +3170,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rignore"
|
||||
version = "0.7.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/41/b6e2be3069ef3b7f24e35d2911bd6deb83d20ed5642ad81d5a6d1c015473/rignore-0.7.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:40be8226e12d6653abbebaffaea2885f80374c1c8f76fe5ca9e0cadd120a272c", size = 885285, upload-time = "2025-11-05T20:42:39.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/66/ba7f561b6062402022887706a7f2b2c2e2e2a28f1e3839202b0a2f77e36d/rignore-0.7.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182f4e5e4064d947c756819446a7d4cdede8e756b8c81cf9e509683fe38778d7", size = 823882, upload-time = "2025-11-05T20:42:23.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/81/4087453df35a90b07370647b19017029324950c1b9137d54bf1f33843f17/rignore-0.7.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16b63047648a916a87be1e51bb5c009063f1b8b6f5afe4f04f875525507e63dc", size = 899362, upload-time = "2025-11-05T20:40:51.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c9/390a8fdfabb76d71416be773bd9f162977bd483084f68daf19da1dec88a6/rignore-0.7.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba5524f5178deca4d7695e936604ebc742acb8958f9395776e1fcb8133f8257a", size = 873633, upload-time = "2025-11-05T20:41:06.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c9/79404fcb0faa76edfbc9df0901f8ef18568d1104919ebbbad6d608c888d1/rignore-0.7.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62020dbb89a1dd4b84ab3d60547b3b2eb2723641d5fb198463643f71eaaed57d", size = 1167633, upload-time = "2025-11-05T20:41:22.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/8d/b3466d32d445d158a0aceb80919085baaae495b1f540fb942f91d93b5e5b/rignore-0.7.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b34acd532769d5a6f153a52a98dcb81615c949ab11697ce26b2eb776af2e174d", size = 941434, upload-time = "2025-11-05T20:41:38.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/40/9cd949761a7af5bc27022a939c91ff622d29c7a0b66d0c13a863097dde2d/rignore-0.7.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c5e53b752f9de44dff7b3be3c98455ce3bf88e69d6dc0cf4f213346c5e3416c", size = 959461, upload-time = "2025-11-05T20:42:08.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/87/1e1a145731f73bdb7835e11f80da06f79a00d68b370d9a847de979575e6d/rignore-0.7.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25b3536d13a5d6409ce85f23936f044576eeebf7b6db1d078051b288410fc049", size = 985323, upload-time = "2025-11-05T20:41:52.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/31/1ecff992fc3f59c4fcdcb6c07d5f6c1e6dfb55ccda19c083aca9d86fa1c6/rignore-0.7.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e01cad2b0b92f6b1993f29fc01f23f2d78caf4bf93b11096d28e9d578eb08ce", size = 1079173, upload-time = "2025-11-05T21:40:12.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/18/162eedadb4c2282fa4c521700dbf93c9b14b8842e8354f7d72b445b8d593/rignore-0.7.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5991e46ab9b4868334c9e372ab0892b0150f3f586ff2b1e314272caeb38aaedb", size = 1139012, upload-time = "2025-11-05T21:40:29.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/96/a9ca398a8af74bb143ad66c2a31303c894111977e28b0d0eab03867f1b43/rignore-0.7.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c8ae562e5d1246cba5eaeb92a47b2a279e7637102828dde41dcbe291f529a3e", size = 1118827, upload-time = "2025-11-05T21:40:46.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/22/1c1a65047df864def9a047dbb40bc0b580b8289a4280e62779cd61ae21f2/rignore-0.7.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aaf938530dcc0b47c4cfa52807aa2e5bfd5ca6d57a621125fe293098692f6345", size = 1128182, upload-time = "2025-11-05T21:41:04.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/f4/1526eb01fdc2235aca1fd9d0189bee4021d009a8dcb0161540238c24166e/rignore-0.7.6-cp311-cp311-win32.whl", hash = "sha256:166ebce373105dd485ec213a6a2695986346e60c94ff3d84eb532a237b24a4d5", size = 646547, upload-time = "2025-11-05T21:41:49.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c8/dda0983e1845706beb5826459781549a840fe5a7eb934abc523e8cd17814/rignore-0.7.6-cp311-cp311-win_amd64.whl", hash = "sha256:44f35ee844b1a8cea50d056e6a595190ce9d42d3cccf9f19d280ae5f3058973a", size = 727139, upload-time = "2025-11-05T21:41:34.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/47/eb1206b7bf65970d41190b879e1723fc6bbdb2d45e53565f28991a8d9d96/rignore-0.7.6-cp311-cp311-win_arm64.whl", hash = "sha256:14b58f3da4fa3d5c3fa865cab49821675371f5e979281c683e131ae29159a581", size = 657598, upload-time = "2025-11-05T21:41:23.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/a6250ff0c49a3cdb943910ada4116e708118e9b901c878cfae616c80a904/rignore-0.7.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a20b6fb61bcced9a83dfcca6599ad45182b06ba720cff7c8d891e5b78db5b65f", size = 886470, upload-time = "2025-11-05T20:42:52.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/af/c69c0c51b8f9f7914d95c4ea91c29a2ac067572048cae95dd6d2efdbe05d/rignore-0.7.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:392dcabfecbe176c9ebbcb40d85a5e86a5989559c4f988c2741da7daf1b5be25", size = 825976, upload-time = "2025-11-05T20:42:35.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d2/1b264f56132264ea609d3213ab603d6a27016b19559a1a1ede1a66a03dcd/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22baa462abdc36fdd5a5e2dae423107723351b85ff093762f9261148b9d0a04a", size = 899739, upload-time = "2025-11-05T20:41:01.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e4/b3c5dfdd8d8a10741dfe7199ef45d19a0e42d0c13aa377c83bd6caf65d90/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53fb28882d2538cb2d231972146c4927a9d9455e62b209f85d634408c4103538", size = 874843, upload-time = "2025-11-05T20:41:17.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/10/d6f3750233881a2a154cefc9a6a0a9b19da526b19f7f08221b552c6f827d/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87409f7eeb1103d6b77f3472a3a0d9a5953e3ae804a55080bdcb0120ee43995b", size = 1170348, upload-time = "2025-11-05T20:41:34.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/10/ad98ca05c9771c15af734cee18114a3c280914b6e34fde9ffea2e61e88aa/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684014e42e4341ab3ea23a203551857fcc03a7f8ae96ca3aefb824663f55db32", size = 942315, upload-time = "2025-11-05T20:41:48.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/00/ab5c0f872acb60d534e687e629c17e0896c62da9b389c66d3aa16b817aa8/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77356ebb01ba13f8a425c3d30fcad40e57719c0e37670d022d560884a30e4767", size = 961047, upload-time = "2025-11-05T20:42:19.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/86/3030fdc363a8f0d1cd155b4c453d6db9bab47a24fcc64d03f61d9d78fe6a/rignore-0.7.6-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6cbd8a48abbd3747a6c830393cd578782fab5d43f4deea48c5f5e344b8fed2b0", size = 986090, upload-time = "2025-11-05T20:42:03.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b8/133aa4002cee0ebbb39362f94e4898eec7fbd09cec9fcbce1cd65b355b7f/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2673225dcec7f90497e79438c35e34638d0d0391ccea3cbb79bfb9adc0dc5bd7", size = 1079656, upload-time = "2025-11-05T21:40:24.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/56/36d5d34210e5e7dfcd134eed8335b19e80ae940ee758f493e4f2b344dd70/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:c081f17290d8a2b96052b79207622aa635686ea39d502b976836384ede3d303c", size = 1139789, upload-time = "2025-11-05T21:40:42.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/5b/bb4f9420802bf73678033a4a55ab1bede36ce2e9b41fec5f966d83d932b3/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:57e8327aacc27f921968cb2a174f9e47b084ce9a7dd0122c8132d22358f6bd79", size = 1120308, upload-time = "2025-11-05T21:40:59.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.12"
|
||||
|
|
@ -3611,15 +3379,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
|
|
|
|||
Loading…
Reference in New Issue