Skip to content

Commit 9aa6310

Browse files
authored
✨ Supports northbound apis service
2 parents 0d15d99 + 07fa280 commit 9aa6310

32 files changed

+2168
-149
lines changed

backend/apps/agent_app.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def search_agent_info_api(agent_id: int = Body(...), authorization: Option
4646
"""
4747
try:
4848
_, tenant_id = get_current_user_id(authorization)
49-
return get_agent_info_impl(agent_id, tenant_id)
49+
return await get_agent_info_impl(agent_id, tenant_id)
5050
except Exception as e:
5151
raise HTTPException(status_code=500, detail=f"Agent search info error: {str(e)}")
5252

@@ -57,7 +57,7 @@ async def get_creating_sub_agent_info_api(authorization: Optional[str] = Header(
5757
Create a new sub agent, return agent_ID
5858
"""
5959
try:
60-
return get_creating_sub_agent_info_impl(authorization)
60+
return await get_creating_sub_agent_info_impl(authorization)
6161
except Exception as e:
6262
raise HTTPException(status_code=500, detail=f"Agent create error: {str(e)}")
6363

@@ -68,7 +68,7 @@ async def update_agent_info_api(request: AgentInfoRequest, authorization: Option
6868
Update an existing agent
6969
"""
7070
try:
71-
update_agent_info_impl(request, authorization)
71+
await update_agent_info_impl(request, authorization)
7272
return {}
7373
except Exception as e:
7474
raise HTTPException(status_code=500, detail=f"Agent update error: {str(e)}")
@@ -115,8 +115,8 @@ async def list_all_agent_info_api(authorization: Optional[str] = Header(None), r
115115
list all agent info
116116
"""
117117
try:
118-
user_id, tenant_id, _ = get_current_user_info(authorization, request)
119-
return list_all_agent_info_impl(tenant_id=tenant_id, user_id=user_id)
118+
_, tenant_id, _ = get_current_user_info(authorization, request)
119+
return await list_all_agent_info_impl(tenant_id=tenant_id)
120120
except Exception as e:
121121
raise HTTPException(status_code=500, detail=f"Agent list error: {str(e)}")
122122

backend/apps/northbound_app.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import logging
2+
from http import HTTPStatus
3+
from http.client import HTTPException
4+
from typing import Optional, Dict
5+
import uuid
6+
7+
from fastapi import APIRouter, Body, Header, Request
8+
from fastapi.responses import JSONResponse
9+
10+
from consts.exceptions import UnauthorizedError, LimitExceededError, SignatureValidationError
11+
from services.northbound_service import (
12+
NorthboundContext,
13+
get_conversation_history,
14+
list_conversations,
15+
start_streaming_chat,
16+
stop_chat,
17+
get_agent_info_list,
18+
update_conversation_title
19+
)
20+
21+
from utils.auth_utils import get_current_user_id, validate_aksk_authentication
22+
23+
24+
router = APIRouter(prefix="/nb/v1", tags=["northbound"])
25+
26+
27+
def _get_header(headers: Dict[str, str], name: str) -> Optional[str]:
28+
for k, v in headers.items():
29+
if k.lower() == name.lower():
30+
return v
31+
return None
32+
33+
34+
async def _parse_northbound_context(request: Request) -> NorthboundContext:
35+
"""
36+
Build northbound context from headers.
37+
38+
- X-Access-Key: Access key for AK/SK authentication
39+
- X-Timestamp: Timestamp for signature validation
40+
- X-Signature: HMAC-SHA256 signature signed with secret key
41+
- Authorization: Bearer <jwt>, jwt contains sub (user_id)
42+
- X-Request-Id: optional, generated if not provided
43+
"""
44+
# 1. Verify AK/SK signature
45+
try:
46+
# Get request body for signature verification
47+
request_body = ""
48+
if request.method in ["POST", "PUT", "PATCH"]:
49+
try:
50+
body_bytes = await request.body()
51+
request_body = body_bytes.decode('utf-8') if body_bytes else ""
52+
except Exception as e:
53+
logging.warning(f"Cannot read request body for signature verification: {e}")
54+
request_body = ""
55+
56+
validate_aksk_authentication(request.headers, request_body)
57+
except (UnauthorizedError, LimitExceededError, SignatureValidationError) as e:
58+
raise e
59+
except Exception:
60+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error: cannot parse northbound context")
61+
62+
63+
# 2. Parse JWT token
64+
auth_header = _get_header(request.headers, "Authorization")
65+
if not auth_header:
66+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: No authorization header found")
67+
68+
# Use auth_utils to parse JWT token
69+
try:
70+
user_id, tenant_id = get_current_user_id(auth_header)
71+
72+
if not user_id:
73+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: missing user_id in JWT token")
74+
if not tenant_id:
75+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: unregistered user_id in JWT token")
76+
77+
except Exception:
78+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error: cannot parse JWT token")
79+
80+
request_id = _get_header(request.headers, "X-Request-Id") or str(uuid.uuid4())
81+
82+
return NorthboundContext(
83+
request_id=request_id,
84+
tenant_id=tenant_id,
85+
user_id=str(user_id),
86+
authorization=auth_header,
87+
)
88+
89+
90+
@router.get("/health")
91+
async def health_check():
92+
return {"status": "healthy", "service": "northbound-api"}
93+
94+
95+
@router.post("/chat/run")
96+
async def run_chat(
97+
request: Request,
98+
conversation_id: str = Body(..., embed=True),
99+
agent_name: str = Body(..., embed=True),
100+
query: str = Body(..., embed=True),
101+
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
102+
):
103+
try:
104+
ctx: NorthboundContext = await _parse_northbound_context(request)
105+
return await start_streaming_chat(
106+
ctx=ctx,
107+
external_conversation_id=conversation_id,
108+
agent_name=agent_name,
109+
query=query,
110+
idempotency_key=idempotency_key,
111+
)
112+
except UnauthorizedError:
113+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
114+
except LimitExceededError:
115+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
116+
except SignatureValidationError:
117+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
118+
except Exception:
119+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
120+
121+
122+
123+
@router.get("/chat/stop/{conversation_id}")
124+
async def stop_chat_stream(request: Request, conversation_id: str):
125+
try:
126+
ctx: NorthboundContext = await _parse_northbound_context(request)
127+
return await stop_chat(ctx=ctx, external_conversation_id=conversation_id)
128+
except UnauthorizedError:
129+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
130+
except LimitExceededError:
131+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
132+
except SignatureValidationError:
133+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
134+
except Exception:
135+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
136+
137+
138+
@router.get("/conversations/{conversation_id}")
139+
async def get_history(request: Request, conversation_id: str):
140+
try:
141+
ctx: NorthboundContext = await _parse_northbound_context(request)
142+
return await get_conversation_history(ctx=ctx, external_conversation_id=conversation_id)
143+
except UnauthorizedError:
144+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
145+
except LimitExceededError:
146+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
147+
except SignatureValidationError:
148+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
149+
except Exception:
150+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
151+
152+
153+
@router.get("/agents")
154+
async def list_agents(request: Request):
155+
try:
156+
ctx: NorthboundContext = await _parse_northbound_context(request)
157+
return await get_agent_info_list(ctx=ctx)
158+
except UnauthorizedError:
159+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
160+
except LimitExceededError:
161+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
162+
except SignatureValidationError:
163+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
164+
except Exception:
165+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
166+
167+
168+
@router.get("/conversations")
169+
async def list_convs(request: Request):
170+
try:
171+
ctx: NorthboundContext = await _parse_northbound_context(request)
172+
return await list_conversations(ctx=ctx)
173+
except UnauthorizedError:
174+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
175+
except LimitExceededError:
176+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
177+
except SignatureValidationError:
178+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
179+
except Exception:
180+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
181+
182+
183+
@router.put("/conversations/{conversation_id}/title")
184+
async def update_convs_title(
185+
request: Request,
186+
conversation_id: str,
187+
title: str,
188+
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
189+
):
190+
try:
191+
ctx: NorthboundContext = await _parse_northbound_context(request)
192+
result = await update_conversation_title(
193+
ctx=ctx,
194+
external_conversation_id=conversation_id,
195+
title=title,
196+
idempotency_key=idempotency_key,
197+
)
198+
headers_out = {"Idempotency-Key": result.get("idempotency_key", ""), "X-Request-Id": ctx.request_id}
199+
return JSONResponse(content=result, headers=headers_out)
200+
201+
except UnauthorizedError:
202+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: AK/SK authentication failed")
203+
except LimitExceededError:
204+
raise HTTPException(status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Too Many Requests: rate limit exceeded")
205+
except SignatureValidationError:
206+
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized: invalid signature")
207+
except Exception:
208+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Internal Server Error")
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from http import HTTPStatus
2+
import logging
3+
from fastapi import FastAPI, HTTPException
4+
from fastapi.responses import JSONResponse
5+
from fastapi.middleware.cors import CORSMiddleware
6+
7+
from .northbound_app import router as northbound_router
8+
from consts.exceptions import LimitExceededError, UnauthorizedError, SignatureValidationError
9+
10+
logger = logging.getLogger("northbound_base_app")
11+
12+
13+
northbound_app = FastAPI(
14+
title="Nexent Northbound API",
15+
description="Northbound APIs for partners",
16+
version="1.0.0",
17+
root_path="/api"
18+
)
19+
20+
northbound_app.add_middleware(
21+
CORSMiddleware,
22+
allow_origins=["*"],
23+
allow_credentials=True,
24+
allow_methods=["GET", "POST", "PUT", "DELETE"],
25+
allow_headers=["*"],
26+
)
27+
28+
29+
northbound_app.include_router(northbound_router)
30+
31+
32+
@northbound_app.exception_handler(HTTPException)
33+
async def northbound_http_exception_handler(request, exc):
34+
logger.error(f"Northbound HTTPException: {exc.detail}")
35+
return JSONResponse(
36+
status_code=exc.status_code,
37+
content={"message": exc.detail},
38+
)
39+
40+
@northbound_app.exception_handler(Exception)
41+
async def northbound_generic_exception_handler(request, exc):
42+
logger.error(f"Northbound Generic Exception: {exc}")
43+
return JSONResponse(
44+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
45+
content={"message": "Internal server error, please try again later."},
46+
)

backend/apps/user_management_app.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from consts.const import SUPABASE_URL, SUPABASE_KEY
1010
from consts.model import STATUS_CODES, ServiceResponse, UserSignUpRequest, UserSignInRequest
1111
from database.model_management_db import create_model_record
12-
from utils.auth_utils import get_jwt_expiry_seconds, calculate_expires_at
12+
from utils.auth_utils import get_jwt_expiry_seconds, calculate_expires_at, get_current_user_id
1313
from database.user_tenant_db import insert_user_tenant
1414
from utils.config_utils import config_manager
1515

@@ -550,8 +550,7 @@ async def get_user_id(request: Request):
550550

551551
# If the token is invalid, try to parse the user ID from the token
552552
try:
553-
from utils.auth_utils import get_current_user_id_from_token
554-
user_id = get_current_user_id_from_token(authorization)
553+
user_id, _ = get_current_user_id(authorization)
555554
if user_id:
556555
logging.info(f"Successfully parsed user ID from token: {user_id}")
557556
return ServiceResponse(

backend/consts/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Northbound customize error class
2+
class LimitExceededError(Exception):
3+
"""Raised when an outer platform calling too frequently"""
4+
pass
5+
6+
class UnauthorizedError(Exception):
7+
"""Raised when a user from outer platform is unauthorized."""
8+
pass
9+
10+
class SignatureValidationError(Exception):
11+
"""Raised when X-Signature header is missing or does not match the expected HMAC value."""
12+
pass

backend/consts/model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,4 @@ class MemoryAgentShareMode(str, Enum):
378378

379379
@classmethod
380380
def default(cls) -> "MemoryAgentShareMode":
381-
return cls.NEVER
381+
return cls.NEVER

backend/database/conversation_db.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ def get_conversation_history(conversation_id: int, user_id: Optional[str] = None
484484
).where(
485485
ConversationMessageUnit.message_id == ConversationMessage.message_id,
486486
ConversationMessageUnit.delete_flag == 'N',
487-
ConversationMessageUnit.unit_type != None
487+
ConversationMessageUnit.unit_type is not None
488488
).scalar_subquery()
489489

490490
query = select(

backend/database/db_models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,24 @@ class AgentRelation(TableBase):
305305
update_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Update time, audit field")
306306
created_by = Column(String(100), doc="Creator ID, audit field")
307307
updated_by = Column(String(100), doc="Last updater ID, audit field")
308+
delete_flag = Column(String(1), default="N", doc="Delete flag, set to Y for soft delete, optional values Y/N")
309+
310+
311+
class PartnerMappingId(TableBase):
312+
"""
313+
External-Internal ID mapping table for partners
314+
"""
315+
__tablename__ = "partner_mapping_id_t"
316+
__table_args__ = {"schema": SCHEMA}
317+
318+
mapping_id = Column(Integer, Sequence("partner_mapping_id_t_mapping_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="ID")
319+
external_id = Column(String(100), doc="The external id given by the outer partner")
320+
internal_id = Column(Integer, doc="The internal id of the other database table")
321+
mapping_type = Column(String(30), doc="Type of the external - internal mapping, value set: CONVERSATION")
322+
tenant_id = Column(String(100), doc="Tenant ID")
323+
user_id = Column(String(100), doc="User ID")
324+
create_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Creation time, audit field")
325+
update_time = Column(TIMESTAMP(timezone=False), server_default=func.now(), doc="Update time, audit field")
326+
created_by = Column(String(100), doc="Creator ID, audit field")
327+
updated_by = Column(String(100), doc="Last updater ID, audit field")
308328
delete_flag = Column(String(1), default="N", doc="Delete flag, set to Y for soft delete, optional values Y/N")

0 commit comments

Comments
 (0)