From 21b845bf8819df071d8f25c4e6089df424830417 Mon Sep 17 00:00:00 2001 From: zhanghui Date: Fri, 24 Jul 2026 13:44:58 +0800 Subject: [PATCH] feat(api): serve endpoints under /api/v2, retain /api/v1 as alias Every business endpoint (memory/*, ome/*, knowledge/*) is now served under /api/v2, aligning the open-source API with the EverOS Cloud contract. /api/v1 is retained as a permanent, backward-compatible alias: the same router objects are mounted under both prefixes, so both resolve to identical handlers and request/response contracts. Existing /api/v1 integrations keep working unchanged. Infra endpoints (/health, /metrics) stay unversioned. Fix the Prometheus request-metric label to build the path from the full request URL (with path params folded) rather than the route's router-relative path, so the version prefix is preserved and v1/v2 traffic stays distinguishable. Docs (docs/api.md, docs/openapi.json), CHANGELOG, and route docstrings updated to lead with /api/v2. Add test_api_versioning as the parity guard: every v2 route has an identical v1 twin and vice versa. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 + docs/api.md | 71 +- docs/openapi.json | 874 +++++++++++++++++- src/everos/core/middleware/prometheus.py | 29 +- src/everos/entrypoints/api/app.py | 26 +- src/everos/entrypoints/api/routes/get.py | 4 +- .../entrypoints/api/routes/knowledge.py | 2 +- src/everos/entrypoints/api/routes/memorize.py | 4 +- src/everos/entrypoints/api/routes/ome.py | 6 +- src/everos/entrypoints/api/routes/search.py | 4 +- src/everos/memory/get/__init__.py | 2 +- src/everos/memory/get/dto.py | 4 +- src/everos/memory/get/manager.py | 2 +- src/everos/memory/search/__init__.py | 2 +- src/everos/memory/search/dto.py | 4 +- src/everos/memory/search/manager.py | 2 +- src/everos/service/get.py | 2 +- src/everos/service/memorize.py | 2 +- .../test_middleware/test_prometheus.py | 31 +- .../test_api/test_api_versioning.py | 68 ++ .../test_routes/test_metrics_route.py | 20 + 21 files changed, 1092 insertions(+), 76 deletions(-) create mode 100644 tests/unit/test_entrypoints/test_api/test_api_versioning.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 379c9c2..227c223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`/api/v2` API prefix** — every business endpoint (`memory/*`, `ome/*`, + `knowledge/*`) is now served under `/api/v2`, aligning the open-source API + with the EverOS Cloud contract. `/api/v1` is retained as a permanent, + backward-compatible alias: both prefixes resolve to the same handlers with + identical request/response contracts, so existing integrations keep working + unchanged. Infrastructure endpoints (`/health`, `/metrics`) stay unversioned. + ## [1.1.4] - 2026-07-20 ### Added diff --git a/docs/api.md b/docs/api.md index b76ca99..aea91d0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,4 @@ -# EverOS HTTP API (v1) +# EverOS HTTP API (v2) Human-readable reference for the EverOS HTTP API. Schema names, types and validation constraints mirror the OpenAPI spec served at @@ -26,11 +26,11 @@ business semantics the raw spec does not carry. - [SearchMethod](#searchmethod) - [GetMemoryType](#getmemorytype) - [Endpoints](#endpoints) - - [POST /api/v1/memory/add](#post-apiv1memoryadd) - - [POST /api/v1/memory/flush](#post-apiv1memoryflush) - - [POST /api/v1/memory/search](#post-apiv1memorysearch) - - [POST /api/v1/memory/get](#post-apiv1memoryget) - - [POST /api/v1/ome/trigger](#post-apiv1ometrigger) + - [POST /api/v2/memory/add](#post-apiv1memoryadd) + - [POST /api/v2/memory/flush](#post-apiv1memoryflush) + - [POST /api/v2/memory/search](#post-apiv1memorysearch) + - [POST /api/v2/memory/get](#post-apiv1memoryget) + - [POST /api/v2/ome/trigger](#post-apiv1ometrigger) - [Knowledge endpoints](#knowledge-endpoints) - [OpenAPI spec source](#openapi-spec-source) @@ -42,15 +42,22 @@ business semantics the raw spec does not carry. |---|---|---| | Host | `127.0.0.1` (loopback only) | `EVEROS_API__HOST` env var or `--host` flag | | Port | `8000` | `EVEROS_API__PORT` env var or `--port` flag | -| Version prefix | `/api/v1` | — | +| Version prefix | `/api/v2` | — | -Business endpoints live under `/api/v1/memory/`, `/api/v1/ome/`, and -`/api/v1/knowledge/`. Knowledge endpoints have their own dedicated +Business endpoints live under `/api/v2/memory/`, `/api/v2/ome/`, and +`/api/v2/knowledge/`. Knowledge endpoints have their own dedicated reference at [docs/knowledge.md](knowledge.md) and are cross-referenced below. The operational endpoints `GET /health` and `GET /metrics` exist but are intentionally outside this reference — they are runtime probes for deployment, not part of the application contract. +`/api/v2` is the canonical prefix, aligned with the EverOS Cloud API. Every +business endpoint is **also** served under `/api/v1`, which is retained as a +permanent, backward-compatible alias: the two prefixes resolve to the same +handlers with identical request/response contracts. Existing `/api/v1` +integrations keep working unchanged; new integrations should use `/api/v2`. +Swap the prefix in any example below to reach the same endpoint under v1. + ### Content type All `POST` endpoints require `Content-Type: application/json`. Request @@ -133,7 +140,7 @@ storage. This is the same rule users see when reading rendered output: e.g. `alice_ep_20260528_00000001` for an episode, `alice_af_...` for an atomic fact. See [storage_layout.md §4](storage_layout.md) for the encoding. -- **All endpoints are POST** for `/api/v1/memory/*` even when the +- **All endpoints are POST** for `/api/v2/memory/*` even when the semantics look like a read (`/search`, `/get`) — the request bodies are too rich (filters, methods, paging) to encode in a query string. @@ -173,7 +180,7 @@ the top level (mirroring the success envelope) alongside a nested "code": "NOT_FOUND", "message": "Document 'abc123' not found", "timestamp": "2026-06-01T12:24:46+00:00", - "path": "/api/v1/knowledge/documents/abc123" + "path": "/api/v2/knowledge/documents/abc123" } } ``` @@ -204,7 +211,7 @@ parsing the human-readable `message` field. | `code` | `string` | One of the `ErrorCode` values listed above | | `message` | `string` | Human-readable reason. For `INVALID_INPUT` from request validation, **only the first** validation error is surfaced, formatted `": "` with the leading `body` segment stripped (e.g. `"Field required: messages"`); a model-level validator with no field location surfaces just `""` (e.g. `"Value error, exactly one of user_id / agent_id must be provided"`) | | `timestamp` | `string` | ISO-8601 with timezone offset (display tz) | -| `path` | `string` | Request path, e.g. `/api/v1/memory/add` | +| `path` | `string` | Request path, e.g. `/api/v2/memory/add` | > Unlike FastAPI's default, the full per-field validation array is **not** > returned — only the first error's message. A client that needs the @@ -478,7 +485,7 @@ require `agent_id`. The mismatching combinations are rejected with ## Endpoints -### POST /api/v1/memory/add +### POST /api/v2/memory/add Append a batch of messages to a session buffer. The server accumulates messages until the boundary detector decides the session @@ -535,7 +542,7 @@ correlation. ```bash TS=$(( $(date +%s) * 1000 )) -curl -X POST http://127.0.0.1:8000/api/v1/memory/add \ +curl -X POST http://127.0.0.1:8000/api/v2/memory/add \ -H 'Content-Type: application/json' \ -d "{ \"session_id\": \"demo-002\", @@ -561,7 +568,7 @@ Response (real capture): } ``` -### POST /api/v1/memory/flush +### POST /api/v2/memory/flush Force the boundary detector to decide **now** for the given session buffer. The LLM runs extraction (one call) regardless of whether the @@ -603,7 +610,7 @@ sync is still asynchronous — see #### cURL example ```bash -curl -X POST http://127.0.0.1:8000/api/v1/memory/flush \ +curl -X POST http://127.0.0.1:8000/api/v2/memory/flush \ -H 'Content-Type: application/json' \ -d '{"session_id":"demo-002","app_id":"default","project_id":"default"}' ``` @@ -620,7 +627,7 @@ extraction LLM call): } ``` -### POST /api/v1/memory/search +### POST /api/v2/memory/search Hybrid retrieval over the memory store. Combines BM25, dense vector ANN, optional scalar filtering, optional cross-encoder rerank, and @@ -823,7 +830,7 @@ attribution, so `session_id` is the only meaningful query dimension. #### cURL example ```bash -curl -X POST http://127.0.0.1:8000/api/v1/memory/search \ +curl -X POST http://127.0.0.1:8000/api/v2/memory/search \ -H 'Content-Type: application/json' \ -d '{ "user_id": "alice", @@ -871,7 +878,7 @@ Response (real capture): } ``` -### POST /api/v1/memory/get +### POST /api/v2/memory/get Paginated listing of memory records of a given kind for a single owner. No ranking — ordering is `sort_by` × `sort_order` only. Used @@ -1003,7 +1010,7 @@ Same shape as [SearchAgentSkillItem](#searchagentskillitem) **minus** #### cURL example ```bash -curl -X POST http://127.0.0.1:8000/api/v1/memory/get \ +curl -X POST http://127.0.0.1:8000/api/v2/memory/get \ -H 'Content-Type: application/json' \ -d '{ "user_id": "alice", @@ -1043,7 +1050,7 @@ Response (real capture): } ``` -### POST /api/v1/ome/trigger +### POST /api/v2/ome/trigger Manually trigger a registered OME strategy. @@ -1071,7 +1078,7 @@ Manually trigger a registered OME strategy. #### cURL example ```bash -curl -X POST http://127.0.0.1:8000/api/v1/ome/trigger \ +curl -X POST http://127.0.0.1:8000/api/v2/ome/trigger \ -H 'Content-Type: application/json' \ -d '{"name": "reflect_episodes", "force": true}' ``` @@ -1080,7 +1087,7 @@ curl -X POST http://127.0.0.1:8000/api/v1/ome/trigger \ ### Knowledge endpoints -The knowledge base subsystem (`/api/v1/knowledge/*`) provides document +The knowledge base subsystem (`/api/v2/knowledge/*`) provides document upload, CRUD, and hybrid search. These endpoints are fully documented in their own reference: **[docs/knowledge.md](knowledge.md)**. @@ -1088,15 +1095,15 @@ Summary of available routes: | Method | Path | Description | |---|---|---| -| `POST` | `/api/v1/knowledge/documents` | Upload and extract a document | -| `GET` | `/api/v1/knowledge/documents` | List documents (paginated) | -| `GET` | `/api/v1/knowledge/documents/{doc_id}` | Get a single document | -| `PUT` | `/api/v1/knowledge/documents/{doc_id}` | Replace a document | -| `PATCH` | `/api/v1/knowledge/documents/{doc_id}` | Partial update | -| `DELETE` | `/api/v1/knowledge/documents/{doc_id}` | Delete a document | -| `GET` | `/api/v1/knowledge/topics/{topic_id}` | Get a single topic | -| `POST` | `/api/v1/knowledge/search` | Hybrid search over topics | -| `GET` | `/api/v1/knowledge/categories` | List taxonomy categories | +| `POST` | `/api/v2/knowledge/documents` | Upload and extract a document | +| `GET` | `/api/v2/knowledge/documents` | List documents (paginated) | +| `GET` | `/api/v2/knowledge/documents/{doc_id}` | Get a single document | +| `PUT` | `/api/v2/knowledge/documents/{doc_id}` | Replace a document | +| `PATCH` | `/api/v2/knowledge/documents/{doc_id}` | Partial update | +| `DELETE` | `/api/v2/knowledge/documents/{doc_id}` | Delete a document | +| `GET` | `/api/v2/knowledge/topics/{topic_id}` | Get a single topic | +| `POST` | `/api/v2/knowledge/search` | Hybrid search over topics | +| `GET` | `/api/v2/knowledge/categories` | List taxonomy categories | --- diff --git a/docs/openapi.json b/docs/openapi.json index c45a2e0..8641833 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -811,6 +811,766 @@ } } } + }, + "/api/v2/memory/add": { + "post": { + "tags": [ + "memory" + ], + "summary": "Add Memory", + "description": "Add messages into the user-memory + agent-memory pipelines.", + "operationId": "add_memory_api_v2_memory_add_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemorizeAddRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_AddResponseData_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/memory/flush": { + "post": { + "tags": [ + "memory" + ], + "summary": "Flush Memory", + "description": "Force boundary detection over the current ``session_id`` buffer.\n\n[OSS-only] — cloud edition decides boundary timing server-side and\ndoes not expose this endpoint.", + "operationId": "flush_memory_api_v2_memory_flush_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemorizeFlushRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_FlushResponseData_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/memory/search": { + "post": { + "tags": [ + "memory" + ], + "summary": "Post Search", + "description": "Hybrid retrieval across the configured memory backends.", + "operationId": "post_search_api_v2_memory_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/memory/get": { + "post": { + "tags": [ + "memory" + ], + "summary": "Post Get", + "description": "Paginated listing over the requested ``memory_type``.", + "operationId": "post_get_api_v2_memory_get_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/ome/trigger": { + "post": { + "tags": [ + "ome" + ], + "summary": "Trigger", + "description": "Manually trigger a registered OME strategy and wait for completion.", + "operationId": "trigger_api_v2_ome_trigger_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/knowledge/documents": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Create Document Route", + "description": "Upload a new knowledge document.", + "operationId": "create_document_route_api_v2_knowledge_documents_post", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_create_document_route_api_v2_knowledge_documents_post" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_DocumentCreateResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "knowledge" + ], + "summary": "List Documents Route", + "description": "Paginated document listing.", + "operationId": "list_documents_route_api_v2_knowledge_documents_get", + "parameters": [ + { + "name": "app_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "App Id" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "Project Id" + } + }, + { + "name": "category_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Id" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 20, + "title": "Page Size" + } + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "schema": { + "enum": [ + "created_at", + "updated_at", + "title" + ], + "type": "string", + "default": "created_at", + "title": "Sort By" + } + }, + { + "name": "sort_order", + "in": "query", + "required": false, + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "default": "desc", + "title": "Sort Order" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_DocumentListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/knowledge/documents/{doc_id}": { + "put": { + "tags": [ + "knowledge" + ], + "summary": "Replace Document Route", + "description": "Replace an existing knowledge document (atomic backup/restore on failure).", + "operationId": "replace_document_route_api_v2_knowledge_documents__doc_id__put", + "parameters": [ + { + "name": "doc_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^d_[a-f0-9]{12,32}$", + "title": "Doc Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_replace_document_route_api_v2_knowledge_documents__doc_id__put" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_DocumentCreateResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "knowledge" + ], + "summary": "Delete Document Route", + "description": "Remove a knowledge document.", + "operationId": "delete_document_route_api_v2_knowledge_documents__doc_id__delete", + "parameters": [ + { + "name": "doc_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^d_[a-f0-9]{12,32}$", + "title": "Doc Id" + } + }, + { + "name": "app_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "App Id" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "knowledge" + ], + "summary": "Get Document Route", + "description": "Fetch a single document with its topic list.", + "operationId": "get_document_route_api_v2_knowledge_documents__doc_id__get", + "parameters": [ + { + "name": "doc_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^d_[a-f0-9]{12,32}$", + "title": "Doc Id" + } + }, + { + "name": "app_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "App Id" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_DocumentDetailResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "knowledge" + ], + "summary": "Patch Document Route", + "description": "Update mutable document metadata fields.", + "operationId": "patch_document_route_api_v2_knowledge_documents__doc_id__patch", + "parameters": [ + { + "name": "doc_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^d_[a-f0-9]{12,32}$", + "title": "Doc Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_DocumentPatchResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/knowledge/topics/{topic_id}": { + "get": { + "tags": [ + "knowledge" + ], + "summary": "Get Topic Route", + "description": "Fetch a single topic with full content.", + "operationId": "get_topic_route_api_v2_knowledge_topics__topic_id__get", + "parameters": [ + { + "name": "topic_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^d_[a-f0-9]{12,32}_\\d+$", + "title": "Topic Id" + } + }, + { + "name": "app_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "App Id" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_TopicDetailResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "tags": [ + "knowledge" + ], + "summary": "Search Knowledge Route", + "description": "Knowledge retrieval (keyword / vector / hybrid).", + "operationId": "search_knowledge_route_api_v2_knowledge_search_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeSearchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_KnowledgeSearchResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/knowledge/categories": { + "get": { + "tags": [ + "knowledge" + ], + "summary": "List Categories Route", + "description": "List taxonomy categories from ``.taxonomy.md``.", + "operationId": "list_categories_route_api_v2_knowledge_categories_get", + "parameters": [ + { + "name": "app_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "App Id" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "default", + "title": "Project Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessEnvelope_CategoryListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -890,6 +1650,59 @@ ], "title": "Body_create_document_route_api_v1_knowledge_documents_post" }, + "Body_create_document_route_api_v2_knowledge_documents_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "title": { + "type": "string", + "minLength": 1, + "pattern": "\\w", + "title": "Title" + }, + "source_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Type" + }, + "category_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "app_id": { + "type": "string", + "title": "App Id", + "default": "default" + }, + "project_id": { + "type": "string", + "title": "Project Id", + "default": "default" + } + }, + "type": "object", + "required": [ + "file", + "title" + ], + "title": "Body_create_document_route_api_v2_knowledge_documents_post" + }, "Body_replace_document_route_api_v1_knowledge_documents__doc_id__put": { "properties": { "file": { @@ -943,6 +1756,59 @@ ], "title": "Body_replace_document_route_api_v1_knowledge_documents__doc_id__put" }, + "Body_replace_document_route_api_v2_knowledge_documents__doc_id__put": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "title": { + "type": "string", + "minLength": 1, + "pattern": "\\w", + "title": "Title" + }, + "source_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Type" + }, + "category_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "app_id": { + "type": "string", + "title": "App Id", + "default": "default" + }, + "project_id": { + "type": "string", + "title": "Project Id", + "default": "default" + } + }, + "type": "object", + "required": [ + "file", + "title" + ], + "title": "Body_replace_document_route_api_v2_knowledge_documents__doc_id__put" + }, "CategoryDTO": { "properties": { "category_id": { @@ -1819,7 +2685,7 @@ "memory_type" ], "title": "GetRequest", - "description": "Request body for ``POST /api/v1/memory/get``.\n\nCallers identify the memory owner via ``user_id`` XOR ``agent_id`` —\nexactly one must be set. Internally the manager keeps using\n``owner_id`` / ``owner_type`` (the storage tables' columns); those\nare exposed as derived properties so the rename only affects the\nwire contract." + "description": "Request body for ``POST /api/v2/memory/get``.\n\nCallers identify the memory owner via ``user_id`` XOR ``agent_id`` —\nexactly one must be set. Internally the manager keeps using\n``owner_id`` / ``owner_type`` (the storage tables' columns); those\nare exposed as derived properties so the rename only affects the\nwire contract." }, "GetResponse": { "properties": { @@ -2642,7 +3508,7 @@ "query" ], "title": "SearchRequest", - "description": "Request body for ``POST /api/v1/memory/search``.\n\nCallers identify the memory owner via ``user_id`` XOR ``agent_id`` —\nexactly one must be set. Internally the manager + compile_filters keep\nusing ``owner_id`` / ``owner_type`` (the storage tables' columns);\nthose are exposed as derived properties so the rename only affects\nthe wire contract, not the internal recall plumbing." + "description": "Request body for ``POST /api/v2/memory/search``.\n\nCallers identify the memory owner via ``user_id`` XOR ``agent_id`` —\nexactly one must be set. Internally the manager + compile_filters keep\nusing ``owner_id`` / ``owner_type`` (the storage tables' columns);\nthose are exposed as derived properties so the rename only affects\nthe wire contract, not the internal recall plumbing." }, "SearchResponse": { "properties": { @@ -3001,7 +3867,7 @@ "name" ], "title": "TriggerRequest", - "description": "Request body for ``POST /api/v1/ome/trigger``." + "description": "Request body for ``POST /api/v2/ome/trigger``." }, "TriggerResponse": { "properties": { @@ -3020,7 +3886,7 @@ "name" ], "title": "TriggerResponse", - "description": "Response body for ``POST /api/v1/ome/trigger``." + "description": "Response body for ``POST /api/v2/ome/trigger``." }, "UnprocessedMessageDTO": { "properties": { diff --git a/src/everos/core/middleware/prometheus.py b/src/everos/core/middleware/prometheus.py index 09dbadb..f0d85db 100644 --- a/src/everos/core/middleware/prometheus.py +++ b/src/everos/core/middleware/prometheus.py @@ -42,18 +42,27 @@ _SKIP_PATHS = frozenset({"/metrics", "/health", "/healthz", "/favicon.ico"}) def _normalize_path(request: Request) -> str: - """Resolve the route template (e.g. ``/users/{user_id}``) for stable labels.""" + """Resolve the full route template (e.g. ``/api/v1/users/{user_id}``). + + Built from the concrete request path with matched path params folded + back to ``{name}`` placeholders — NOT from ``route.path``. A route + mounted under a prefix (the ``/api/vN`` version aliases) only carries + its router-relative path on the route object (e.g. ``/memory/get``); + the prefix lives on the mounted router. Using ``route.path`` would drop + the version prefix and collapse v1/v2 traffic into one label, so the + label is rebuilt from ``url.path`` instead. Unmatched requests (no + ``route`` in scope) fold to a single bucket to bound cardinality. + """ scope = getattr(request, "scope", {}) route = scope.get("route") if isinstance(scope, dict) else None - if route is not None and hasattr(route, "path"): - return route.path - if request.path_params: - path = request.url.path - for name, value in request.path_params.items(): - if str(value) in path: - path = path.replace(str(value), f"{{{name}}}") - return path - return "{unmatched}" + if route is None: + return "{unmatched}" + path = request.url.path + for name, value in request.path_params.items(): + value_str = str(value) + if value_str: + path = path.replace(value_str, f"{{{name}}}") + return path class PrometheusMiddleware(BaseHTTPMiddleware): diff --git a/src/everos/entrypoints/api/app.py b/src/everos/entrypoints/api/app.py index 16c2464..3eae0cd 100644 --- a/src/everos/entrypoints/api/app.py +++ b/src/everos/entrypoints/api/app.py @@ -121,14 +121,28 @@ def create_app( # or handler runs, so all logs + the response header carry it. app.add_middleware(RequestIdMiddleware) - # Routes. + # Infra endpoints — deliberately unversioned. app.include_router(health.router) app.include_router(metrics.router) - app.include_router(memorize.router) - app.include_router(search.router) - app.include_router(get.router) - app.include_router(ome.router) - app.include_router(knowledge.router) + + # Business API — served under both /api/v2 (cloud-aligned name) and + # /api/v1 (retained as a permanent backward-compatible alias). The same + # router object is mounted twice, so both prefixes resolve to the exact + # same handlers; FastAPI's default operationId embeds the path, so the + # two copies get distinct OpenAPI ids automatically (no collision). + # v1 and v2 stay identical by construction — see test_api_versioning. + # v1 first — retained alias, behavior identical. + app.include_router(memorize.router, prefix="/api/v1") + app.include_router(search.router, prefix="/api/v1") + app.include_router(get.router, prefix="/api/v1") + app.include_router(ome.router, prefix="/api/v1") + app.include_router(knowledge.router, prefix="/api/v1") + # v2 — cloud-aligned name, same routers. + app.include_router(memorize.router, prefix="/api/v2") + app.include_router(search.router, prefix="/api/v2") + app.include_router(get.router, prefix="/api/v2") + app.include_router(ome.router, prefix="/api/v2") + app.include_router(knowledge.router, prefix="/api/v2") logger.info("app_created", docs_enabled=enable_docs) return app diff --git a/src/everos/entrypoints/api/routes/get.py b/src/everos/entrypoints/api/routes/get.py index cbd8562..049de73 100644 --- a/src/everos/entrypoints/api/routes/get.py +++ b/src/everos/entrypoints/api/routes/get.py @@ -1,4 +1,4 @@ -"""POST /api/v1/memory/get — paginated listing endpoint. +"""POST /api/v2/memory/get — paginated listing endpoint. Thin adapter: validate the request DTO, dispatch to the service layer, return the envelope verbatim. ``request_id`` is generated inside the @@ -12,7 +12,7 @@ from fastapi import APIRouter from everos.memory.get import GetRequest, GetResponse from everos.service import get as get_service -router = APIRouter(prefix="/api/v1/memory", tags=["memory"]) +router = APIRouter(prefix="/memory", tags=["memory"]) @router.post("/get", response_model=GetResponse) diff --git a/src/everos/entrypoints/api/routes/knowledge.py b/src/everos/entrypoints/api/routes/knowledge.py index 1b853ac..58e74f6 100644 --- a/src/everos/entrypoints/api/routes/knowledge.py +++ b/src/everos/entrypoints/api/routes/knowledge.py @@ -59,7 +59,7 @@ from everos.service import ( # a shared module would be cleaner but is out of scope for this PR. from .memorize import PathSafeId, SuccessEnvelope -router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) +router = APIRouter(prefix="/knowledge", tags=["knowledge"]) # ── Annotated param types (satisfies B008) ────────────────────────────────── diff --git a/src/everos/entrypoints/api/routes/memorize.py b/src/everos/entrypoints/api/routes/memorize.py index af901ae..2f5a750 100644 --- a/src/everos/entrypoints/api/routes/memorize.py +++ b/src/everos/entrypoints/api/routes/memorize.py @@ -1,4 +1,4 @@ -"""POST /api/v1/memory/add and /api/v1/memory/flush. +"""POST /api/v2/memory/add and /api/v2/memory/flush. DTOs follow the v1 API brief (01_v1_api_brief.md §2 / §3). Routes are thin adapters: validate the DTO, dump to dict, hand to service. No @@ -19,7 +19,7 @@ from pydantic import AfterValidator, BaseModel, ConfigDict, Field from everos.entrypoints.api.utils import extract_request_id from everos.service import memorize -router = APIRouter(prefix="/api/v1/memory", tags=["memory"]) +router = APIRouter(prefix="/memory", tags=["memory"]) # ── Path-safe identifier ──────────────────────────────────────────────────── diff --git a/src/everos/entrypoints/api/routes/ome.py b/src/everos/entrypoints/api/routes/ome.py index 72b4cd9..c4ac2a2 100644 --- a/src/everos/entrypoints/api/routes/ome.py +++ b/src/everos/entrypoints/api/routes/ome.py @@ -8,13 +8,13 @@ from pydantic import BaseModel from everos.core.errors import NotFoundError from everos.core.observability.logging import get_logger -router = APIRouter(prefix="/api/v1/ome", tags=["ome"]) +router = APIRouter(prefix="/ome", tags=["ome"]) logger = get_logger(__name__) class TriggerRequest(BaseModel): - """Request body for ``POST /api/v1/ome/trigger``.""" + """Request body for ``POST /api/v2/ome/trigger``.""" name: str timeout: float = 120.0 @@ -22,7 +22,7 @@ class TriggerRequest(BaseModel): class TriggerResponse(BaseModel): - """Response body for ``POST /api/v1/ome/trigger``.""" + """Response body for ``POST /api/v2/ome/trigger``.""" status: str name: str diff --git a/src/everos/entrypoints/api/routes/search.py b/src/everos/entrypoints/api/routes/search.py index 26be838..a171f7a 100644 --- a/src/everos/entrypoints/api/routes/search.py +++ b/src/everos/entrypoints/api/routes/search.py @@ -1,4 +1,4 @@ -"""POST /api/v1/memory/search — hybrid retrieval endpoint. +"""POST /api/v2/memory/search — hybrid retrieval endpoint. Thin adapter: validate the request DTO, dispatch to the service layer, return the envelope verbatim. ``request_id`` is generated inside the @@ -13,7 +13,7 @@ from fastapi import APIRouter from everos.memory.search import SearchRequest, SearchResponse from everos.service import search -router = APIRouter(prefix="/api/v1/memory", tags=["memory"]) +router = APIRouter(prefix="/memory", tags=["memory"]) @router.post("/search", response_model=SearchResponse) diff --git a/src/everos/memory/get/__init__.py b/src/everos/memory/get/__init__.py index ebb3b8d..53274ef 100644 --- a/src/everos/memory/get/__init__.py +++ b/src/everos/memory/get/__init__.py @@ -1,7 +1,7 @@ """memory.get — read path: paginated listing over LanceDB. This subpackage owns the dispatch + shape layer for ``POST -/api/v1/memory/get``. Unlike :mod:`memory.search`, /get does no +/api/v2/memory/get``. Unlike :mod:`memory.search`, /get does no ranking — it is a pure offset/limit + scalar-filter listing, partitioned by ``(owner_type, memory_type)``. diff --git a/src/everos/memory/get/dto.py b/src/everos/memory/get/dto.py index 8361672..df9804c 100644 --- a/src/everos/memory/get/dto.py +++ b/src/everos/memory/get/dto.py @@ -1,4 +1,4 @@ -"""Public DTOs for ``POST /api/v1/memory/get``. +"""Public DTOs for ``POST /api/v2/memory/get``. Contract per the final design (mirrors :mod:`memory.search.dto` shape, minus ``score`` because /get is a paginated listing rather than a @@ -54,7 +54,7 @@ class GetMemoryType(StrEnum): class GetRequest(BaseModel): - """Request body for ``POST /api/v1/memory/get``. + """Request body for ``POST /api/v2/memory/get``. Callers identify the memory owner via ``user_id`` XOR ``agent_id`` — exactly one must be set. Internally the manager keeps using diff --git a/src/everos/memory/get/manager.py b/src/everos/memory/get/manager.py index 472033c..c42edd7 100644 --- a/src/everos/memory/get/manager.py +++ b/src/everos/memory/get/manager.py @@ -1,4 +1,4 @@ -"""GetManager — top-level orchestrator for ``POST /api/v1/memory/get``. +"""GetManager — top-level orchestrator for ``POST /api/v2/memory/get``. Hard partition by ``(owner_type, memory_type)`` (validated by :class:`GetRequest`): diff --git a/src/everos/memory/search/__init__.py b/src/everos/memory/search/__init__.py index 86dbbb6..da2bef7 100644 --- a/src/everos/memory/search/__init__.py +++ b/src/everos/memory/search/__init__.py @@ -1,7 +1,7 @@ """memory.search — read path: hybrid retrieval over LanceDB. This subpackage owns the recall + adapter layer for ``POST -/api/v1/memory/search``. All fusion / rerank / agentic algorithms are +/api/v2/memory/search``. All fusion / rerank / agentic algorithms are delegated to :mod:`everalgo.rank`; this layer is responsible for: * compiling the Filters DSL into a LanceDB ``where`` string, diff --git a/src/everos/memory/search/dto.py b/src/everos/memory/search/dto.py index d825be5..53d37fa 100644 --- a/src/everos/memory/search/dto.py +++ b/src/everos/memory/search/dto.py @@ -1,4 +1,4 @@ -"""Public DTOs for ``POST /api/v1/memory/search``. +"""Public DTOs for ``POST /api/v2/memory/search``. Contract per the final design: @@ -56,7 +56,7 @@ class FilterNode(BaseModel): class SearchRequest(BaseModel): - """Request body for ``POST /api/v1/memory/search``. + """Request body for ``POST /api/v2/memory/search``. Callers identify the memory owner via ``user_id`` XOR ``agent_id`` — exactly one must be set. Internally the manager + compile_filters keep diff --git a/src/everos/memory/search/manager.py b/src/everos/memory/search/manager.py index 3a5fc70..b06e046 100644 --- a/src/everos/memory/search/manager.py +++ b/src/everos/memory/search/manager.py @@ -1,4 +1,4 @@ -"""SearchManager — top-level orchestrator for ``POST /api/v1/memory/search``. +"""SearchManager — top-level orchestrator for ``POST /api/v2/memory/search``. Hard partition by ``owner_type``: diff --git a/src/everos/service/get.py b/src/everos/service/get.py index 9b9a7e6..6d5a72b 100644 --- a/src/everos/service/get.py +++ b/src/everos/service/get.py @@ -1,4 +1,4 @@ -"""Get use case — lazy singleton wiring for ``POST /api/v1/memory/get``. +"""Get use case — lazy singleton wiring for ``POST /api/v2/memory/get``. Mirrors :mod:`everos.service.search`: the :class:`GetManager` and its LanceDB repo singletons are built on first call so the FastAPI module diff --git a/src/everos/service/memorize.py b/src/everos/service/memorize.py index 03d455d..d65fdfe 100644 --- a/src/everos/service/memorize.py +++ b/src/everos/service/memorize.py @@ -2,7 +2,7 @@ End-to-end orchestration: - POST /api/v1/memory/add { session_id, messages[] } + POST /api/v2/memory/add { session_id, messages[] } → ingest.process → IngestResult → _boundary.prepare_cells(mode=settings.memorize.mode) → cells → asyncio.gather( diff --git a/tests/unit/test_core/test_middleware/test_prometheus.py b/tests/unit/test_core/test_middleware/test_prometheus.py index adcf664..11534cb 100644 --- a/tests/unit/test_core/test_middleware/test_prometheus.py +++ b/tests/unit/test_core/test_middleware/test_prometheus.py @@ -119,8 +119,32 @@ async def test_path_params_normalized(client: AsyncClient) -> None: # ── _normalize_path direct tests (defensive fallback branches) ───────── -def test_normalize_path_uses_path_params_fallback() -> None: - """When scope has no ``route`` but ``path_params`` is set, substitute names.""" +def test_normalize_path_uses_full_request_path_not_route_path() -> None: + """Label is built from the full request path (keeps the mount prefix) with + path params folded to ``{name}`` — NOT from the route's router-relative + path. A versioned alias mounts the router under ``/api/vN``, so the route + object only carries ``/memory/{id}``; the label must keep the prefix. + """ + from types import SimpleNamespace + + from everos.core.middleware.prometheus import _normalize_path + + fake_req = SimpleNamespace( + scope={"route": SimpleNamespace(path="/memory/{id}")}, + url=SimpleNamespace(path="/api/v1/memory/abc"), + path_params={"id": "abc"}, + ) + # type: ignore[arg-type] — helper accepts anything duck-typed. + assert _normalize_path(fake_req) == "/api/v1/memory/{id}" # type: ignore[arg-type] + + +def test_normalize_path_unmatched_even_with_path_params() -> None: + """No matched ``route`` in scope → ``{unmatched}``, even if params are set. + + ``path_params`` is only populated by route matching, so their presence + without a route means no real match — we do not trust them to build a + label (that would risk unbounded cardinality from unmatched paths). + """ from types import SimpleNamespace from everos.core.middleware.prometheus import _normalize_path @@ -130,8 +154,7 @@ def test_normalize_path_uses_path_params_fallback() -> None: url=SimpleNamespace(path="/x/abc/y"), path_params={"id": "abc"}, ) - # type: ignore[arg-type] — helper accepts anything duck-typed. - assert _normalize_path(fake_req) == "/x/{id}/y" # type: ignore[arg-type] + assert _normalize_path(fake_req) == "{unmatched}" # type: ignore[arg-type] def test_normalize_path_unmatched_fallback() -> None: diff --git a/tests/unit/test_entrypoints/test_api/test_api_versioning.py b/tests/unit/test_entrypoints/test_api/test_api_versioning.py new file mode 100644 index 0000000..da67d90 --- /dev/null +++ b/tests/unit/test_entrypoints/test_api/test_api_versioning.py @@ -0,0 +1,68 @@ +"""API version aliasing — every business route is served under v1 and v2. + +The ``/api/v2`` prefix is the cloud-aligned name; ``/api/v1`` is retained as a +permanent backward-compatible alias pointing to the *same* endpoint. These +tests are the completeness guard: they fail if any versioned route is exposed +under one prefix but not the other, or if the two prefixes ever diverge to +different handlers. Infrastructure endpoints (``/health``, ``/metrics``) are +deliberately unversioned and must NOT be mirrored. + +Assertions run against ``app.openapi()["paths"]`` — the authoritative, +fully-resolved public surface — rather than ``app.routes`` (which FastAPI keeps +as lazy ``_IncludedRouter`` wrappers, so leaf paths are not directly readable). +Same-handler identity is proved via the operationId, which FastAPI derives from +the endpoint function name + path: twin routes must share an operationId that +differs only by the version segment. +""" + +from __future__ import annotations + +from everos.entrypoints.api.app import create_app + +_V1 = "/api/v1/" +_V2 = "/api/v2/" + + +def _openapi_paths() -> dict[str, dict]: + app = create_app(lifespan_providers=[]) + return app.openapi()["paths"] + + +def test_every_v1_route_has_identical_v2_twin() -> None: + paths = _openapi_paths() + v1 = {p: ops for p, ops in paths.items() if p.startswith(_V1)} + assert v1, "expected at least one /api/v1 route" + + for path, ops in v1.items(): + twin = _V2 + path[len(_V1) :] + assert twin in paths, f"{path} has no v2 twin at {twin}" + assert set(paths[twin]) == set(ops), f"{twin} verbs differ from {path}" + # Same handler: operationId differs only by the version token. + for method, op in ops.items(): + v1_id = op["operationId"] + v2_id = paths[twin][method]["operationId"] + assert v1_id.replace("_v1_", "_v2_") == v2_id, ( + f"{twin} [{method}] resolves to a different handler: " + f"{v1_id!r} vs {v2_id!r}" + ) + + +def test_every_v2_route_has_v1_twin() -> None: + paths = _openapi_paths() + v2 = {p for p in paths if p.startswith(_V2)} + assert v2, "expected at least one /api/v2 route" + + for path in v2: + twin = _V1 + path[len(_V2) :] + assert twin in paths, f"{path} has no v1 twin at {twin}" + + +def test_infra_endpoints_are_not_versioned() -> None: + paths = set(_openapi_paths()) + assert "/health" in paths + assert "/metrics" in paths + # No accidental versioned mirror of infra endpoints. + assert "/api/v1/health" not in paths + assert "/api/v2/health" not in paths + assert "/api/v1/metrics" not in paths + assert "/api/v2/metrics" not in paths diff --git a/tests/unit/test_entrypoints/test_api/test_routes/test_metrics_route.py b/tests/unit/test_entrypoints/test_api/test_routes/test_metrics_route.py index abeff4b..4e8378f 100644 --- a/tests/unit/test_entrypoints/test_api/test_routes/test_metrics_route.py +++ b/tests/unit/test_entrypoints/test_api/test_routes/test_metrics_route.py @@ -112,6 +112,26 @@ async def test_metrics_counter_increments_on_request(client: AsyncClient) -> Non assert after - before == 1.0, f"counter not bumped: {before} → {after}" +async def test_v1_and_v2_recorded_under_distinct_path_labels( + client: AsyncClient, +) -> None: + """v1 and v2 alias hits must NOT collapse into one metric label. + + Both prefixes resolve to the same handler, but the ``path`` label must + keep the ``/api/vN`` prefix so existing dashboards keep working and + per-version traffic stays distinguishable. (Regression guard: the leaf + route only carries its router-relative path, so the label must be built + from the full request path, not ``route.path``.) + """ + await client.post("/api/v1/memory/get", json={}) + await client.post("/api/v2/memory/get", json={}) + + dump = (await client.get("/metrics")).text + recorded = _all_recorded_paths(dump) + assert "/api/v1/memory/get" in recorded, recorded + assert "/api/v2/memory/get" in recorded, recorded + + async def test_metrics_skip_paths_not_recorded(client: AsyncClient) -> None: """``_SKIP_PATHS`` (``/metrics``, ``/health``) never appear in the counter.""" # Hit both endpoints. If they were *not* skipped, they'd show up in