nexus-agent-framework 0.1.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.
- nexus/__init__.py +3 -0
- nexus/__main__.py +4 -0
- nexus/agents/__init__.py +6 -0
- nexus/agents/base.py +93 -0
- nexus/agents/context.py +45 -0
- nexus/ai/__init__.py +18 -0
- nexus/ai/client.py +390 -0
- nexus/ai/pricing.py +28 -0
- nexus/api/__init__.py +14 -0
- nexus/api/app.py +119 -0
- nexus/api/middleware.py +33 -0
- nexus/cli/__init__.py +5 -0
- nexus/cli/__main__.py +4 -0
- nexus/cli/main.py +324 -0
- nexus/dashboard/__init__.py +17 -0
- nexus/dashboard/server.py +377 -0
- nexus/dashboard/telemetry.py +92 -0
- nexus/database/__init__.py +5 -0
- nexus/db/__init__.py +14 -0
- nexus/db/base.py +37 -0
- nexus/db/session.py +51 -0
- nexus/maintenance/__init__.py +5 -0
- nexus/maintenance/healer.py +38 -0
- nexus/orchestrator/__init__.py +6 -0
- nexus/orchestrator/mcp.py +112 -0
- nexus/orchestrator/protocol.py +54 -0
- nexus/providers/__init__.py +7 -0
- nexus/providers/base.py +77 -0
- nexus/providers/mock.py +85 -0
- nexus/providers/vertex.py +91 -0
- nexus/qa/__init__.py +5 -0
- nexus/qa/judge.py +38 -0
- nexus/scraping/__init__.py +5 -0
- nexus/scraping/client.py +86 -0
- nexus/security/__init__.py +12 -0
- nexus/security/guardrail.py +45 -0
- nexus/security/injection.py +32 -0
- nexus/security/sanitizer.py +25 -0
- nexus/teams/__init__.py +5 -0
- nexus/teams/it_pack/__init__.py +32 -0
- nexus/teams/it_pack/ba.py +48 -0
- nexus/teams/it_pack/data_architect.py +45 -0
- nexus/teams/it_pack/designer.py +43 -0
- nexus/teams/it_pack/devops.py +53 -0
- nexus/teams/it_pack/engineer.py +41 -0
- nexus/teams/it_pack/fullstack.py +65 -0
- nexus/teams/it_pack/manager.py +55 -0
- nexus/teams/it_pack/qa.py +56 -0
- nexus/teams/it_pack/solution_architect.py +60 -0
- nexus/teams/it_pack/team.py +422 -0
- nexus/tools/__init__.py +5 -0
- nexus/tools/base.py +82 -0
- nexus_agent_framework-0.1.0.dist-info/METADATA +201 -0
- nexus_agent_framework-0.1.0.dist-info/RECORD +56 -0
- nexus_agent_framework-0.1.0.dist-info/WHEEL +4 -0
- nexus_agent_framework-0.1.0.dist-info/entry_points.txt +2 -0
nexus/__init__.py
ADDED
nexus/__main__.py
ADDED
nexus/agents/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Nexus Agents: Autonomous agent primitives, context, and execution runtime."""
|
|
2
|
+
|
|
3
|
+
from nexus.agents.base import AgentRunResult, BaseAgent
|
|
4
|
+
from nexus.agents.context import AgentContext, BudgetExceededError
|
|
5
|
+
|
|
6
|
+
__all__ = ["AgentContext", "AgentRunResult", "BaseAgent", "BudgetExceededError"]
|
nexus/agents/base.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Nexus Agents: Base Autonomous Agent Primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from nexus.agents.context import AgentContext
|
|
9
|
+
from nexus.providers.base import BaseModelProvider
|
|
10
|
+
from nexus.providers.vertex import VertexAIProvider
|
|
11
|
+
from nexus.security.guardrail import GuardrailManager
|
|
12
|
+
from nexus.tools.base import Tool
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("nexus.agents")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentRunResult:
|
|
18
|
+
"""The result of an autonomous agent task execution."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
output: str,
|
|
23
|
+
turns: int,
|
|
24
|
+
context: AgentContext,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.output = output
|
|
27
|
+
self.turns = turns
|
|
28
|
+
self.context = context
|
|
29
|
+
self.total_cost_usd = context.accumulated_cost_usd
|
|
30
|
+
self.input_tokens = context.total_input_tokens
|
|
31
|
+
self.output_tokens = context.total_output_tokens
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"<AgentRunResult output={self.output[:40]!r}... turns={self.turns} cost=${self.total_cost_usd:.6f}>"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BaseAgent:
|
|
38
|
+
"""
|
|
39
|
+
Autonomous AI Agent Primitive.
|
|
40
|
+
Handles reasoning, tool dispatching, security guardrails, and budget enforcement.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
system_prompt: str,
|
|
47
|
+
provider: BaseModelProvider | None = None,
|
|
48
|
+
tools: Sequence[Tool] | None = None,
|
|
49
|
+
guardrail: GuardrailManager | None = None,
|
|
50
|
+
max_budget_usd: float = 0.50,
|
|
51
|
+
max_turns: int = 6,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.name = name
|
|
54
|
+
self.system_prompt = system_prompt
|
|
55
|
+
self.provider = provider or VertexAIProvider()
|
|
56
|
+
self.tools = {t.name: t for t in (tools or [])}
|
|
57
|
+
self.guardrail = guardrail or GuardrailManager()
|
|
58
|
+
self.max_budget_usd = max_budget_usd
|
|
59
|
+
self.max_turns = max_turns
|
|
60
|
+
|
|
61
|
+
def run(self, task: str) -> AgentRunResult:
|
|
62
|
+
"""Execute a task autonomously."""
|
|
63
|
+
# 1. Inspect and scrub input via security guardrails
|
|
64
|
+
clean_task = self.guardrail.inspect_input(task)
|
|
65
|
+
|
|
66
|
+
# 2. Initialize execution context with spend limit
|
|
67
|
+
context = AgentContext(max_budget_usd=self.max_budget_usd)
|
|
68
|
+
context.add_user_message(clean_task)
|
|
69
|
+
|
|
70
|
+
tool_schemas = [t.schema for t in self.tools.values()]
|
|
71
|
+
turn_count = 0
|
|
72
|
+
|
|
73
|
+
for turn in range(self.max_turns):
|
|
74
|
+
turn_count += 1
|
|
75
|
+
# Execute model turn
|
|
76
|
+
resp = self.provider.converse(
|
|
77
|
+
messages=context.messages,
|
|
78
|
+
tools=tool_schemas,
|
|
79
|
+
system_instruction=self.system_prompt,
|
|
80
|
+
)
|
|
81
|
+
context.record_usage(resp.input_tokens, resp.output_tokens, resp.cost_usd)
|
|
82
|
+
|
|
83
|
+
# Check if tools were executed or final answer returned
|
|
84
|
+
# If plain text returned
|
|
85
|
+
if resp.text:
|
|
86
|
+
context.add_model_message(resp.text)
|
|
87
|
+
return AgentRunResult(output=resp.text, turns=turn_count, context=context)
|
|
88
|
+
|
|
89
|
+
return AgentRunResult(
|
|
90
|
+
output="Agent completed max turns without final message.",
|
|
91
|
+
turns=turn_count,
|
|
92
|
+
context=context,
|
|
93
|
+
)
|
nexus/agents/context.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Nexus Agents: Execution Context, memory, and budget tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BudgetExceededError(Exception):
|
|
10
|
+
"""Raised when an agent session hits its spend ceiling."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentContext:
|
|
14
|
+
"""Manages working memory, message logs, and spend ledger for an agent run."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
session_id: str | None = None,
|
|
19
|
+
max_budget_usd: float = 0.50, # Default safety cap: $0.50 max per run
|
|
20
|
+
) -> None:
|
|
21
|
+
self.session_id = session_id or str(uuid.uuid4())
|
|
22
|
+
self.max_budget_usd = max_budget_usd
|
|
23
|
+
self.messages: list[dict[str, Any]] = []
|
|
24
|
+
self.tool_executions: list[dict[str, Any]] = []
|
|
25
|
+
self.accumulated_cost_usd: float = 0.0
|
|
26
|
+
self.total_input_tokens: int = 0
|
|
27
|
+
self.total_output_tokens: int = 0
|
|
28
|
+
|
|
29
|
+
def record_usage(self, input_tokens: int, output_tokens: int, cost_usd: float) -> None:
|
|
30
|
+
"""Record model inference tokens and check budget ceiling."""
|
|
31
|
+
self.total_input_tokens += input_tokens
|
|
32
|
+
self.total_output_tokens += output_tokens
|
|
33
|
+
self.accumulated_cost_usd += cost_usd
|
|
34
|
+
|
|
35
|
+
if self.accumulated_cost_usd > self.max_budget_usd:
|
|
36
|
+
raise BudgetExceededError(
|
|
37
|
+
f"Agent session {self.session_id} exceeded max budget of ${self.max_budget_usd:.4f} "
|
|
38
|
+
f"(current cost: ${self.accumulated_cost_usd:.4f})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def add_user_message(self, text: str) -> None:
|
|
42
|
+
self.messages.append({"role": "user", "parts": [{"text": text}]})
|
|
43
|
+
|
|
44
|
+
def add_model_message(self, text: str) -> None:
|
|
45
|
+
self.messages.append({"role": "model", "parts": [{"text": text}]})
|
nexus/ai/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Nexus AI package: Vertex AI & Gemini 3.8 client, pricing, and structured output."""
|
|
2
|
+
|
|
3
|
+
from nexus.ai.client import (
|
|
4
|
+
GeminiClient,
|
|
5
|
+
GeminiResponse,
|
|
6
|
+
get_gcp_access_token,
|
|
7
|
+
get_gcp_project,
|
|
8
|
+
)
|
|
9
|
+
from nexus.ai.pricing import DEFAULT_MODEL, calculate_cost_usd
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DEFAULT_MODEL",
|
|
13
|
+
"GeminiClient",
|
|
14
|
+
"GeminiResponse",
|
|
15
|
+
"calculate_cost_usd",
|
|
16
|
+
"get_gcp_access_token",
|
|
17
|
+
"get_gcp_project",
|
|
18
|
+
]
|
nexus/ai/client.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Nexus AI: Standardized Google Cloud Vertex AI & Gemini 3.8 Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from nexus.ai.pricing import DEFAULT_MODEL, calculate_cost_usd
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("nexus.ai")
|
|
20
|
+
|
|
21
|
+
# Cache token locally for 40 minutes to prevent overhead
|
|
22
|
+
_CACHED_TOKEN: tuple[str, float] | None = None
|
|
23
|
+
_TOKEN_TTL_SECONDS = 40 * 60
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T", bound=BaseModel)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_gcp_project() -> str:
|
|
29
|
+
"""Resolve the active Google Cloud project ID."""
|
|
30
|
+
proj = os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
|
|
31
|
+
if proj:
|
|
32
|
+
return proj
|
|
33
|
+
gcloud_bin = shutil.which("gcloud") or shutil.which("gcloud.cmd")
|
|
34
|
+
if gcloud_bin:
|
|
35
|
+
try:
|
|
36
|
+
res = subprocess.run(
|
|
37
|
+
[gcloud_bin, "config", "get-value", "project"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=5,
|
|
41
|
+
check=False,
|
|
42
|
+
)
|
|
43
|
+
val = res.stdout.strip()
|
|
44
|
+
if val and val != "(unset)":
|
|
45
|
+
return val
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return "finhub-510316"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_gcp_access_token() -> str | None:
|
|
52
|
+
"""Acquire an OAuth access token via IAM instance metadata or gcloud CLI."""
|
|
53
|
+
global _CACHED_TOKEN
|
|
54
|
+
now = time.time()
|
|
55
|
+
if _CACHED_TOKEN and (now - _CACHED_TOKEN[1]) < _TOKEN_TTL_SECONDS:
|
|
56
|
+
return _CACHED_TOKEN[0]
|
|
57
|
+
|
|
58
|
+
# 1. Environment token override
|
|
59
|
+
env_token = os.getenv("GCP_ACCESS_TOKEN") or os.getenv("GOOGLE_OAUTH_ACCESS_TOKEN")
|
|
60
|
+
if env_token:
|
|
61
|
+
_CACHED_TOKEN = (env_token.strip(), now)
|
|
62
|
+
return env_token.strip()
|
|
63
|
+
|
|
64
|
+
# 2. Check GCP Compute Engine / Cloud Run instance metadata server
|
|
65
|
+
try:
|
|
66
|
+
resp = httpx.get(
|
|
67
|
+
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
|
|
68
|
+
headers={"Metadata-Flavor": "Google"},
|
|
69
|
+
timeout=1.5,
|
|
70
|
+
)
|
|
71
|
+
if resp.status_code == 200:
|
|
72
|
+
token = resp.json().get("access_token")
|
|
73
|
+
if token:
|
|
74
|
+
_CACHED_TOKEN = (token, now)
|
|
75
|
+
return str(token)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# 3. Local development fallback: gcloud auth print-access-token
|
|
80
|
+
gcloud_bin = shutil.which("gcloud") or shutil.which("gcloud.cmd")
|
|
81
|
+
if gcloud_bin:
|
|
82
|
+
try:
|
|
83
|
+
res = subprocess.run(
|
|
84
|
+
[gcloud_bin, "auth", "print-access-token"],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
timeout=15,
|
|
88
|
+
check=False,
|
|
89
|
+
)
|
|
90
|
+
token = res.stdout.strip()
|
|
91
|
+
if token and res.returncode == 0 and not token.startswith("ERROR"):
|
|
92
|
+
_CACHED_TOKEN = (token, now)
|
|
93
|
+
return token
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.debug("Local gcloud token lookup failed: %s", exc)
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GeminiResponse:
|
|
101
|
+
"""Normalized response from Gemini 3.8."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
text: str,
|
|
106
|
+
input_tokens: int,
|
|
107
|
+
output_tokens: int,
|
|
108
|
+
cost_usd: float,
|
|
109
|
+
model: str,
|
|
110
|
+
raw: Mapping[str, Any],
|
|
111
|
+
) -> None:
|
|
112
|
+
self.text = text
|
|
113
|
+
self.input_tokens = input_tokens
|
|
114
|
+
self.output_tokens = output_tokens
|
|
115
|
+
self.cost_usd = cost_usd
|
|
116
|
+
self.model = model
|
|
117
|
+
self.raw = raw
|
|
118
|
+
|
|
119
|
+
def __repr__(self) -> str:
|
|
120
|
+
return f"<GeminiResponse model={self.model} cost=${self.cost_usd:.6f} length={len(self.text)}>"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GeminiClient:
|
|
124
|
+
"""
|
|
125
|
+
Standardized client for Google Cloud Vertex AI & Gemini 3.8 Flash.
|
|
126
|
+
Provides structured schema extraction, multimodal parsing, and multi-turn tool loops.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
project_id: str | None = None,
|
|
132
|
+
location: str = "global",
|
|
133
|
+
api_key: str | None = None,
|
|
134
|
+
default_model: str = DEFAULT_MODEL,
|
|
135
|
+
) -> None:
|
|
136
|
+
self.project_id = project_id or get_gcp_project()
|
|
137
|
+
self.location = location
|
|
138
|
+
self.api_key = api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
|
139
|
+
self.default_model = default_model
|
|
140
|
+
self._http = httpx.Client(timeout=httpx.Timeout(180.0, connect=30.0, read=180.0))
|
|
141
|
+
|
|
142
|
+
def _post(
|
|
143
|
+
self,
|
|
144
|
+
url: str,
|
|
145
|
+
headers: dict[str, str],
|
|
146
|
+
body: dict[str, Any],
|
|
147
|
+
max_retries: int = 2,
|
|
148
|
+
) -> httpx.Response:
|
|
149
|
+
import time
|
|
150
|
+
|
|
151
|
+
for attempt in range(max_retries):
|
|
152
|
+
try:
|
|
153
|
+
return self._http.post(url, headers=headers, json=body)
|
|
154
|
+
except (httpx.ReadTimeout, httpx.ConnectTimeout):
|
|
155
|
+
if attempt == max_retries - 1:
|
|
156
|
+
raise
|
|
157
|
+
time.sleep(2.0)
|
|
158
|
+
raise RuntimeError("HTTP request failed after max retries")
|
|
159
|
+
|
|
160
|
+
def _build_url_and_headers(self, model: str) -> tuple[str, dict[str, str]]:
|
|
161
|
+
token = get_gcp_access_token()
|
|
162
|
+
if token:
|
|
163
|
+
# Vertex AI global or regional publisher endpoint
|
|
164
|
+
if self.location == "global":
|
|
165
|
+
url = (
|
|
166
|
+
f"https://aiplatform.googleapis.com/v1/projects/{self.project_id}"
|
|
167
|
+
f"/locations/global/publishers/google/models/{model}:generateContent"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
url = (
|
|
171
|
+
f"https://{self.location}-aiplatform.googleapis.com/v1/projects/{self.project_id}"
|
|
172
|
+
f"/locations/{self.location}/publishers/google/models/{model}:generateContent"
|
|
173
|
+
)
|
|
174
|
+
headers = {
|
|
175
|
+
"Authorization": f"Bearer {token}",
|
|
176
|
+
"Content-Type": "application/json",
|
|
177
|
+
}
|
|
178
|
+
return url, headers
|
|
179
|
+
|
|
180
|
+
if self.api_key:
|
|
181
|
+
# Direct Google Generative Language API
|
|
182
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}"
|
|
183
|
+
headers = {"Content-Type": "application/json"}
|
|
184
|
+
return url, headers
|
|
185
|
+
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
"No Google Cloud authentication found. Provide GCP IAM credentials, run "
|
|
188
|
+
"'gcloud auth application-default login', or set GEMINI_API_KEY."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def generate(
|
|
192
|
+
self,
|
|
193
|
+
prompt: str,
|
|
194
|
+
system_instruction: str | None = None,
|
|
195
|
+
model: str | None = None,
|
|
196
|
+
temperature: float = 0.2,
|
|
197
|
+
) -> GeminiResponse:
|
|
198
|
+
"""Execute a text completion turn."""
|
|
199
|
+
chosen_model = model or self.default_model
|
|
200
|
+
url, headers = self._build_url_and_headers(chosen_model)
|
|
201
|
+
|
|
202
|
+
body: dict[str, Any] = {
|
|
203
|
+
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
|
204
|
+
"generationConfig": {
|
|
205
|
+
"temperature": temperature,
|
|
206
|
+
"thinkingConfig": {"thinkingLevel": "low"},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
if system_instruction:
|
|
210
|
+
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
|
211
|
+
|
|
212
|
+
resp = self._post(url, headers=headers, body=body)
|
|
213
|
+
if resp.status_code != 200:
|
|
214
|
+
raise RuntimeError(f"Gemini API returned status {resp.status_code}: {resp.text}")
|
|
215
|
+
|
|
216
|
+
data = resp.json()
|
|
217
|
+
usage = data.get("usageMetadata", {})
|
|
218
|
+
inp = int(usage.get("promptTokenCount", 0))
|
|
219
|
+
out = int(usage.get("candidatesTokenCount", 0))
|
|
220
|
+
cost = calculate_cost_usd(inp, out, chosen_model)
|
|
221
|
+
|
|
222
|
+
candidates = data.get("candidates", [])
|
|
223
|
+
text = ""
|
|
224
|
+
if candidates:
|
|
225
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
226
|
+
for p in parts:
|
|
227
|
+
if "text" in p:
|
|
228
|
+
text += p["text"]
|
|
229
|
+
|
|
230
|
+
return GeminiResponse(
|
|
231
|
+
text=text.strip(),
|
|
232
|
+
input_tokens=inp,
|
|
233
|
+
output_tokens=out,
|
|
234
|
+
cost_usd=cost,
|
|
235
|
+
model=chosen_model,
|
|
236
|
+
raw=data,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def structured(
|
|
240
|
+
self,
|
|
241
|
+
prompt: str,
|
|
242
|
+
response_model: type[T],
|
|
243
|
+
system_instruction: str | None = None,
|
|
244
|
+
inline_pdf_bytes: bytes | None = None,
|
|
245
|
+
model: str | None = None,
|
|
246
|
+
) -> T:
|
|
247
|
+
"""Extract a strongly typed Pydantic schema using constrained JSON generation."""
|
|
248
|
+
chosen_model = model or self.default_model
|
|
249
|
+
url, headers = self._build_url_and_headers(chosen_model)
|
|
250
|
+
|
|
251
|
+
parts: list[dict[str, Any]] = []
|
|
252
|
+
if inline_pdf_bytes:
|
|
253
|
+
import base64
|
|
254
|
+
|
|
255
|
+
b64 = base64.b64encode(inline_pdf_bytes).decode("ascii")
|
|
256
|
+
parts.append({"inlineData": {"mimeType": "application/pdf", "data": b64}})
|
|
257
|
+
parts.append({"text": prompt})
|
|
258
|
+
|
|
259
|
+
# Generate JSON Schema from Pydantic model
|
|
260
|
+
schema = response_model.model_json_schema()
|
|
261
|
+
# Clean $defs / titles if needed for Gemini schema compatibility
|
|
262
|
+
clean_schema = self._sanitize_schema(schema)
|
|
263
|
+
|
|
264
|
+
body: dict[str, Any] = {
|
|
265
|
+
"contents": [{"role": "user", "parts": parts}],
|
|
266
|
+
"generationConfig": {
|
|
267
|
+
"temperature": 0.0,
|
|
268
|
+
"responseMimeType": "application/json",
|
|
269
|
+
"responseSchema": clean_schema,
|
|
270
|
+
"thinkingConfig": {"thinkingLevel": "low"},
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
if system_instruction:
|
|
274
|
+
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
|
275
|
+
|
|
276
|
+
resp = self._post(url, headers=headers, body=body)
|
|
277
|
+
if resp.status_code != 200:
|
|
278
|
+
raise RuntimeError(f"Gemini structured output failed {resp.status_code}: {resp.text}")
|
|
279
|
+
|
|
280
|
+
data = resp.json()
|
|
281
|
+
candidates = data.get("candidates", [])
|
|
282
|
+
if not candidates:
|
|
283
|
+
raise RuntimeError(f"No candidates returned in structured output: {data}")
|
|
284
|
+
|
|
285
|
+
parts_out = candidates[0].get("content", {}).get("parts", [])
|
|
286
|
+
raw_text = "".join(p.get("text", "") for p in parts_out).strip()
|
|
287
|
+
parsed_json = json.loads(raw_text)
|
|
288
|
+
return response_model.model_validate(parsed_json)
|
|
289
|
+
|
|
290
|
+
def converse(
|
|
291
|
+
self,
|
|
292
|
+
messages: Sequence[dict[str, Any]],
|
|
293
|
+
tools: Sequence[Callable[..., Any]] | Sequence[dict[str, Any]],
|
|
294
|
+
system_instruction: str | None = None,
|
|
295
|
+
max_turns: int = 5,
|
|
296
|
+
model: str | None = None,
|
|
297
|
+
) -> GeminiResponse:
|
|
298
|
+
"""Multi-turn tool-calling loop executing tools locally without hallucinations."""
|
|
299
|
+
chosen_model = model or self.default_model
|
|
300
|
+
url, headers = self._build_url_and_headers(chosen_model)
|
|
301
|
+
|
|
302
|
+
# Convert tools to function declarations
|
|
303
|
+
func_declarations: list[dict[str, Any]] = []
|
|
304
|
+
tool_dispatch: dict[str, Callable[..., Any]] = {}
|
|
305
|
+
for t in tools:
|
|
306
|
+
if callable(t):
|
|
307
|
+
fn_name = getattr(t, "__name__", "tool")
|
|
308
|
+
doc = getattr(t, "__doc__", "") or f"Execute {fn_name}"
|
|
309
|
+
tool_dispatch[fn_name] = t
|
|
310
|
+
func_declarations.append(
|
|
311
|
+
{
|
|
312
|
+
"name": fn_name,
|
|
313
|
+
"description": doc.strip(),
|
|
314
|
+
"parameters": {"type": "object", "properties": {}},
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
elif isinstance(t, dict):
|
|
318
|
+
func_declarations.append(t)
|
|
319
|
+
|
|
320
|
+
current_contents = list(messages)
|
|
321
|
+
total_inp = 0
|
|
322
|
+
total_out = 0
|
|
323
|
+
|
|
324
|
+
for _ in range(max_turns):
|
|
325
|
+
body: dict[str, Any] = {
|
|
326
|
+
"contents": current_contents,
|
|
327
|
+
"generationConfig": {
|
|
328
|
+
"temperature": 0.1,
|
|
329
|
+
"thinkingConfig": {"thinkingLevel": "low"},
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
if func_declarations:
|
|
333
|
+
body["tools"] = [{"functionDeclarations": func_declarations}]
|
|
334
|
+
if system_instruction:
|
|
335
|
+
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
|
336
|
+
|
|
337
|
+
resp = self._post(url, headers=headers, body=body)
|
|
338
|
+
if resp.status_code != 200:
|
|
339
|
+
raise RuntimeError(f"Gemini converse error {resp.status_code}: {resp.text}")
|
|
340
|
+
|
|
341
|
+
data = resp.json()
|
|
342
|
+
usage = data.get("usageMetadata", {})
|
|
343
|
+
total_inp += int(usage.get("promptTokenCount", 0))
|
|
344
|
+
total_out += int(usage.get("candidatesTokenCount", 0))
|
|
345
|
+
|
|
346
|
+
candidate = (data.get("candidates") or [{}])[0]
|
|
347
|
+
content = candidate.get("content", {})
|
|
348
|
+
parts = content.get("parts", [])
|
|
349
|
+
|
|
350
|
+
# Check if model requested tool call
|
|
351
|
+
function_calls = [p["functionCall"] for p in parts if "functionCall" in p]
|
|
352
|
+
if not function_calls:
|
|
353
|
+
# Text answer completed
|
|
354
|
+
text = "".join(p.get("text", "") for p in parts).strip()
|
|
355
|
+
cost = calculate_cost_usd(total_inp, total_out, chosen_model)
|
|
356
|
+
return GeminiResponse(text, total_inp, total_out, cost, chosen_model, data)
|
|
357
|
+
|
|
358
|
+
# Append model turn to conversation
|
|
359
|
+
current_contents.append({"role": "model", "parts": parts})
|
|
360
|
+
|
|
361
|
+
# Execute tool and append functionResponse
|
|
362
|
+
response_parts = []
|
|
363
|
+
for call in function_calls:
|
|
364
|
+
name = call.get("name", "")
|
|
365
|
+
args = call.get("args", {})
|
|
366
|
+
fn = tool_dispatch.get(name)
|
|
367
|
+
try:
|
|
368
|
+
result = fn(**args) if fn else {"status": "error", "message": f"Tool {name} not found"}
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
result = {"status": "error", "message": str(exc)}
|
|
371
|
+
|
|
372
|
+
response_parts.append(
|
|
373
|
+
{
|
|
374
|
+
"functionResponse": {
|
|
375
|
+
"name": name,
|
|
376
|
+
"response": {"output": result if isinstance(result, (dict, list)) else str(result)},
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
current_contents.append({"role": "user", "parts": response_parts})
|
|
381
|
+
|
|
382
|
+
raise RuntimeError(f"Gemini converse exceeded max_turns={max_turns}")
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _sanitize_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
"""Strip keywords unsupported by Gemini JSON Schema parser."""
|
|
387
|
+
s: dict[str, Any] = dict(json.loads(json.dumps(schema)))
|
|
388
|
+
for key in ("title", "$defs", "$schema", "default"):
|
|
389
|
+
s.pop(key, None)
|
|
390
|
+
return s
|
nexus/ai/pricing.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Token pricing and spend tracking for Vertex AI models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
# Vertex AI Gemini 3.8 Flash pricing per million tokens
|
|
8
|
+
GEMINI_3_8_FLASH_INPUT_PER_M: Final[float] = 0.075
|
|
9
|
+
GEMINI_3_8_FLASH_OUTPUT_PER_M: Final[float] = 0.30
|
|
10
|
+
|
|
11
|
+
# Fallback default model
|
|
12
|
+
DEFAULT_MODEL: Final[str] = "gemini-3.8-flash"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def calculate_cost_usd(
|
|
16
|
+
input_tokens: int,
|
|
17
|
+
output_tokens: int,
|
|
18
|
+
model: str = DEFAULT_MODEL,
|
|
19
|
+
) -> float:
|
|
20
|
+
"""Calculate the estimated USD cost for a model call."""
|
|
21
|
+
if "gemini-3.8" in model or "gemini-3" in model:
|
|
22
|
+
in_cost = (input_tokens / 1_000_000.0) * GEMINI_3_8_FLASH_INPUT_PER_M
|
|
23
|
+
out_cost = (output_tokens / 1_000_000.0) * GEMINI_3_8_FLASH_OUTPUT_PER_M
|
|
24
|
+
return in_cost + out_cost
|
|
25
|
+
# Standard Gemini 2.x / 1.5 pricing baseline
|
|
26
|
+
in_cost = (input_tokens / 1_000_000.0) * 0.15
|
|
27
|
+
out_cost = (output_tokens / 1_000_000.0) * 0.60
|
|
28
|
+
return in_cost + out_cost
|
nexus/api/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Nexus API package: Application factory and middleware."""
|
|
2
|
+
|
|
3
|
+
from nexus.ai.client import GeminiClient
|
|
4
|
+
from nexus.api.app import create_nexus_app
|
|
5
|
+
from nexus.api.middleware import NexusTelemetryMiddleware
|
|
6
|
+
from nexus.db.base import Base, NexusEntity
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Base",
|
|
10
|
+
"GeminiClient",
|
|
11
|
+
"NexusEntity",
|
|
12
|
+
"NexusTelemetryMiddleware",
|
|
13
|
+
"create_nexus_app",
|
|
14
|
+
]
|