remdb 0.3.114__py3-none-any.whl → 0.3.172__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
@@ -15,6 +15,9 @@ Available Tools:
15
15
  - ask_rem_agent: Natural language to REM query conversion via agent
16
16
  - ingest_into_rem: Full file ingestion pipeline (read + store + parse + chunk)
17
17
  - read_resource: Access MCP resources (for Claude Desktop compatibility)
18
+ - register_metadata: Register response metadata for SSE MetadataEvent
19
+ - list_schema: List all schemas (tables, agents) in the database with row counts
20
+ - get_schema: Get detailed schema for a table (columns, types, indexes)
18
21
  """
19
22
 
20
23
  from functools import wraps
@@ -113,7 +116,8 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
113
116
  # Otherwise wrap in success response
114
117
  return {"status": "success", **result}
115
118
  except Exception as e:
116
- logger.error(f"{func.__name__} failed: {e}", exc_info=True)
119
+ # Use %s format to avoid issues with curly braces in error messages
120
+ logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
117
121
  return {
118
122
  "status": "error",
119
123
  "error": str(e),
@@ -377,9 +381,10 @@ async def ask_rem_agent(
377
381
  from ...utils.schema_loader import load_agent_schema
378
382
 
379
383
  # Create agent context
384
+ # Note: tenant_id defaults to "default" if user_id is None
380
385
  context = AgentContext(
381
386
  user_id=user_id,
382
- tenant_id=user_id, # Set tenant_id to user_id for backward compat
387
+ tenant_id=user_id or "default", # Use default tenant for anonymous users
383
388
  default_model=settings.llm.default_model,
384
389
  )
385
390
 
@@ -603,7 +608,9 @@ async def register_metadata(
603
608
  references: list[str] | None = None,
604
609
  sources: list[str] | None = None,
605
610
  flags: list[str] | None = None,
606
- # Risk assessment fields (used by mental health agents like Siggy)
611
+ # Session naming
612
+ session_name: str | None = None,
613
+ # Risk assessment fields (used by specialized agents)
607
614
  risk_level: str | None = None,
608
615
  risk_score: int | None = None,
609
616
  risk_reasoning: str | None = None,
@@ -636,6 +643,11 @@ async def register_metadata(
636
643
  flags: Optional flags for the response (e.g., "needs_review",
637
644
  "uncertain", "incomplete", "crisis_alert").
638
645
 
646
+ session_name: Short 1-3 phrase name describing the session topic.
647
+ Used by the UI to label conversations in the sidebar.
648
+ Examples: "Prescription Drug Questions", "AWS Setup Help",
649
+ "Python Code Review", "Travel Planning".
650
+
639
651
  risk_level: Risk level indicator (e.g., "green", "orange", "red").
640
652
  Used by mental health agents for C-SSRS style assessment.
641
653
  risk_score: Numeric risk score (e.g., 0-6 for C-SSRS).
@@ -660,7 +672,7 @@ async def register_metadata(
660
672
  sources=["REM database lookup"]
661
673
  )
662
674
 
663
- # Mental health risk assessment (Siggy-style)
675
+ # Risk assessment example
664
676
  register_metadata(
665
677
  confidence=0.9,
666
678
  risk_level="green",
@@ -703,6 +715,10 @@ async def register_metadata(
703
715
  "flags": flags,
704
716
  }
705
717
 
718
+ # Add session name if provided
719
+ if session_name is not None:
720
+ result["session_name"] = session_name
721
+
706
722
  # Add risk assessment fields if provided
707
723
  if risk_level is not None:
708
724
  result["risk_level"] = risk_level
@@ -718,3 +734,401 @@ async def register_metadata(
718
734
  result["extra"] = extra
719
735
 
720
736
  return result
737
+
738
+
739
+ @mcp_tool_error_handler
740
+ async def list_schema(
741
+ include_system: bool = False,
742
+ user_id: str | None = None,
743
+ ) -> dict[str, Any]:
744
+ """
745
+ List all schemas (tables) in the REM database.
746
+
747
+ Returns metadata about all available tables including their names,
748
+ row counts, and descriptions. Use this to discover what data is
749
+ available before constructing queries.
750
+
751
+ Args:
752
+ include_system: If True, include PostgreSQL system tables (pg_*, information_schema).
753
+ Default False shows only REM application tables.
754
+ user_id: Optional user identifier (defaults to authenticated user or "default")
755
+
756
+ Returns:
757
+ Dict with:
758
+ - status: "success" or "error"
759
+ - tables: List of table metadata dicts with:
760
+ - name: Table name
761
+ - schema: Schema name (usually "public")
762
+ - estimated_rows: Approximate row count
763
+ - description: Table comment if available
764
+
765
+ Examples:
766
+ # List all REM schemas
767
+ list_schema()
768
+
769
+ # Include system tables
770
+ list_schema(include_system=True)
771
+ """
772
+ rem_service = await get_rem_service()
773
+ user_id = AgentContext.get_user_id_or_default(user_id, source="list_schema")
774
+
775
+ # Query information_schema for tables
776
+ schema_filter = ""
777
+ if not include_system:
778
+ schema_filter = """
779
+ AND table_schema = 'public'
780
+ AND table_name NOT LIKE 'pg_%'
781
+ AND table_name NOT LIKE '_pg_%'
782
+ """
783
+
784
+ query = f"""
785
+ SELECT
786
+ t.table_schema,
787
+ t.table_name,
788
+ pg_catalog.obj_description(
789
+ (quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass,
790
+ 'pg_class'
791
+ ) as description,
792
+ (
793
+ SELECT reltuples::bigint
794
+ FROM pg_class c
795
+ JOIN pg_namespace n ON n.oid = c.relnamespace
796
+ WHERE c.relname = t.table_name
797
+ AND n.nspname = t.table_schema
798
+ ) as estimated_rows
799
+ FROM information_schema.tables t
800
+ WHERE t.table_type = 'BASE TABLE'
801
+ {schema_filter}
802
+ ORDER BY t.table_schema, t.table_name
803
+ """
804
+
805
+ # Access postgres service directly from cache
806
+ postgres_service = _service_cache.get("postgres")
807
+ if not postgres_service:
808
+ postgres_service = rem_service._postgres
809
+
810
+ rows = await postgres_service.fetch(query)
811
+
812
+ tables = []
813
+ for row in rows:
814
+ tables.append({
815
+ "name": row["table_name"],
816
+ "schema": row["table_schema"],
817
+ "estimated_rows": int(row["estimated_rows"]) if row["estimated_rows"] else 0,
818
+ "description": row["description"],
819
+ })
820
+
821
+ logger.info(f"Listed {len(tables)} schemas for user {user_id}")
822
+
823
+ return {
824
+ "tables": tables,
825
+ "count": len(tables),
826
+ }
827
+
828
+
829
+ @mcp_tool_error_handler
830
+ async def get_schema(
831
+ table_name: str,
832
+ include_indexes: bool = True,
833
+ include_constraints: bool = True,
834
+ columns: list[str] | None = None,
835
+ user_id: str | None = None,
836
+ ) -> dict[str, Any]:
837
+ """
838
+ Get detailed schema information for a specific table.
839
+
840
+ Returns column definitions, data types, constraints, and indexes.
841
+ Use this to understand table structure before writing SQL queries.
842
+
843
+ Args:
844
+ table_name: Name of the table to inspect (e.g., "resources", "moments")
845
+ include_indexes: Include index information (default True)
846
+ include_constraints: Include constraint information (default True)
847
+ columns: Optional list of specific columns to return. If None, returns all columns.
848
+ user_id: Optional user identifier (defaults to authenticated user or "default")
849
+
850
+ Returns:
851
+ Dict with:
852
+ - status: "success" or "error"
853
+ - table_name: Name of the table
854
+ - columns: List of column definitions with:
855
+ - name: Column name
856
+ - type: PostgreSQL data type
857
+ - nullable: Whether NULL is allowed
858
+ - default: Default value if any
859
+ - description: Column comment if available
860
+ - indexes: List of indexes (if include_indexes=True)
861
+ - constraints: List of constraints (if include_constraints=True)
862
+ - primary_key: Primary key column(s)
863
+
864
+ Examples:
865
+ # Get full schema for resources table
866
+ get_schema(table_name="resources")
867
+
868
+ # Get only specific columns
869
+ get_schema(
870
+ table_name="resources",
871
+ columns=["id", "name", "created_at"]
872
+ )
873
+
874
+ # Get schema without indexes
875
+ get_schema(
876
+ table_name="moments",
877
+ include_indexes=False
878
+ )
879
+ """
880
+ rem_service = await get_rem_service()
881
+ user_id = AgentContext.get_user_id_or_default(user_id, source="get_schema")
882
+
883
+ # Access postgres service
884
+ postgres_service = _service_cache.get("postgres")
885
+ if not postgres_service:
886
+ postgres_service = rem_service._postgres
887
+
888
+ # Verify table exists
889
+ exists_query = """
890
+ SELECT EXISTS (
891
+ SELECT 1 FROM information_schema.tables
892
+ WHERE table_schema = 'public' AND table_name = $1
893
+ )
894
+ """
895
+ exists = await postgres_service.fetchval(exists_query, table_name)
896
+ if not exists:
897
+ return {
898
+ "status": "error",
899
+ "error": f"Table '{table_name}' not found in public schema",
900
+ }
901
+
902
+ # Get columns
903
+ columns_filter = ""
904
+ if columns:
905
+ placeholders = ", ".join(f"${i+2}" for i in range(len(columns)))
906
+ columns_filter = f"AND column_name IN ({placeholders})"
907
+
908
+ columns_query = f"""
909
+ SELECT
910
+ c.column_name,
911
+ c.data_type,
912
+ c.udt_name,
913
+ c.is_nullable,
914
+ c.column_default,
915
+ c.character_maximum_length,
916
+ c.numeric_precision,
917
+ pg_catalog.col_description(
918
+ (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
919
+ c.ordinal_position
920
+ ) as description
921
+ FROM information_schema.columns c
922
+ WHERE c.table_schema = 'public'
923
+ AND c.table_name = $1
924
+ {columns_filter}
925
+ ORDER BY c.ordinal_position
926
+ """
927
+
928
+ params = [table_name]
929
+ if columns:
930
+ params.extend(columns)
931
+
932
+ column_rows = await postgres_service.fetch(columns_query, *params)
933
+
934
+ column_defs = []
935
+ for row in column_rows:
936
+ # Build a more readable type string
937
+ data_type = row["data_type"]
938
+ if row["character_maximum_length"]:
939
+ data_type = f"{data_type}({row['character_maximum_length']})"
940
+ elif row["udt_name"] in ("int4", "int8", "float4", "float8"):
941
+ # Use common type names
942
+ type_map = {"int4": "integer", "int8": "bigint", "float4": "real", "float8": "double precision"}
943
+ data_type = type_map.get(row["udt_name"], data_type)
944
+ elif row["udt_name"] == "vector":
945
+ data_type = "vector"
946
+
947
+ column_defs.append({
948
+ "name": row["column_name"],
949
+ "type": data_type,
950
+ "nullable": row["is_nullable"] == "YES",
951
+ "default": row["column_default"],
952
+ "description": row["description"],
953
+ })
954
+
955
+ result = {
956
+ "table_name": table_name,
957
+ "columns": column_defs,
958
+ "column_count": len(column_defs),
959
+ }
960
+
961
+ # Get primary key
962
+ pk_query = """
963
+ SELECT a.attname as column_name
964
+ FROM pg_index i
965
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
966
+ WHERE i.indrelid = $1::regclass
967
+ AND i.indisprimary
968
+ ORDER BY array_position(i.indkey, a.attnum)
969
+ """
970
+ pk_rows = await postgres_service.fetch(pk_query, table_name)
971
+ result["primary_key"] = [row["column_name"] for row in pk_rows]
972
+
973
+ # Get indexes
974
+ if include_indexes:
975
+ indexes_query = """
976
+ SELECT
977
+ i.relname as index_name,
978
+ am.amname as index_type,
979
+ ix.indisunique as is_unique,
980
+ ix.indisprimary as is_primary,
981
+ array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
982
+ FROM pg_index ix
983
+ JOIN pg_class i ON i.oid = ix.indexrelid
984
+ JOIN pg_class t ON t.oid = ix.indrelid
985
+ JOIN pg_am am ON am.oid = i.relam
986
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
987
+ WHERE t.relname = $1
988
+ GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary
989
+ ORDER BY i.relname
990
+ """
991
+ index_rows = await postgres_service.fetch(indexes_query, table_name)
992
+ result["indexes"] = [
993
+ {
994
+ "name": row["index_name"],
995
+ "type": row["index_type"],
996
+ "unique": row["is_unique"],
997
+ "primary": row["is_primary"],
998
+ "columns": row["columns"],
999
+ }
1000
+ for row in index_rows
1001
+ ]
1002
+
1003
+ # Get constraints
1004
+ if include_constraints:
1005
+ constraints_query = """
1006
+ SELECT
1007
+ con.conname as constraint_name,
1008
+ con.contype as constraint_type,
1009
+ array_agg(a.attname ORDER BY array_position(con.conkey, a.attnum)) as columns,
1010
+ pg_get_constraintdef(con.oid) as definition
1011
+ FROM pg_constraint con
1012
+ JOIN pg_class t ON t.oid = con.conrelid
1013
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(con.conkey)
1014
+ WHERE t.relname = $1
1015
+ GROUP BY con.conname, con.contype, con.oid
1016
+ ORDER BY con.contype, con.conname
1017
+ """
1018
+ constraint_rows = await postgres_service.fetch(constraints_query, table_name)
1019
+
1020
+ # Map constraint types to readable names
1021
+ type_map = {
1022
+ "p": "PRIMARY KEY",
1023
+ "u": "UNIQUE",
1024
+ "f": "FOREIGN KEY",
1025
+ "c": "CHECK",
1026
+ "x": "EXCLUSION",
1027
+ }
1028
+
1029
+ result["constraints"] = []
1030
+ for row in constraint_rows:
1031
+ # contype is returned as bytes (char type), decode it
1032
+ con_type = row["constraint_type"]
1033
+ if isinstance(con_type, bytes):
1034
+ con_type = con_type.decode("utf-8")
1035
+ result["constraints"].append({
1036
+ "name": row["constraint_name"],
1037
+ "type": type_map.get(con_type, con_type),
1038
+ "columns": row["columns"],
1039
+ "definition": row["definition"],
1040
+ })
1041
+
1042
+ logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
1043
+
1044
+ return result
1045
+
1046
+
1047
+ @mcp_tool_error_handler
1048
+ async def save_agent(
1049
+ name: str,
1050
+ description: str,
1051
+ properties: dict[str, Any] | None = None,
1052
+ required: list[str] | None = None,
1053
+ tools: list[str] | None = None,
1054
+ tags: list[str] | None = None,
1055
+ version: str = "1.0.0",
1056
+ user_id: str | None = None,
1057
+ ) -> dict[str, Any]:
1058
+ """
1059
+ Save an agent schema to REM, making it available for use.
1060
+
1061
+ This tool creates or updates an agent definition in the user's schema space.
1062
+ The agent becomes immediately available for conversations.
1063
+
1064
+ **Default Tools**: All agents automatically get `search_rem` and `register_metadata`
1065
+ tools unless explicitly overridden.
1066
+
1067
+ Args:
1068
+ name: Agent name in kebab-case (e.g., "code-reviewer", "sales-assistant").
1069
+ Must be unique within the user's schema space.
1070
+ description: The agent's system prompt. This is the full instruction set
1071
+ that defines the agent's behavior, personality, and capabilities.
1072
+ Use markdown formatting for structure.
1073
+ properties: Output schema properties as a dict. Each property should have:
1074
+ - type: "string", "number", "boolean", "array", "object"
1075
+ - description: What this field captures
1076
+ Example: {"answer": {"type": "string", "description": "Response to user"}}
1077
+ If not provided, defaults to a simple {"answer": {"type": "string"}} schema.
1078
+ required: List of required property names. Defaults to ["answer"] if not provided.
1079
+ tools: List of tool names the agent can use. Defaults to ["search_rem", "register_metadata"].
1080
+ tags: Optional tags for categorizing the agent.
1081
+ version: Semantic version string (default: "1.0.0").
1082
+ user_id: User identifier for scoping. Uses authenticated user if not provided.
1083
+
1084
+ Returns:
1085
+ Dict with:
1086
+ - status: "success" or "error"
1087
+ - agent_name: Name of the saved agent
1088
+ - version: Version saved
1089
+ - message: Human-readable status
1090
+
1091
+ Examples:
1092
+ # Create a simple agent
1093
+ save_agent(
1094
+ name="greeting-bot",
1095
+ description="You are a friendly greeter. Say hello warmly.",
1096
+ properties={"answer": {"type": "string", "description": "Greeting message"}},
1097
+ required=["answer"]
1098
+ )
1099
+
1100
+ # Create agent with structured output
1101
+ save_agent(
1102
+ name="sentiment-analyzer",
1103
+ description="Analyze sentiment of text provided by the user.",
1104
+ properties={
1105
+ "answer": {"type": "string", "description": "Analysis explanation"},
1106
+ "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
1107
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1}
1108
+ },
1109
+ required=["answer", "sentiment"],
1110
+ tags=["analysis", "nlp"]
1111
+ )
1112
+ """
1113
+ from ...agentic.agents.agent_manager import save_agent as _save_agent
1114
+
1115
+ # Get user_id from context if not provided
1116
+ user_id = AgentContext.get_user_id_or_default(user_id, source="save_agent")
1117
+
1118
+ # Delegate to agent_manager
1119
+ result = await _save_agent(
1120
+ name=name,
1121
+ description=description,
1122
+ user_id=user_id,
1123
+ properties=properties,
1124
+ required=required,
1125
+ tools=tools,
1126
+ tags=tags,
1127
+ version=version,
1128
+ )
1129
+
1130
+ # Add helpful message for Slack users
1131
+ if result.get("status") == "success":
1132
+ result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
1133
+
1134
+ return result
@@ -102,14 +102,14 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
102
102
  # Tenant ID from header or default
103
103
  tenant_id = request.headers.get("X-Tenant-Id", "default")
104
104
 
105
- # 4. Rate Limiting
106
- if settings.postgres.enabled:
105
+ # 4. Rate Limiting (skip if disabled via settings)
106
+ if settings.postgres.enabled and settings.api.rate_limit_enabled:
107
107
  is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
108
108
  tenant_id=tenant_id,
109
109
  identifier=identifier,
110
110
  tier=tier
111
111
  )
112
-
112
+
113
113
  if not is_allowed:
114
114
  return JSONResponse(
115
115
  status_code=429,
@@ -141,8 +141,8 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
141
141
  secure=settings.environment == "production"
142
142
  )
143
143
 
144
- # Add Rate Limit headers
145
- if settings.postgres.enabled and 'limit' in locals():
144
+ # Add Rate Limit headers (only if rate limiting is enabled)
145
+ if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
146
146
  response.headers["X-RateLimit-Limit"] = str(limit)
147
147
  response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
148
148