boundedllm 0.4.0__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.
Files changed (53) hide show
  1. boundedllm/__init__.py +86 -0
  2. boundedllm/adapters/__init__.py +6 -0
  3. boundedllm/adapters/http_model.py +45 -0
  4. boundedllm/adapters/otlp.py +104 -0
  5. boundedllm/adapters/sql/__init__.py +136 -0
  6. boundedllm/adapters/sql/alembic.ini +39 -0
  7. boundedllm/adapters/sql/migrations/env.py +59 -0
  8. boundedllm/adapters/sql/migrations/script.py.mako +24 -0
  9. boundedllm/adapters/sql/migrations/versions/20260916_71f5e126461a_baseline_schema.py +183 -0
  10. boundedllm/adapters/sql/schema.py +123 -0
  11. boundedllm/adapters/sql/store.py +1206 -0
  12. boundedllm/audit.py +156 -0
  13. boundedllm/authz.py +42 -0
  14. boundedllm/budget.py +20 -0
  15. boundedllm/cli.py +390 -0
  16. boundedllm/config.py +171 -0
  17. boundedllm/context.py +26 -0
  18. boundedllm/contrib/__init__.py +22 -0
  19. boundedllm/contrib/anthropic.py +122 -0
  20. boundedllm/contrib/ledger.py +181 -0
  21. boundedllm/contrib/pgvector.py +250 -0
  22. boundedllm/egress.py +103 -0
  23. boundedllm/engine.py +327 -0
  24. boundedllm/errors.py +33 -0
  25. boundedllm/identity.py +125 -0
  26. boundedllm/limits.py +83 -0
  27. boundedllm/main.py +200 -0
  28. boundedllm/middleware.py +87 -0
  29. boundedllm/model_gateway.py +52 -0
  30. boundedllm/models.py +122 -0
  31. boundedllm/network.py +27 -0
  32. boundedllm/normalize.py +32 -0
  33. boundedllm/output_firewall.py +87 -0
  34. boundedllm/parsing.py +26 -0
  35. boundedllm/ports.py +118 -0
  36. boundedllm/prompts.py +14 -0
  37. boundedllm/retrieval.py +39 -0
  38. boundedllm/risk.py +54 -0
  39. boundedllm/support/__init__.py +44 -0
  40. boundedllm/support/authz.py +16 -0
  41. boundedllm/support/gateway.py +48 -0
  42. boundedllm/support/models.py +49 -0
  43. boundedllm/support/policy.py +67 -0
  44. boundedllm/support/projections.py +8 -0
  45. boundedllm/support/schema.py +45 -0
  46. boundedllm/support/store.py +257 -0
  47. boundedllm/telemetry.py +82 -0
  48. boundedllm/tool_gateway.py +33 -0
  49. boundedllm-0.4.0.dist-info/METADATA +373 -0
  50. boundedllm-0.4.0.dist-info/RECORD +53 -0
  51. boundedllm-0.4.0.dist-info/WHEEL +4 -0
  52. boundedllm-0.4.0.dist-info/entry_points.txt +2 -0
  53. boundedllm-0.4.0.dist-info/licenses/LICENSE +202 -0
boundedllm/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """Deterministic security boundaries for enterprise LLM applications.
2
+
3
+ Importing this package starts no servers, reads no credentials, and pulls in no
4
+ database driver. The core depends on the storage contracts in
5
+ ``boundedllm.ports``; a host implements them over whatever it already runs, or
6
+ imports ``boundedllm.adapters.sql`` for a working implementation.
7
+
8
+ from boundedllm import Audit, Guard, Limits
9
+ from boundedllm.adapters.sql import SQLStore, sql_ports
10
+
11
+ audit = Audit(key)
12
+ store = SQLStore(settings, audit)
13
+ guard = Guard(provider=provider, signer=audit, **sql_ports(store))
14
+
15
+ What this package is for: bounding what a compromised agent can reach and do.
16
+ Identity, tenant isolation, retrieval ACLs, tool authorization, human consent,
17
+ idempotency, quotas, and a tamper-evident ledger are deterministic application
18
+ code here, and they hold whether or not the model has been manipulated.
19
+
20
+ What it is not: a prompt-injection detector. ``boundedllm.risk`` is a tripwire for
21
+ blunt override attempts, and the bundled ``PatternScanner`` is a test baseline,
22
+ not a DLP product. Both are signals. The boundaries above are the controls.
23
+ """
24
+
25
+ __version__ = "0.4.0"
26
+
27
+ from boundedllm.audit import Audit
28
+ from boundedllm.engine import Guard
29
+ from boundedllm.errors import (
30
+ Conflict,
31
+ Denied,
32
+ GuardError,
33
+ InvalidToken,
34
+ LimitExceeded,
35
+ OutputBlocked,
36
+ Unavailable,
37
+ )
38
+ from boundedllm.limits import Limits
39
+ from boundedllm.model_gateway import ModelProvider, ModelRequest
40
+ from boundedllm.models import (
41
+ ChatRequest,
42
+ ChatResponse,
43
+ Document,
44
+ IngestRequest,
45
+ Principal,
46
+ ProposedToolCall,
47
+ ToolExecutionResult,
48
+ TurnFlags,
49
+ )
50
+ from boundedllm.output_firewall import Scanner
51
+ from boundedllm.ports import Documents, Ledger, Operations, Quotas, Signer
52
+ from boundedllm.telemetry import Observability, OpenTelemetryObservability
53
+ from boundedllm.tool_gateway import ToolExecutor
54
+
55
+ __all__ = [
56
+ "Audit",
57
+ "ChatRequest",
58
+ "ChatResponse",
59
+ "Conflict",
60
+ "Denied",
61
+ "Document",
62
+ "Documents",
63
+ "Guard",
64
+ "GuardError",
65
+ "IngestRequest",
66
+ "InvalidToken",
67
+ "Ledger",
68
+ "LimitExceeded",
69
+ "Limits",
70
+ "ModelProvider",
71
+ "ModelRequest",
72
+ "Observability",
73
+ "OpenTelemetryObservability",
74
+ "Operations",
75
+ "OutputBlocked",
76
+ "Principal",
77
+ "ProposedToolCall",
78
+ "Quotas",
79
+ "Scanner",
80
+ "Signer",
81
+ "ToolExecutionResult",
82
+ "ToolExecutor",
83
+ "TurnFlags",
84
+ "Unavailable",
85
+ "__version__",
86
+ ]
@@ -0,0 +1,6 @@
1
+ """Concrete implementations of the core ports and provider contracts.
2
+
3
+ Everything here is optional. The core depends on ``boundedllm.ports``; these
4
+ modules depend on SQLAlchemy, httpx, and deployment configuration, which is why
5
+ they are kept off the core import path.
6
+ """
@@ -0,0 +1,45 @@
1
+ """A generic HTTPS model transport for deployments without a vendor SDK.
2
+
3
+ Kept out of the core so importing ``boundedllm`` costs no HTTP client and no
4
+ deployment configuration. A host that already uses a vendor SDK should implement
5
+ ``ModelProvider`` against it directly rather than route through this contract.
6
+ """
7
+
8
+ import httpx
9
+
10
+ from boundedllm.config import Settings
11
+ from boundedllm.errors import Unavailable
12
+ from boundedllm.model_gateway import ModelRequest
13
+ from boundedllm.network import bounded_json
14
+
15
+
16
+ class HTTPModelProvider:
17
+ """Generic enterprise endpoint contract, not a vendor-specific API implementation."""
18
+
19
+ def __init__(self, settings: Settings, client: httpx.AsyncClient):
20
+ if not settings.model_url:
21
+ raise ValueError("model_url required")
22
+ self.settings = settings
23
+ self.client = client
24
+
25
+ async def complete(self, request: ModelRequest) -> str:
26
+ headers = {}
27
+ if self.settings.model_api_key:
28
+ headers["Authorization"] = "Bearer " + self.settings.model_api_key.get_secret_value()
29
+ data = await bounded_json(
30
+ self.client,
31
+ "POST",
32
+ self.settings.model_url,
33
+ max_bytes=131072,
34
+ headers=headers,
35
+ json={
36
+ "model": request.model,
37
+ "system": request.system,
38
+ "user": request.user,
39
+ "max_output_tokens": request.max_output_tokens,
40
+ "temperature": 0,
41
+ },
42
+ )
43
+ if set(data) != {"text"} or not isinstance(data["text"], str):
44
+ raise Unavailable("MODEL_RESPONSE_SCHEMA")
45
+ return data["text"]
@@ -0,0 +1,104 @@
1
+ """At-least-once delivery of signed ledger records to an OTLP/HTTP collector.
2
+
3
+ The request path writes the ledger and its outbox in one database transaction.
4
+ This worker drains that outbox and marks records exported only after the
5
+ collector acknowledges them, so a delivery failure never loses evidence and a
6
+ retry never loses a record. Collectors and SIEMs deduplicate on ``event_id``.
7
+
8
+ Kept out of the core because it needs an HTTP client and a concrete store.
9
+ """
10
+
11
+ import json
12
+ import time
13
+ from typing import Protocol
14
+
15
+ import httpx
16
+
17
+ from boundedllm.adapters.sql.store import SQLStore
18
+ from boundedllm.config import require_https
19
+
20
+
21
+ class SecurityEventSink(Protocol):
22
+ """Small adapter surface for an OTel Collector or enterprise SIEM gateway."""
23
+
24
+ def export(self, events: list[dict]) -> None: ...
25
+
26
+
27
+ class OpenTelemetryHTTPSink:
28
+ """Send metadata-only events using OTLP/HTTP JSON to ``/v1/logs``."""
29
+
30
+ def __init__(self, endpoint: str, authorization: str | None = None):
31
+ self.endpoint = require_https(endpoint)
32
+ self.authorization = authorization
33
+
34
+ def export(self, events: list[dict]) -> None:
35
+ if not events:
36
+ return
37
+ records = []
38
+ for envelope in events:
39
+ payload = json.loads(envelope["payload"])
40
+ attributes = [
41
+ {"key": "security.event_id", "value": {"stringValue": envelope["event_id"]}},
42
+ {"key": "security.tenant_id", "value": {"stringValue": envelope["tenant_id"]}},
43
+ {"key": "security.sequence", "value": {"intValue": str(envelope["sequence"])}},
44
+ {"key": "security.record_hash", "value": {"stringValue": envelope["record_hash"]}},
45
+ {"key": "security.key_id", "value": {"stringValue": envelope["key_id"]}},
46
+ ]
47
+ records.append(
48
+ {
49
+ "timeUnixNano": str(int(payload["timestamp"] * 1_000_000_000)),
50
+ "observedTimeUnixNano": str(time.time_ns()),
51
+ "severityText": str(payload.get("severity", "INFO")).upper(),
52
+ "body": {"stringValue": envelope["payload"]},
53
+ "attributes": attributes,
54
+ }
55
+ )
56
+ body = {
57
+ "resourceLogs": [
58
+ {
59
+ "resource": {
60
+ "attributes": [
61
+ {
62
+ "key": "service.name",
63
+ "value": {"stringValue": "boundedllm"},
64
+ }
65
+ ]
66
+ },
67
+ "scopeLogs": [
68
+ {
69
+ "scope": {"name": "boundedllm.audit"},
70
+ "logRecords": records,
71
+ }
72
+ ],
73
+ }
74
+ ]
75
+ }
76
+ headers = {"Content-Type": "application/json"}
77
+ if self.authorization:
78
+ headers["Authorization"] = self.authorization
79
+ with httpx.Client(
80
+ timeout=httpx.Timeout(15, connect=5),
81
+ follow_redirects=False,
82
+ trust_env=False,
83
+ ) as client:
84
+ with client.stream("POST", self.endpoint, headers=headers, json=body) as response:
85
+ response.raise_for_status()
86
+ received = 0
87
+ for chunk in response.iter_bytes():
88
+ received += len(chunk)
89
+ if received > 65536:
90
+ raise ValueError("OTLP acknowledgement exceeded 64 KiB")
91
+
92
+
93
+ def export_pending(store: SQLStore, tenant_id: str, sink: SecurityEventSink, limit: int = 100) -> int:
94
+ """Deliver one bounded batch and acknowledge its exact hashes after success."""
95
+ events = store.pending_audit_exports(tenant_id, limit)
96
+ if not events:
97
+ return 0
98
+ try:
99
+ sink.export(events)
100
+ except Exception:
101
+ store.mark_audit_export_failed(tenant_id, events)
102
+ raise
103
+ store.mark_audit_exported(tenant_id, events)
104
+ return len(events)
@@ -0,0 +1,136 @@
1
+ """Batteries-included SQL implementation of every core port.
2
+
3
+ Use this when you want the package to own its storage. A host that already has a
4
+ document store, an audit pipeline, or an authorization service should implement
5
+ ``boundedllm.ports`` against those instead; nothing in the core knows this module
6
+ exists.
7
+
8
+ The store is synchronous SQLAlchemy, so this adapter is where the threading
9
+ policy lives. Every call crosses into a worker thread through
10
+ ``asyncio.to_thread``, which means the default executor's thread count is the
11
+ real concurrency ceiling for database work. Raise it deliberately for a
12
+ high-throughput deployment:
13
+
14
+ import asyncio, concurrent.futures
15
+ loop = asyncio.get_running_loop()
16
+ loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=64))
17
+
18
+ Putting that decision here, rather than in the request path, is the point of the
19
+ port boundary: swapping in an async driver changes this file and nothing else.
20
+ """
21
+
22
+ import asyncio
23
+ from pathlib import Path
24
+
25
+ from boundedllm.adapters.sql.schema import metadata
26
+ from boundedllm.adapters.sql.store import SQLStore
27
+ from boundedllm.models import ChatRequest, ChatResponse, Document, Principal, RequestContext
28
+
29
+ ALEMBIC_INI = Path(__file__).with_name("alembic.ini")
30
+
31
+ __all__ = ["ALEMBIC_INI", "SQLAdapter", "SQLStore", "metadata", "migrate", "sql_ports", "stamp"]
32
+
33
+
34
+ def _alembic_config():
35
+ from alembic.config import Config
36
+
37
+ config = Config(str(ALEMBIC_INI))
38
+ # script_location resolves against the ini's own directory so migrations are
39
+ # found inside an installed wheel, not just in a source checkout.
40
+ config.set_main_option("script_location", str(ALEMBIC_INI.parent / "migrations"))
41
+ return config
42
+
43
+
44
+ def migrate(revision: str = "head") -> None:
45
+ """Run versioned migrations against GUARD_DATABASE_URL.
46
+
47
+ This is the production path. ``SQLStore.initialize`` is a development
48
+ convenience that creates tables directly and leaves no reviewable history,
49
+ which is not something a DBA team will accept on a real database.
50
+ """
51
+ from alembic import command
52
+
53
+ command.upgrade(_alembic_config(), revision)
54
+
55
+
56
+ def stamp(revision: str = "head") -> None:
57
+ """Record a revision without running it, for a database created before Alembic."""
58
+ from alembic import command
59
+
60
+ command.stamp(_alembic_config(), revision)
61
+
62
+
63
+ class SQLAdapter:
64
+ """Implements Ledger, Quotas, Operations, and Documents over one SQL store."""
65
+
66
+ def __init__(self, store: SQLStore):
67
+ self.store = store
68
+
69
+ # Ledger ---------------------------------------------------------------
70
+ async def event(self, ctx: RequestContext, event: str, **fields) -> None:
71
+ await asyncio.to_thread(self.store.event, ctx, event, **fields)
72
+
73
+ # Quotas ---------------------------------------------------------------
74
+ async def throttle(self, principal: Principal) -> None:
75
+ await asyncio.to_thread(self.store.throttle, principal)
76
+
77
+ async def reserve_model_cost(self, ctx: RequestContext) -> None:
78
+ await asyncio.to_thread(self.store.reserve_model_cost, ctx)
79
+
80
+ # Operations -----------------------------------------------------------
81
+ async def claim(self, ctx: RequestContext, request: ChatRequest) -> ChatResponse | None:
82
+ return await asyncio.to_thread(self.store.claim_operation, ctx, request)
83
+
84
+ async def finish(
85
+ self,
86
+ ctx: RequestContext,
87
+ operation_id: str,
88
+ response: ChatResponse | None,
89
+ docs: list[Document] | None = None,
90
+ ) -> None:
91
+ await asyncio.to_thread(self.store.finish_operation, ctx, operation_id, response, docs)
92
+
93
+ # Documents ------------------------------------------------------------
94
+ async def search(
95
+ self,
96
+ principal: Principal,
97
+ query: str,
98
+ limit: int,
99
+ max_level: int,
100
+ conversation_id: str,
101
+ attachment_ids: list[str],
102
+ ) -> list[Document]:
103
+ return await asyncio.to_thread(
104
+ self.store.search, principal, query, limit, max_level, conversation_id, attachment_ids
105
+ )
106
+
107
+ async def quarantine(self, ctx: RequestContext, doc_id: str, signals: list[str]) -> None:
108
+ await asyncio.to_thread(self.store.quarantine_document, ctx, doc_id, signals)
109
+
110
+ async def attachment_results(
111
+ self, principal: Principal, conversation_id: str, attachment_ids: list[str]
112
+ ) -> list[dict]:
113
+ return await asyncio.to_thread(
114
+ self.store.attachment_results, principal, conversation_id, attachment_ids
115
+ )
116
+
117
+ # Convenience used by host tool executors, which are async by contract.
118
+ async def get_account(self, principal: Principal, account_id: str):
119
+ return await asyncio.to_thread(self.store.get_account, principal, account_id)
120
+
121
+ async def propose_waiver(self, ctx, request, args, flags, policy) -> dict:
122
+ return await asyncio.to_thread(self.store.propose_waiver, ctx, request, args, flags, policy)
123
+
124
+
125
+ def sql_ports(store: SQLStore) -> dict:
126
+ """Spread into ``Guard(...)`` so one adapter satisfies every port by name.
127
+
128
+ guard = Guard(provider=provider, signer=audit, **sql_ports(store))
129
+ """
130
+ adapter = SQLAdapter(store)
131
+ return {
132
+ "ledger": adapter,
133
+ "quotas": adapter,
134
+ "operations": adapter,
135
+ "documents": adapter,
136
+ }
@@ -0,0 +1,39 @@
1
+ # The database URL is resolved from GUARD_DATABASE_URL in env.py, never here, so
2
+ # this file carries no credentials and is safe to ship inside the wheel.
3
+ [alembic]
4
+ script_location = %(here)s/migrations
5
+ prepend_sys_path = .
6
+ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
7
+
8
+ [loggers]
9
+ keys = root,sqlalchemy,alembic
10
+
11
+ [handlers]
12
+ keys = console
13
+
14
+ [formatters]
15
+ keys = generic
16
+
17
+ [logger_root]
18
+ level = WARNING
19
+ handlers = console
20
+ qualname =
21
+
22
+ [logger_sqlalchemy]
23
+ level = WARNING
24
+ handlers =
25
+ qualname = sqlalchemy.engine
26
+
27
+ [logger_alembic]
28
+ level = INFO
29
+ handlers =
30
+ qualname = alembic
31
+
32
+ [handler_console]
33
+ class = StreamHandler
34
+ args = (sys.stderr,)
35
+ level = NOTSET
36
+ formatter = generic
37
+
38
+ [formatter_generic]
39
+ format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,59 @@
1
+ """Alembic environment for the bundled SQL adapter.
2
+
3
+ The database URL comes from ``GUARD_DATABASE_URL`` through ``Settings`` so a
4
+ migration run cannot be pointed somewhere the application itself would refuse,
5
+ and so the URL never has to be written into a config file in the repository.
6
+
7
+ Importing the support schema registers the reference domain's tables on the same
8
+ MetaData. A deployment that does not use that domain can remove the import; its
9
+ tables are then absent from autogenerate and from the migration history.
10
+ """
11
+
12
+ from alembic import context
13
+ from sqlalchemy import engine_from_config, pool
14
+
15
+ import boundedllm.support.schema # noqa: F401 registers the reference domain tables
16
+ from boundedllm.adapters.sql.schema import metadata
17
+ from boundedllm.config import Settings
18
+
19
+ config = context.config
20
+ target_metadata = metadata
21
+
22
+
23
+ def _url() -> str:
24
+ return Settings().database_url.get_secret_value()
25
+
26
+
27
+ def run_migrations_offline() -> None:
28
+ context.configure(
29
+ url=_url(),
30
+ target_metadata=target_metadata,
31
+ literal_binds=True,
32
+ dialect_opts={"paramstyle": "named"},
33
+ compare_type=True,
34
+ )
35
+ with context.begin_transaction():
36
+ context.run_migrations()
37
+
38
+
39
+ def run_migrations_online() -> None:
40
+ section = config.get_section(config.config_ini_section, {})
41
+ section["sqlalchemy.url"] = _url()
42
+ connectable = engine_from_config(section, prefix="sqlalchemy.", poolclass=pool.NullPool)
43
+ with connectable.connect() as connection:
44
+ context.configure(
45
+ connection=connection,
46
+ target_metadata=target_metadata,
47
+ compare_type=True,
48
+ # SQLite cannot ALTER most columns in place; batch mode rewrites the
49
+ # table instead so the same revision runs on the demo and on Postgres.
50
+ render_as_batch=connection.dialect.name == "sqlite",
51
+ )
52
+ with context.begin_transaction():
53
+ context.run_migrations()
54
+
55
+
56
+ if context.is_offline_mode():
57
+ run_migrations_offline()
58
+ else:
59
+ run_migrations_online()
@@ -0,0 +1,24 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision: str = ${repr(up_revision)}
14
+ down_revision: str | None = ${repr(down_revision)}
15
+ branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
16
+ depends_on: str | Sequence[str] | None = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade() -> None:
24
+ ${downgrades if downgrades else "pass"}