Merge pull request #356 from EverMind-AI/feat/api-v2-alias

feat(api): serve endpoints under /api/v2, retain /api/v1 as alias
This commit is contained in:
zhanghui 2026-07-24 15:29:24 +08:00 committed by GitHub
commit a59d385d23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1092 additions and 76 deletions

View File

@ -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

View File

@ -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 `"<msg>: <dotted-loc>"` with the leading `body` segment stripped (e.g. `"Field required: messages"`); a model-level validator with no field location surfaces just `"<msg>"` (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 |
---

View File

@ -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": {

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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) ──────────────────────────────────

View File

@ -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 ────────────────────────────────────────────────────

View File

@ -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

View File

@ -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)

View File

@ -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)``.

View File

@ -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

View File

@ -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`):

View File

@ -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,

View File

@ -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

View File

@ -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``:

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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

View File

@ -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