This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
"""asf-ai: ASF AI Gateway — "l'ufficio analisti".
*Business code NEVER calls a specific LLM provider directly.*
It calls typed capability methods on this gateway.
Provider adapters (Mock, OpenAI-compat, NVIDIA, local, customer-hosted, ...)
implement the low-level interface. Routing, priority queues, tenant AI
policy, usage metering and cost tracking live here.
"""
__version__ = "0.1.0"
from .capabilities import (
CapabilityRequest,
CapabilityResponse,
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
TraceContext,
)
from .gateway import AIGateway, AIGatewayConfig
from .providers import (
AIProvider,
MockProvider,
ProviderRegistry,
build_default_gateway,
)
from .usage import UsageRecord
__all__ = [
"CapabilityRequest",
"CapabilityResponse",
"ChatRequest",
"ChatResponse",
"CodeAnalysisRequest",
"CodeAnalysisResult",
"TestGenerationRequest",
"GeneratedTests",
"RequirementDecomposeRequest",
"RequirementDecomposition",
"TraceContext",
"AIGateway",
"AIGatewayConfig",
"AIProvider",
"MockProvider",
"ProviderRegistry",
"build_default_gateway",
"UsageRecord",
]
+133
View File
@@ -0,0 +1,133 @@
"""Typed capability requests / responses.
Business services construct these Pydantic models and call methods on the
AIGateway. They never know which concrete model or provider was chosen.
"""
from __future__ import annotations
from typing import Any, Optional, Sequence
from uuid import UUID
from pydantic import BaseModel, Field
class TraceContext(BaseModel):
"""Context propagated through every call for audit / logging."""
tenant_id: UUID
project_id: Optional[UUID] = None
user_id: Optional[UUID] = None
job_id: Optional[UUID] = None
correlation_id: Optional[str] = None
class CapabilityRequest(BaseModel):
"""Base marker for every capability request supported by the gateway."""
trace: TraceContext
preferred_model: Optional[str] = Field(
None, max_length=128, description="Hint only. Gateway's routing decision is authoritative."
)
priority: str = "normal"
class CapabilityResponse(BaseModel):
"""Every response carries at least trace + usage."""
provider: str
model_used: str
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
duration_ms: Optional[int] = None
estimated_cost: Optional[float] = None
execution_id: UUID
raw: Optional[dict[str, Any]] = Field(
default=None, description="Raw provider output, preserved for debugging."
)
# ============================================================================
# Individual capabilities
# ============================================================================
class ChatMessage(BaseModel):
role: str = Field(..., pattern="^(system|user|assistant|tool)$")
content: str
class ChatRequest(CapabilityRequest):
messages: Sequence[ChatMessage]
temperature: float = Field(0.2, ge=0.0, le=2.0)
max_tokens: Optional[int] = Field(None, ge=1)
context_summary: Optional[str] = Field(
None, description="Short plaintext summary of the Context Engine assembly."
)
class ChatResponse(CapabilityResponse):
reply: str
references: list[str] = Field(default_factory=list)
class CodeAnalysisRequest(CapabilityRequest):
file_path: Optional[str] = None
language: Optional[str] = None
code_snippet: str = Field(..., min_length=1)
analysis_types: Sequence[str] = Field(
default_factory=lambda: ["bugs", "complexity", "security", "style"],
description="Which kinds of analyses to run.",
)
class Finding(BaseModel):
severity: str = Field(..., pattern="^(info|low|medium|high|critical)$")
kind: str
message: str
line_start: Optional[int] = None
line_end: Optional[int] = None
suggestion: Optional[str] = None
class CodeAnalysisResult(CapabilityResponse):
summary: str
findings: list[Finding] = Field(default_factory=list)
class TestGenerationRequest(CapabilityRequest):
file_path: Optional[str] = None
language: str = "python"
test_framework: str = "pytest"
code_snippet: str = Field(..., min_length=1)
goal: str = "Generate unit tests covering happy path + main failure modes"
existing_tests: Optional[str] = None
class GeneratedTests(CapabilityResponse):
test_cases: list[str] = Field(default_factory=list)
fixtures_needed: list[str] = Field(default_factory=list)
coverage_notes: str = ""
class RequirementDecomposeRequest(CapabilityRequest):
requirement_text: str = Field(..., min_length=1)
context: Optional[str] = None
existing_subtasks: Sequence[str] = Field(default_factory=list)
max_subtasks: int = Field(20, ge=1, le=100)
class SubTask(BaseModel):
title: str
description: str
estimated_complexity: str = Field(..., pattern="^(xs|s|m|l|xl)$")
depends_on: list[int] = Field(
default_factory=list,
description="Index (0-based) of sibling subtasks this one depends on.",
)
class RequirementDecomposition(CapabilityResponse):
subtasks: list[SubTask] = Field(default_factory=list)
architecture_notes: list[str] = Field(default_factory=list)
open_questions: list[str] = Field(default_factory=list)
+29
View File
@@ -0,0 +1,29 @@
"""asf-ai settings surface (minimal; extensible)."""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class AISettings(BaseSettings):
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
mongo_db: str = "asf"
default_provider: str = "mock"
# Provider-specific (optional — only required if the chosen provider is used)
openai_api_key: str | None = None
anthropic_api_key: str | None = None
nvidia_api_key: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache(maxsize=1)
def get_settings() -> AISettings:
return AISettings()
+185
View File
@@ -0,0 +1,185 @@
"""AIGateway — the high-level capability surface called by business code.
It handles:
1. Routing: picks which provider + model to use given the request.
2. Policy enforcement: tenant-level AI policy (which providers/models are
allowed; budget caps will plug in here cleanly later).
3. Usage recording: emits UsageRecords for metering + future billing.
4. Error translation (provider-specific → stable ASF exceptions).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Optional
import structlog
from .capabilities import (
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
)
from .providers import (
AIProvider,
ProviderRegistry,
default_registry,
)
from .usage import UsageRecord
log = structlog.get_logger(__name__)
@dataclass
class AIGatewayConfig:
default_provider: str = "mock"
default_model_by_capability: dict[str, str] = field(default_factory=dict)
# Hook for future tenant policy layer: given (tenant_id, capability),
# return the provider name to route to. None → use default_provider.
policy_selector: Optional[Callable[..., Optional[str]]] = None
class AIGateway:
"""Entry point for all ASF AI operations."""
def __init__(
self,
*,
config: Optional[AIGatewayConfig] = None,
registry: Optional[ProviderRegistry] = None,
) -> None:
self._config = config or AIGatewayConfig()
self._registry = registry or default_registry()
self._usage_callbacks: list[Callable[[UsageRecord], None]] = []
# --- callback hooks (future usage metering / billing) ---
def on_usage(self, cb: Callable[[UsageRecord], None]) -> None:
self._usage_callbacks.append(cb)
# --- routing helper ---
def _route(self, req, capability: str) -> AIProvider:
tenant_id = str(req.trace.tenant_id) if req.trace else None
chosen: Optional[str] = None
if self._config.policy_selector and tenant_id:
try:
chosen = self._config.policy_selector(
tenant_id=req.trace.tenant_id,
capability=capability,
preferred=req.preferred_model,
)
except Exception: # noqa: BLE001
log.exception("ai_policy_selector_failed")
chosen = chosen or self._config.default_provider
return self._registry.get(chosen)
def _emit_usage(self, record: UsageRecord) -> None:
for cb in self._usage_callbacks:
try:
cb(record)
except Exception: # noqa: BLE001
log.exception("ai_usage_callback_failed", provider=record.provider)
# ------------------------------------------------------------------
# Capability methods
# ------------------------------------------------------------------
async def chat(self, req: ChatRequest) -> ChatResponse:
provider = self._route(req, "chat")
log.debug("ai_chat_start", provider=provider.name, tenant=str(req.trace.tenant_id))
resp = await provider.chat(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="chat",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
provider = self._route(req, "analyze_code")
resp = await provider.analyze_code(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="analyze_code",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
provider = self._route(req, "generate_tests")
resp = await provider.generate_tests(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="generate_tests",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition:
provider = self._route(req, "decompose_requirement")
resp = await provider.decompose_requirement(req)
self._emit_usage(
UsageRecord(
tenant_id=req.trace.tenant_id,
project_id=req.trace.project_id,
job_id=req.trace.job_id,
execution_id=resp.execution_id,
provider=resp.provider,
model=resp.model_used,
capability="decompose_requirement",
input_tokens=resp.input_tokens,
output_tokens=resp.output_tokens,
estimated_cost=resp.estimated_cost,
duration_ms=resp.duration_ms,
recorded_at=datetime.now(tz=timezone.utc),
)
)
return resp
def build_default_gateway() -> AIGateway:
"""Default bootstrap-configured gateway — uses MockProvider, stdout usage logging."""
gw = AIGateway()
gw.on_usage(lambda record: log.debug("ai_usage_record", **record.model_dump(mode="json")))
return gw
+256
View File
@@ -0,0 +1,256 @@
"""AIProvider interface + mock + registry."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, Optional, Type
from uuid import uuid4, UUID
import structlog
from .capabilities import (
CapabilityRequest,
CapabilityResponse,
ChatRequest,
ChatResponse,
CodeAnalysisRequest,
CodeAnalysisResult,
TestGenerationRequest,
GeneratedTests,
RequirementDecomposeRequest,
RequirementDecomposition,
SubTask,
Finding,
)
log = structlog.get_logger(__name__)
@dataclass
class ProviderCaps:
supports_chat: bool = True
supports_code_analysis: bool = True
supports_test_generation: bool = True
supports_decompose: bool = True
max_input_chars: int = 200_000
class AIProvider(Protocol):
"""Low-level provider interface. The Gateway calls exactly one per request.
Concrete implementations: MockProvider, OpenAICompatProvider, NVIDIADGXProvider, ...
"""
@property
def name(self) -> str: ...
@property
def default_model(self) -> str: ...
@property
def capabilities(self) -> ProviderCaps: ...
async def chat(self, req: ChatRequest) -> ChatResponse: ...
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult: ...
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests: ...
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition: ...
# ==============================================================================
# MockProvider — deterministic, canned, latency-simulating responses
# ==============================================================================
class MockProvider:
"""Provider that always returns deterministic, predictable responses.
Uses:
* unit tests of code that depends on the AI Gateway
* local dev when no API keys are set
* UI demo / smoke tests
"""
name = "mock"
default_model = "asf-mock-v1"
capabilities = ProviderCaps()
def __init__(self, *, response_latency_ms: int = 40) -> None:
self._latency = max(0, response_latency_ms)
async def _maybe_sleep(self) -> None:
if self._latency <= 0:
return
import asyncio
await asyncio.sleep(self._latency / 1000.0)
async def chat(self, req: ChatRequest) -> ChatResponse:
await self._maybe_sleep()
# Mirror the last user message back as a demo reply — never misleadingly smart
last_user = next(
(m.content for m in reversed(req.messages) if m.role == "user"),
"(no user message)",
)
reply = (
"[ASF Mock AI] This is a deterministic response from MockProvider. "
f"Your last message was: {last_user[:160]!r}. "
"To enable real AI, configure a non-mock provider in the ASF AI Gateway."
)
return ChatResponse(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
reply=reply,
references=["mock://no-real-data"],
input_tokens=len(last_user) // 4,
output_tokens=len(reply) // 4,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
await self._maybe_sleep()
lines = [ln for ln in req.code_snippet.splitlines() if ln.strip()]
findings: list[Finding] = []
if "TODO" in req.code_snippet:
findings.append(
Finding(
severity="info",
kind="todo",
message="Mock analysis found TODO comment — remember to follow up.",
)
)
if "except Exception" in req.code_snippet:
findings.append(
Finding(
severity="medium",
kind="broad_exception",
message="Broad except clause detected — consider narrowing exception types.",
)
)
return CodeAnalysisResult(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
summary=f"Mock: analyzed {len(lines)} non-empty lines; {len(findings)} informational finding(s).",
findings=findings,
input_tokens=max(10, len(req.code_snippet) // 4),
output_tokens=100,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
await self._maybe_sleep()
test_cases = [
"# Generated by MockProvider — placeholder skeleton\n"
"def test_mock_happy_path():\n"
" # TODO: replace with real assertions from a production AI provider\n"
" assert True\n",
"# Generated by MockProvider — placeholder skeleton\n"
"def test_mock_failure_mode():\n"
" # TODO: simulate and assert on failure behavior\n"
" assert True\n",
]
return GeneratedTests(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
test_cases=test_cases,
fixtures_needed=[
"NOTE: Mock provider — fixtures listed are placeholders only.",
],
coverage_notes="Mock response. Real AI provider will target specific branches.",
input_tokens=max(10, len(req.code_snippet) // 4),
output_tokens=300,
duration_ms=self._latency,
estimated_cost=0.0,
)
async def decompose_requirement(
self, req: RequirementDecomposeRequest
) -> RequirementDecomposition:
await self._maybe_sleep()
limit = max(1, min(5, req.max_subtasks))
subtasks = [
SubTask(
title=f"(Mock) Clarify scope of requirement",
description="Mock placeholder — confirm acceptance criteria with stakeholders.",
estimated_complexity="s",
),
SubTask(
title=f"(Mock) Draft architecture",
description="Mock placeholder — identify modules, data flow, contracts.",
estimated_complexity="m",
depends_on=[0],
),
SubTask(
title=f"(Mock) Implement feature",
description="Mock placeholder — concrete coding work.",
estimated_complexity="l",
depends_on=[1],
),
SubTask(
title=f"(Mock) Add tests",
description="Mock placeholder — unit + integration coverage.",
estimated_complexity="m",
depends_on=[2],
),
SubTask(
title=f"(Mock) Deploy + monitor",
description="Mock placeholder — staging → production, observability.",
estimated_complexity="s",
depends_on=[3],
),
][:limit]
return RequirementDecomposition(
provider=self.name,
model_used=self.default_model,
execution_id=uuid4(),
subtasks=subtasks,
architecture_notes=["(Mock) Decomposition is deterministic placeholder data."],
open_questions=[
"(Mock) Which non-functional requirements apply?",
"(Mock) Any existing integration contracts to preserve?",
],
input_tokens=max(10, len(req.requirement_text) // 4),
output_tokens=400,
duration_ms=self._latency,
estimated_cost=0.0,
)
# ==============================================================================
# ProviderRegistry — maps provider name → AIProvider instance
# ==============================================================================
class ProviderRegistry:
def __init__(self) -> None:
self._providers: dict[str, AIProvider] = {}
def register(self, name: str, provider: AIProvider) -> None:
self._providers[name] = provider
def get(self, name: str) -> AIProvider:
if name not in self._providers:
raise KeyError(f"Unknown AI provider: {name}. Registered: {list(self._providers)}")
return self._providers[name]
def names(self) -> list[str]:
return list(self._providers.keys())
def default_registry() -> ProviderRegistry:
r = ProviderRegistry()
r.register("mock", MockProvider(response_latency_ms=40))
return r
def build_default_gateway():
"""Instantiate the canonical production AIGateway (mock by default).
Imported lazily to avoid a cycle with ``app.gateway``.
"""
from .gateway import AIGateway, AIGatewayConfig
return AIGateway(config=AIGatewayConfig(), registry=default_registry())
+25
View File
@@ -0,0 +1,25 @@
"""Usage record (for billing / metering later)."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class UsageRecord(BaseModel):
tenant_id: UUID
project_id: Optional[UUID] = None
job_id: Optional[UUID] = None
execution_id: UUID
provider: str
model: str
capability: str
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
gpu_seconds: Optional[float] = None
estimated_cost: Optional[float] = None
duration_ms: Optional[int] = None
recorded_at: datetime