Merge remote-tracking branch github/main

This commit is contained in:
Chris Christiansen 2026-06-23 11:15:16 +00:00
commit 4610521e73
5 changed files with 279 additions and 145 deletions

View File

@ -13,6 +13,7 @@ import google.auth
import google.auth.transport.requests import google.auth.transport.requests
from typing import Literal from typing import Literal
import vertexai
from google.adk.agents import Agent from google.adk.agents import Agent
from google.adk.tools import FunctionTool from google.adk.tools import FunctionTool
from google.adk.runners import Runner from google.adk.runners import Runner
@ -22,16 +23,24 @@ from google.genai import types
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "global") LOCATION = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")
# Initialiser Vertex AI eksplisitt — ADK krev dette for Cloud Run
vertexai.init(project=PROJECT_ID, location=LOCATION)
ORCHESTRATOR_MODEL = os.environ.get("ORCHESTRATOR_MODEL", "gemini-2.5-flash") ORCHESTRATOR_MODEL = os.environ.get("ORCHESTRATOR_MODEL", "gemini-2.5-flash")
SUBAGENT_MODEL = os.environ.get("SUBAGENT_MODEL", "gemini-2.5-flash") SUBAGENT_MODEL = os.environ.get("SUBAGENT_MODEL", "gemini-2.5-flash")
REASONING_MODEL = os.environ.get("REASONING_MODEL", "gemini-2.5-flash") REASONING_MODEL = os.environ.get("REASONING_MODEL", "gemini-2.5-flash")
HEAVY_ORCHESTRATOR = os.environ.get("HEAVY_ORCHESTRATOR_MODEL", "gemini-2.5-pro") HEAVY_ORCHESTRATOR = os.environ.get("HEAVY_ORCHESTRATOR_MODEL", "gemini-2.5-pro")
HEAVY_SUBAGENT = os.environ.get("HEAVY_SUBAGENT_MODEL", "gemini-2.5-pro") HEAVY_SUBAGENT = os.environ.get("HEAVY_SUBAGENT_MODEL", "gemini-2.5-pro")
HEAVY_REASONING = os.environ.get("HEAVY_REASONING_MODEL", "gemini-2.5-flash") HEAVY_REASONING = os.environ.get("HEAVY_REASONING_MODEL", "gemini-2.5-flash")
BUDGET_LIGHT = float(os.environ.get("BUDGET_A_USD_PER_TASK", "1.0")) BUDGET_LIGHT = float(os.environ.get("BUDGET_A_USD_PER_TASK", "1.0"))
BUDGET_HEAVY = float(os.environ.get("HEAVY_MODE_BUDGET_USD_PER_DAY", "3.0")) BUDGET_HEAVY = float(os.environ.get("HEAVY_MODE_BUDGET_USD_PER_DAY", "3.0"))
HEAVY_MODE_ALLOWED_USERS = ["opax", "admin"] HEAVY_MODE_ALLOWED_USERS = ["opax", "admin"]
Mode = Literal["light", "heavy"] Mode = Literal["light", "heavy"]
APP_NAME = "opax" APP_NAME = "opax"
@ -43,23 +52,34 @@ except ImportError:
try: try:
import sys as _sys, pathlib as _pathlib import sys as _sys, pathlib as _pathlib
_sys.path.insert(0, str(_pathlib.Path(__file__).parent.parent.parent / "ml"))
from token_budget import trim_context, route_model, TokenBudgetExceeded, budget_summary from token_budget import trim_context, route_model, TokenBudgetExceeded, budget_summary
logger.info(f"[agent] token_budget lastet: {budget_summary()}")
except ImportError as _e: except ImportError as _e:
logger.warning(f"[agent] token_budget ikke tilgjengelig: {_e}")
def trim_context(history, system_prompt="", max_tokens=32000): def trim_context(history, system_prompt="", max_tokens=32000):
return history[-10:] if len(history) > 10 else history return history[-10:] if len(history) > 10 else history
def route_model(message, mode, flash_model, pro_model): def route_model(message, mode, flash_model, pro_model):
return flash_model if mode != "heavy" else pro_model return flash_model if mode != "heavy" else pro_model
class TokenBudgetExceeded(Exception): pass class TokenBudgetExceeded(Exception): pass
_OPAX_MCP_URL = os.environ.get("MCP_SERVER_URL", "https://opax-mcp-zjbqp3prqq-uc.a.run.app") create_bq_table_if_not_exists()
# ---------------------------------------------------------------------------
# OPAX MCP Client (inlina)
# ---------------------------------------------------------------------------
_OPAX_MCP_URL = os.environ.get("MCP_SERVER_URL", "https://opax-mcp-357036551735.us-central1.run.app")
_MCP_SECRET = os.environ.get("MCP_SECRET", "") _MCP_SECRET = os.environ.get("MCP_SECRET", "")
def _identity_token() -> str: def _identity_token() -> str:
metadata_url = (
"http://metadata.google.internal/computeMetadata/v1/instance"
f"/service-accounts/default/identity?audience={_OPAX_MCP_URL}&format=full"
)
try: try:
resp = httpx.get( resp = httpx.get(metadata_url, headers={"Metadata-Flavor": "Google"}, timeout=5)
f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={_OPAX_MCP_URL}&format=full",
headers={"Metadata-Flavor": "Google"}, timeout=5)
if resp.status_code == 200 and resp.text.strip(): if resp.status_code == 200 and resp.text.strip():
return resp.text.strip() return resp.text.strip()
except Exception: except Exception:
@ -68,134 +88,199 @@ def _identity_token() -> str:
credentials.refresh(google.auth.transport.requests.Request()) credentials.refresh(google.auth.transport.requests.Request())
return credentials.token return credentials.token
def _mcp_headers() -> dict: def _mcp_headers() -> dict:
return {"Authorization": f"Bearer {_identity_token()}", "X-MCP-Secret": _MCP_SECRET, "Content-Type": "application/json"} return {
"Authorization": f"Bearer {_identity_token()}",
"X-MCP-Secret": _MCP_SECRET,
"Content-Type": "application/json",
}
def _call_tool(tool: str, params: dict = None) -> dict: def _call_tool(tool: str, params: dict = None) -> dict:
resp = httpx.post(f"{_OPAX_MCP_URL}/tools/call", headers=_mcp_headers(), json={"tool": tool, "params": params or {}}, timeout=30) resp = httpx.post(
f"{_OPAX_MCP_URL}/tools/call",
headers=_mcp_headers(),
json={"tool": tool, "params": params or {}},
timeout=30,
)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def get_billing_summary() -> dict: def get_billing_summary() -> dict:
"""Hent billing-oversikt for OSVauco (GCP-kostnader, token-forbruk).""" """Hent billing-oversikt for OSVauco (GCP-kostnader, token-forbruk)."""
return _call_tool("get_billing_summary") return _call_tool("get_billing_summary")
def get_billing_credits() -> dict: def get_billing_credits() -> dict:
"""Hent gjenværende GCP-kreditter og burn-rate.""" """Hent gjenværende GCP-kreditter og burn-rate."""
return _call_tool("get_billing_credits") return _call_tool("get_billing_credits")
def get_billing_anomalies() -> dict: def get_billing_anomalies() -> dict:
"""Sjekk for kostnadanomalier og uventede spiker.""" """Sjekk for kostnadanomalier og uventede spiker."""
return _call_tool("get_billing_anomalies") return _call_tool("get_billing_anomalies")
def get_billing_history() -> dict: def get_billing_history() -> dict:
"""Hent historisk kostnadsdata (90 dagar).""" """Hent historisk kostnadsdata (90 dagar)."""
return _call_tool("get_billing_history") return _call_tool("get_billing_history")
def get_billing_budget() -> dict: def get_billing_budget() -> dict:
"""Hent gjeldande budsjett for OSVauco.""" """Hent gjeldande budsjett for OSVauco."""
return _call_tool("get_billing_budget") return _call_tool("get_billing_budget")
def set_billing_budget(amount: float) -> dict: def set_billing_budget(amount: float) -> dict:
"""Sett nytt budsjettgrense. amount er beløpet i USD.""" """Sett nytt budsjettgrense. amount er beløpet i USD."""
return _call_tool("set_billing_budget", {"amount": amount}) return _call_tool("set_billing_budget", {"amount": amount})
def create_invite(email: str, company: str = "", tier: str = "starter") -> dict: def create_invite(email: str, company: str = "", tier: str = "starter") -> dict:
"""Opprett invite-link for ny kunde. tier: starter | guard | shield | enterprise.""" """Opprett invite-link for ny kunde. tier: starter | guard | shield | enterprise."""
return _call_tool("create_invite", {"email": email, "company": company, "tier": tier}) return _call_tool("create_invite", {"email": email, "company": company, "tier": tier})
def list_customers() -> dict: def list_customers() -> dict:
"""List alle onboardede kunder og systemtilstand.""" """List alle onboardede kunder og systemtilstand."""
return _call_tool("list_customers") return _call_tool("list_customers")
def send_webhook(message: str, url: str = "", title: str = "OPAX varsel") -> dict: def send_webhook(message: str, url: str = "", title: str = "OPAX varsel") -> dict:
"""Send webhook-varsling til Slack/Teams/Discord.""" """Send webhook-varsling til Slack/Teams/Discord."""
return _call_tool("send_webhook", {"message": message, "url": url, "title": title}) return _call_tool("send_webhook", {"message": message, "url": url, "title": title})
def send_email(to: str, subject: str, body: str = "") -> dict: def send_email(to: str, subject: str, body: str = "") -> dict:
"""Send e-post via SendGrid.""" """Send e-post via SendGrid."""
return _call_tool("send_email", {"to": to, "subject": subject, "body": body}) return _call_tool("send_email", {"to": to, "subject": subject, "body": body})
def get_notify_channels() -> dict: def get_notify_channels() -> dict:
"""List konfigurerte varslingskanalar.""" """List konfigurerte varslingskanalar."""
return _call_tool("get_notify_channels") return _call_tool("get_notify_channels")
def get_health() -> dict: def get_health() -> dict:
"""Sjekk helsestatus for OPAX-plattformen.""" """Sjekk helsestatus for OPAX-plattformen."""
return _call_tool("get_health") return _call_tool("get_health")
def get_build_status() -> dict: def get_build_status() -> dict:
"""Hent status på siste Cloud Build-kjøring.""" """Hent status på siste Cloud Build-kjøring."""
return _call_tool("get_build_status") return _call_tool("get_build_status")
def get_state() -> dict: def get_state() -> dict:
"""Hent gjeldande systemtilstand og agent-statistikk for OSVauco.""" """Hent gjeldande systemtilstand og agent-statistikk for OSVauco."""
return _call_tool("get_state") return _call_tool("get_state")
def get_telemetry() -> dict: def get_telemetry() -> dict:
"""Hent token-forbruk og ytingsdata per modul.""" """Hent token-forbruk og ytingsdata per modul."""
return _call_tool("get_telemetry") return _call_tool("get_telemetry")
def run_terminal(command: str) -> dict: def run_terminal(command: str) -> dict:
"""Kjør whitelist-kommando i OPAX terminal. Gyldige: health, billing, build, logs, help.""" """Kjør whitelist-kommando i OPAX terminal. Gyldige: health, billing, build, logs, help."""
return _call_tool("run_terminal", {"command": command}) return _call_tool("run_terminal", {"command": command})
def list_commits(limit: int = 10) -> dict: def list_commits(limit: int = 10) -> dict:
"""List siste commits i OSVauco-repoet på Gitea.""" """List siste commits i OSVauco-repoet på Gitea."""
return _call_tool("list_commits", {"limit": limit}) return _call_tool("list_commits", {"limit": limit})
def get_file(path: str) -> dict: def get_file(path: str) -> dict:
"""Hent innhald i ein fil frå Gitea-repoet.""" """Hent innhald i ein fil frå Gitea-repoet."""
return _call_tool("get_file", {"path": path}) return _call_tool("get_file", {"path": path})
def list_open_issues() -> dict: def list_open_issues() -> dict:
"""List alle opne issues i OSVauco Gitea-repoet.""" """List alle opne issues i OSVauco Gitea-repoet."""
return _call_tool("list_open_issues", {}) return _call_tool("list_open_issues", {})
def create_issue(title: str, body: str = "") -> dict: def create_issue(title: str, body: str = "") -> dict:
"""Opprett nytt issue i Gitea-repoet.""" """Opprett nytt issue i Gitea-repoet."""
return _call_tool("create_issue", {"title": title, "body": body}) return _call_tool("create_issue", {"title": title, "body": body})
def push_file(path: str, content: str, message: str = "", sha: str = "") -> dict: def push_file(path: str, content: str, message: str = "", sha: str = "") -> dict:
"""Push/oppdater ein fil i Gitea.""" """Push/oppdater ein fil i Gitea."""
return _call_tool("push_file", {"path": path, "content": content, "message": message, "sha": sha}) return _call_tool("push_file", {"path": path, "content": content, "message": message, "sha": sha})
OPAX_TOOLS = [ OPAX_TOOLS = [
FunctionTool(func=get_billing_summary), FunctionTool(func=get_billing_credits), FunctionTool(func=get_billing_summary),
FunctionTool(func=get_billing_anomalies), FunctionTool(func=get_billing_history), FunctionTool(func=get_billing_credits),
FunctionTool(func=get_billing_budget), FunctionTool(func=set_billing_budget), FunctionTool(func=get_billing_anomalies),
FunctionTool(func=create_invite), FunctionTool(func=list_customers), FunctionTool(func=get_billing_history),
FunctionTool(func=send_webhook), FunctionTool(func=send_email), FunctionTool(func=get_billing_budget),
FunctionTool(func=get_notify_channels), FunctionTool(func=get_health), FunctionTool(func=set_billing_budget),
FunctionTool(func=get_build_status), FunctionTool(func=get_state), FunctionTool(func=create_invite),
FunctionTool(func=get_telemetry), FunctionTool(func=run_terminal), FunctionTool(func=list_customers),
FunctionTool(func=list_commits), FunctionTool(func=get_file), FunctionTool(func=send_webhook),
FunctionTool(func=list_open_issues), FunctionTool(func=create_issue), FunctionTool(func=send_email),
FunctionTool(func=get_notify_channels),
FunctionTool(func=get_health),
FunctionTool(func=get_build_status),
FunctionTool(func=get_state),
FunctionTool(func=get_telemetry),
FunctionTool(func=run_terminal),
FunctionTool(func=list_commits),
FunctionTool(func=get_file),
FunctionTool(func=list_open_issues),
FunctionTool(func=create_issue),
FunctionTool(func=push_file), FunctionTool(func=push_file),
] ]
# ---------------------------------------------------------------------------
# Agent logic
# ---------------------------------------------------------------------------
def _normalize_mode(mode: str) -> str: def _normalize_mode(mode: str) -> str:
mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"} mapping = {"A": "light", "A+": "heavy", "light": "light", "heavy": "heavy"}
if mode not in mapping: if mode not in mapping:
raise ValueError(f"Invalid mode '{mode}'. Must be 'light' or 'heavy'.") raise ValueError(f"Invalid mode '{mode}'. Must be 'light' or 'heavy'.")
return mapping[mode] return mapping[mode]
def authorize_mode(user_id: str, mode: str) -> None: def authorize_mode(user_id: str, mode: str) -> None:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
if mode == "heavy" and user_id not in HEAVY_MODE_ALLOWED_USERS: if mode == "heavy" and user_id not in HEAVY_MODE_ALLOWED_USERS:
raise PermissionError(f"User '{user_id}' is not authorized for heavy mode.") raise PermissionError(f"User '{user_id}' is not authorized for heavy mode.")
def get_models_for_mode(mode: Mode) -> dict: def get_models_for_mode(mode: Mode) -> dict:
if mode == "heavy": if mode == "heavy":
return {"orchestrator": HEAVY_ORCHESTRATOR, "subagent": HEAVY_SUBAGENT, "reasoning": HEAVY_REASONING, "budget_usd": BUDGET_HEAVY} return {"orchestrator": HEAVY_ORCHESTRATOR, "subagent": HEAVY_SUBAGENT, "reasoning": HEAVY_REASONING, "budget_usd": BUDGET_HEAVY}
return {"orchestrator": ORCHESTRATOR_MODEL, "subagent": SUBAGENT_MODEL, "reasoning": REASONING_MODEL, "budget_usd": BUDGET_LIGHT} return {"orchestrator": ORCHESTRATOR_MODEL, "subagent": SUBAGENT_MODEL, "reasoning": REASONING_MODEL, "budget_usd": BUDGET_LIGHT}
_INSTRUCTION_TEMPLATE = ( _INSTRUCTION_TEMPLATE = (
"Du er Jason Vauger — OPAX-agenten for Vauco AS. Modus: {mode}. Modell: {model}. " "Du er Jason Vauger — OPAX-agenten for Vauco AS. Modus: {mode}. Modell: {model}. "
"Du har tilgang til alle OPAX-tools via opax-mcp. " "Du har tilgang til alle OPAX-tools via opax-mcp. "
"Tools: list_commits, get_file, push_file, create_issue, list_open_issues (Gitea), "
"get_billing_summary, get_billing_credits, get_billing_anomalies, "
"get_build_status, get_state, get_telemetry, run_terminal, "
"create_invite, list_customers, send_webhook, send_email, get_notify_channels. "
"Når brukaren ber om data — KALL ALLTID rett tool før du svarar. " "Når brukaren ber om data — KALL ALLTID rett tool før du svarar. "
"Svar på norsk (bokmål) med mindre annet er bedt om. " "Svar på norsk (bokmål) med mindre annet er bedt om. "
"HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris." "HITL: ikkje kjør terraform/deploy utan godkjenning frå Chris."
) )
def build_agent(mode: str = "light", message: str = "") -> tuple: def build_agent(mode: str = "light", message: str = "") -> tuple:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
models = get_models_for_mode(mode) models = get_models_for_mode(mode)
actual_model = route_model(message=message, mode=mode, flash_model=ORCHESTRATOR_MODEL, pro_model=models["orchestrator"]) actual_model = route_model(message=message, mode=mode, flash_model=ORCHESTRATOR_MODEL, pro_model=models["orchestrator"])
instruction = _INSTRUCTION_TEMPLATE.format(mode=mode, model=actual_model) instruction = _INSTRUCTION_TEMPLATE.format(mode=mode, model=actual_model)
agent = Agent(model=actual_model, name="jason_vauger", description=f"Jason Vauger — OPAX enterprise agent [{mode}]", instruction=instruction, tools=OPAX_TOOLS) agent = Agent(
model=actual_model,
name="jason_vauger",
description=f"Jason Vauger — OPAX enterprise agent [{mode}]",
instruction=instruction,
tools=OPAX_TOOLS,
)
return agent, actual_model, instruction return agent, actual_model, instruction
root_agent, _, _ = build_agent(mode="light") root_agent, _, _ = build_agent(mode="light")
async def _run_async(message: str, user_id: str, session_id: str, mode: str, caller_type: str = "agent") -> str: async def _run_async(message: str, user_id: str, session_id: str, mode: str, caller_type: str = "agent") -> str:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
agent, actual_model, instruction = build_agent(mode=mode, message=message) agent, actual_model, instruction = build_agent(mode=mode, message=message)
module_name = f"jason/{mode}" module_name = f"jason/{mode}"
try: try:
trim_context([types.Content(role="user", parts=[types.Part(text=message)])], system_prompt=instruction) trim_context([types.Content(role="user", parts=[types.Part(text=message)])], system_prompt=instruction)
except TokenBudgetExceeded as e: except TokenBudgetExceeded as e:
return f"⚠️ Token-budsjett overskredet: {e}" return f"⚠️ Token-budsjett overskredet: {e}"
session_service = InMemorySessionService() session_service = InMemorySessionService()
session = await session_service.create_session(app_name=APP_NAME, user_id=user_id, session_id=session_id) session = await session_service.create_session(app_name=APP_NAME, user_id=user_id, session_id=session_id)
runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service) runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service)
@ -204,6 +289,7 @@ async def _run_async(message: str, user_id: str, session_id: str, mode: str, cal
input_tokens = 0 input_tokens = 0
output_tokens = 0 output_tokens = 0
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=new_message): async for event in runner.run_async(user_id=user_id, session_id=session.id, new_message=new_message):
if event.is_final_response() and event.content and event.content.parts: if event.is_final_response() and event.content and event.content.parts:
final_text = event.content.parts[0].text or "" final_text = event.content.parts[0].text or ""
@ -211,15 +297,24 @@ async def _run_async(message: str, user_id: str, session_id: str, mode: str, cal
um = event.usage_metadata um = event.usage_metadata
input_tokens += getattr(um, "prompt_token_count", 0) or 0 input_tokens += getattr(um, "prompt_token_count", 0) or 0
output_tokens += getattr(um, "candidates_token_count", 0) or 0 output_tokens += getattr(um, "candidates_token_count", 0) or 0
if input_tokens > 0 or output_tokens > 0: if input_tokens > 0 or output_tokens > 0:
log_token_usage(agent_name=module_name, model_name=actual_model, input_tokens=input_tokens, output_tokens=output_tokens, request_id=request_id, module_name=module_name, caller_type=caller_type, session_id=session_id) log_token_usage(
agent_name=module_name, model_name=actual_model,
input_tokens=input_tokens, output_tokens=output_tokens,
request_id=request_id, module_name=module_name,
caller_type=caller_type, session_id=session_id,
)
logger.info(f"[agent] {mode}/{actual_model} in={input_tokens} out={output_tokens} caller={caller_type} session={session_id}")
return final_text return final_text
def run(message: str, user_id: str = "opax", session_id: str = "default", mode: str = "light", caller_type: str = "agent") -> str: def run(message: str, user_id: str = "opax", session_id: str = "default", mode: str = "light", caller_type: str = "agent") -> str:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
authorize_mode(user_id, mode) authorize_mode(user_id, mode)
return asyncio.run(_run_async(message=message, user_id=user_id, session_id=session_id, mode=mode, caller_type=caller_type)) return asyncio.run(_run_async(message=message, user_id=user_id, session_id=session_id, mode=mode, caller_type=caller_type))
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)

View File

@ -1,39 +1,51 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""
deploy_agent.py Deploy ADK agent til Vertex AI Agent Engine
Usage: python3 deploy_agent.py [--project PROJECT_ID] [--region REGION] \
[--display-name DISPLAY_NAME] [--staging-bucket GS_URI]
Krever: google-cloud-aiplatform>=1.157.0, google-adk>=2.0.0
"""
import argparse import argparse
import sys import sys
from pathlib import Path
import vertexai import vertexai
from vertexai import agent_engines from vertexai import agent_engines # riktig import for SDK >= 1.157.0
def deploy(project, region, display_name, staging_bucket):
def deploy(project: str, region: str, display_name: str, staging_bucket: str):
print(f"Initialiserer Vertex AI: project={project}, region={region}") print(f"Initialiserer Vertex AI: project={project}, region={region}")
vertexai.init(project=project, location=region, staging_bucket=staging_bucket) vertexai.init(project=project, location=region, staging_bucket=staging_bucket)
base_dir = Path(__file__).resolve().parent # Importer Jason ADK agent
sys.path.insert(0, str(base_dir)) sys.path.insert(0, ".")
import agent as my_agent
import agent as _agent_module print(f"Deployer agent '{display_name}' til Vertex AI Agent Engine i {region}...")
root_agent = _agent_module.root_agent remote_agent = agent_engines.create(
my_agent.root_agent,
print(f"Deployer agent '{display_name}'...")
remote = agent_engines.create(
root_agent,
requirements=[ requirements=[
"google-cloud-aiplatform[adk,agent_engines]>=1.157.0", "google-cloud-aiplatform[adk,agent_engines]>=1.157.0",
"google-adk>=2.2.0", "google-adk>=2.2.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"google-auth>=2.29.0", "google-auth>=2.29.0",
], ],
extra_packages=[str(base_dir / "agent.py")], extra_packages=["./opax_mcp_client.py"],
display_name=display_name, display_name=display_name,
) )
print(f"\n✅ Agent deployet!\n Resource name: {remote.resource_name}") print(f"\n✅ Agent deployet!")
print(f" Resource name : {remote_agent.resource_name}")
print(f" Console : https://console.cloud.google.com/ai/agents?project={project}")
print(f" Region : {region}")
print("\n⚠️ Husk: kjør teardown når du er ferdig for å unngå unnødige kostnader.")
return remote_agent
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="Deploy Jason ADK agent til Vertex AI")
parser.add_argument("--project", default="propane-will-491900-m5") parser.add_argument("--project", default="propane-will-491900-m5")
parser.add_argument("--region", default="us-central1") parser.add_argument("--region", default="us-central1")
parser.add_argument("--display-name", default="jason-vauger-v12") parser.add_argument("--display-name", default="jason-vauger-v1")
parser.add_argument("--staging-bucket", default="gs://propane-will-491900-m5-agent-staging") parser.add_argument("--staging-bucket", default="gs://propane-will-491900-m5-agent-staging")
args = parser.parse_args() args = parser.parse_args()
deploy(args.project, args.region, args.display_name, args.staging_bucket) deploy(args.project, args.region, args.display_name, args.staging_bucket)

View File

@ -1,6 +1,6 @@
""" """
opax_mcp_client.py REST-klient for opax-mcp Cloud Run service. opax_mcp_client REST-klient for opax-mcp Cloud Run service.
Kopiert inn i core-logic/ slik at Vertex AI Agent Engine finn den ved deploy. Pakke-struktur for Vertex AI Agent Engine extra_packages-kompatibilitet.
Auth-lag: Auth-lag:
1. Cloud Run IAM : Authorization: Bearer <identity-token> (automatisk) 1. Cloud Run IAM : Authorization: Bearer <identity-token> (automatisk)

View File

@ -1,37 +1,14 @@
# HANDOFF — 2026-06-19
## Status
Deploy SUCCESS kl 23:10 CEST — build 6af0bb06
## Problem løst
OPAX-hub (opax.vauco.no) hadde ikke-fungerende chat, panel-switching og suggest-knapper.
## Rot-årsak
Alle fetch()-kall manglet `credentials: 'include'` — IAP-cookies ble ikke sendt med API-requests.
## Fix
Lagt til `credentials: 'include'` i:
- fetchWithTimeout() — linje 804
- sendMessage() fetch til /run — linje 1102
- termKey() fetch til /terminal/exec — linje 1050
## Neste sesjon
- Verifiser at Jason-chat fungerer med IAP-auth i nettleser
- Sjekk at panel-switching og build-status oppdateres korrekt
---
# HANDOFF — Vauco OS # HANDOFF — Vauco OS
**Sist oppdatert:** 2026-06-15 19:46 CEST **Sist oppdatert:** 2026-06-18 14:00 CEST
**Skrevet av:** Perplexity (Orchestrator) **Skrevet av:** Perplexity (Orchestrator)
**Status:** Fase B — Gitea er primær Git. GitHub er fasa ut. **Status:** Fase B — Gitea er primær Git. GitHub er fasa ut.
--- ---
## ⚠️ KRITISKE REGLER — les alltid først ## ⚠️ KRITISKE REGLER — les alltid først
- **Hovudapp:** `main.py` i rot — IKKJE `agents/core-logic/app.py` - **Hovedapp:** `main.py` i rot — IKKJE `agents/core-logic/app.py`
- **Dockerfile:** `agents/core-logic/Dockerfile` — WORKDIR `/app`, CMD uvicorn main:app - **Dockerfile:** `agents/core-logic/Dockerfile` — WORKDIR `/app`, CMD uvicorn main:app
- **Region:** `us-central1` | **Service:** `osvauco-agent` | **Prosjekt:** `propane-will-491900-m5` - **Region:** `us-central1` | **Service:** `osvauco-agent` | **Prosjekt:** `propane-will-491900-m5`
- **Test via proxy:** `gcloud run services proxy osvauco-agent --region us-central1 --project propane-will-491900-m5 --port 8181` - **Test via proxy:** `gcloud run services proxy osvauco-agent --region us-central1 --project propane-will-491900-m5 --port 8181`
@ -44,7 +21,20 @@ Lagt til `credentials: 'include'` i:
--- ---
## ✅ Fullført i dag (2026-06-15) ## ✅ Fullført i dag (2026-06-18) — IAP Auth fix for opax-mcp
| Oppgave | Status |
|---------|--------|
| Identifisert rotårsak: feil IAP audience i identity token-kall | ✅ |
| Lagt til `roles/iap.httpsResourceAccessor` for `jason-vauger` SA | ✅ |
| Oppdatert `opax-mcp/server.py` — ny env var `OPAX_IAP_CLIENT_ID` som audience | ✅ |
| Lagt til `OPAX_IAP_CLIENT_ID` env var i Cloud Run (revisjon 00019 — feil navn `IAP_CLIENT_ID`) | ⚠️ |
| Rettet env var navn til `OPAX_IAP_CLIENT_ID` i Cloud Run — revisjon 00020 deployed | ✅ |
| Revisjon `opax-mcp-00020-w6j` aktiv med 100% trafikk | ✅ |
---
## ✅ Fullført tidligere (2026-06-15)
| Oppgave | Status | | Oppgave | Status |
|---------|--------| |---------|--------|
@ -53,7 +43,7 @@ Lagt til `credentials: 'include'` i:
| Jason deployet til Vertex AI Agent Engine | ✅ | | Jason deployet til Vertex AI Agent Engine | ✅ |
| Jason svarer på norsk og kallar tools automatisk | ✅ | | Jason svarer på norsk og kallar tools automatisk | ✅ |
| Gitea installert på VM (`34.59.131.162:3000`) | ✅ | | Gitea installert på VM (`34.59.131.162:3000`) | ✅ |
| Gitea køyrer som systemd-teneste (overlever reboot) | ✅ | | Gitea kjøyrer som systemd-teneste (overlever reboot) | ✅ |
| Mirror-repo sletta og erstatta med vanleg repo | ✅ | | Mirror-repo sletta og erstatta med vanleg repo | ✅ |
| **2867 commits pusha frå GitHub → Gitea** | ✅ | | **2867 commits pusha frå GitHub → Gitea** | ✅ |
| Gitea API-tokens i Secret Manager | ✅ | | Gitea API-tokens i Secret Manager | ✅ |
@ -61,92 +51,116 @@ Lagt til `credentials: 'include'` i:
--- ---
## 🏗 Systemstatus ## 🖥 Systemstatus
| Komponent | URL / Referanse | Status | | Komponent | URL / Referanse | Status |
|-----------|-----------------|--------| |-----------|-----------------|--------|
| `osvauco-agent` Cloud Run | `us-central1` | ✅ Live | | osvauco-agent (Cloud Run) | https://osvauco-agent-zjbgp3prqg-uc.a.run.app | ✅ Live |
| `opax-mcp` Cloud Run | `https://opax-mcp-zjbqp3prqq-uc.a.run.app` | ✅ Live (25 tools) | | opax-mcp (Cloud Run) | https://opax-mcp-357036551735.us-central1.run.app | ✅ Live (rev 00020) |
| Jason Vertex AI Agent Engine | `reasoningEngines/237959055262351360` | ✅ Live | | opax frontend (IAP LB) | https://opax.vauco.no | ✅ Live |
| **Gitea (primær Git)** | `http://34.59.131.162:3000/chris/OSVauco` | ✅ Live + systemd | | Jason (Vertex AI Agent) | Agent Engine, us-central1 | ✅ Live |
| GitHub OSVauco | `https://github.com/vauco-saas/OSVauco` | ❌ Fasa ut | | Gitea (self-hosted) | http://34.59.131.162:3000 | ✅ Live |
| `opax.vauco.no` | — | ✅ Live | | GitHub (legacy) | https://github.com/vauco-saas/OSVauco | ⚠️ Fasa ut |
| Emma (Gemma, lokal VM) | — | ⏳ Neste fase |
--- ---
## 🔑 Credentials ## 🔑 Credentials
| System | Brukar | Verdi | - **Gitea login:** `chris` / se Secret Manager
|--------|--------|-------| - **Gitea API-token:** Secret Manager → `gitea-api-token`
| Gitea | `chris` | `OSVauco@Gitea2026!` | - **MCP-Secret:** Secret Manager → `mcp-secret`
| Gitea API token (jason-token2) | — | Secret Manager: `gitea-api-token` (`278c0b41...`) | - **Prosjekt:** `propane-will-491900-m5`
| Gitea API token (admin-token2) | — | `94527a0f5353c4b1db29a007b98f340b5fd1f385` | - **IAP OAuth Client ID (opax backend):** `357036551735-kq8nt71d38hfqlcfb3n52ef7ala4meo.apps.googleusercontent.com`
| opax-mcp MCP_SECRET | — | Secret Manager: `mcp-server-key` | - **IAP Backend Service path:** `/projects/357036551735/global/backendServices/<navn>` (satt som `OPAX_IAP_CLIENT_ID` i Cloud Run)
| Jason Agent Engine | — | `reasoningEngines/237959055262351360` |
--- ---
## 🟠 Gjenstår (prioritert) ## 🎯 Gjenstår (prioritert)
| # | Oppgave | Notat | 1. **KRITISK — Verifiser IAP auth end-to-end** — kjør verifikasjonskommando under og bekreft `HTTP/2 200`
|---|---------|-------| 2. Oppdater Gitea-klient-logikk i agenter (fra GitHub API → Gitea API)
| 1 | Oppdater `opax_mcp_client.py` GitHub-tools → Gitea API | Byt `api.github.com``34.59.131.162:3000/api/v1` | 3. DNS/TLS for Gitea (`git.vauco.no`)
| 2 | Cloud Build webhook → Gitea (ikkje GitHub) | Trigger på Gitea push | 4. GPU VM-arbeid (planlagt)
| 3 | DNS `git.vauco.no``34.59.131.162` | Domene for Gitea | 5. Smoke-test pipeline for opax-mcp etter IAP-fix
| 4 | HTTPS/TLS for Gitea (Caddy) | Etter DNS |
| 5 | Emma — Gemma lokal på ML-3a GPU VM | Eiga fase |
| 6 | Sett manglande env-vars på `osvauco-agent` | `SENDGRID_API_KEY`, `TWILIO_*`, `NOTIFY_*` |
--- ---
## 📌 Neste sesjon — start her ## 🚀 Neste sesjon — start her
### 1. Verifiser IAP auth fix (VIKTIGST)
Kjør fra `osvauco-dev-vm`:
```bash ```bash
# 1. Verifiser Gitea # Hent identity token med korrekt IAP audience
curl -s http://34.59.131.162:3000/api/v1/repos/chris/OSVauco TOKEN=$(curl -s -H "Metadata-Flavor: Google" \
-H "Authorization: token 94527a0f5353c4b1db29a007b98f340b5fd1f385" "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=357036551735-kq8nt71d38hfqlcfb3n52ef7ala4meo.apps.googleusercontent.com&format=full")
| python3 -c "import sys,json; r=json.load(sys.stdin); print('mirror:', r['mirror'], '| commits OK')"
# 2. Verifiser Jason # Test mot opax via IAP load balancer
cd ~/OSVauco/agents/core-logic && source .venv/bin/activate curl -si https://opax.vauco.no/opax/build-status \
python3 - <<'EOF' -H "Authorization: Bearer $TOKEN" | head -5
import vertexai
from vertexai import agent_engines
vertexai.init(project="propane-will-491900-m5", location="us-central1")
agent = agent_engines.get("projects/357036551735/locations/us-central1/reasoningEngines/237959055262351360")
session = agent.create_session(user_id="chris")
for event in agent.stream_query(user_id="chris", session_id=session["id"], message="Status?"):
if event.get("content"):
print(event["content"]["parts"][0].get("text", ""), end="")
EOF
# 3. Neste: oppdater opax_mcp_client.py til Gitea API
``` ```
Forventet: `HTTP/2 200` (tidligere: `HTTP/2 401`)
### 2. Sjekk Jason sin status
```bash
curl -s https://osvauco-agent-zjbgp3prqg-uc.a.run.app/health \
-H "Authorization: Bearer $(gcloud auth print-identity-token)"
```
### 3. Hva som ble fikset i opax-mcp/server.py
**Fil:** `opax-mcp/server.py` — commit `9542106` ("OPAX_IAP_CLIENT_ID som IAP audience i identitytoken")
**Endring linje 17:**
```python
# FØR:
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
# ETTER:
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "")
```
**Endring linje 37 (`_opax_identity_token`):**
```python
# FØR:
f"...identity?audience={OPAX_BASE_URL}&format=full"
# ETTER:
f"...identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full"
```
**Cloud Run env vars på opax-mcp (aktive i rev 00020):**
| Navn | Verdi |
|------|-------|
| `OPAX_BASE_URL` | `https://opax.vauco.no` |
| `OPAX_IAP_CLIENT_ID` | `/projects/357036551735/global/backendServices/...` |
| `MCP_SECRET` | (fra Secret Manager) |
| `GOOGLE_CLOUD_PROJECT` | `propane-will-491900-m5` |
--- ---
## 🗺 Arkitektur ## 🏗 Arkitektur
``` ```
Chris (naturleg språk) Bruker
└─► Jason (Vertex AI Agent Engine, Gemini 2.5 Pro)
Jason Vauger (Vertex AI Agent Engine — Gemini 2.5 Flash) └─► osvauco-agent (Cloud Run)
reasoningEngines/237959055262351360 └─► opax-mcp (Cloud Run)
└─► [IAP] opax.vauco.no
opax-mcp (Cloud Run — 25 tools) └─► OPAX backend (GCP)
https://opax-mcp-zjbqp3prqq-uc.a.run.app
┌─────────────────────────────────┐
│ Gitea 34.59.131.162:3000 │ ← Eigen Git (PRIMÆR)
│ GCP Cloud Build / Run │ ← CI/CD + hosting
│ GitHub — FASA UT │ ← Ikkje i bruk lenger
└─────────────────────────────────┘
``` ```
--- **Auth-flyt for opax-mcp → opax.vauco.no:**
```
*Jason Vauger — han blir den du trenger han til å bli.* opax-mcp container
→ GCE Metadata Service
## NESTE OPPGAVE → identity token med audience = IAP_CLIENT_ID
Verifiser full agent-loop: Jason mottar spørsmål → kaller opax-mcp → får svar fra opax.vauco.no uten feil. → Authorization: Bearer <token>
→ IAP validerer token
→ Request videresendt til OPAX backend
```

View File

@ -5,6 +5,8 @@ Auth: Cloud Run IAM (Authorization header) + X-MCP-Secret header for tool-level
import os import os
import httpx import httpx
import base64 import base64
import google.auth
import google.auth.transport.requests
from fastapi import FastAPI, HTTPException, Header from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any from typing import Optional, Any
@ -12,16 +14,7 @@ from typing import Optional, Any
app = FastAPI(title="opax-mcp", version="2.0.0") app = FastAPI(title="opax-mcp", version="2.0.0")
OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no") OPAX_BASE_URL = os.environ.get("OPAX_BASE_URL", "https://opax.vauco.no")
IAP_CLIENT_ID = os.environ.get("IAP_CLIENT_ID", "357036551735-kq8nt7ld38hfqlcfb3n52ef7tala4meo.apps.googleusercontent.com") OPAX_IAP_CLIENT_ID = os.environ.get("OPAX_IAP_CLIENT_ID", "")
async def _iap_token() -> str:
"""Fetch IAP identity token from GCP metadata server."""
url = (f"http://metadata.google.internal/computeMetadata/v1/instance/"
f"service-accounts/default/identity?audience={IAP_CLIENT_ID}&format=full")
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url, headers={"Metadata-Flavor": "Google"})
r.raise_for_status()
return r.text.strip()
MCP_SECRET = os.environ.get("MCP_SECRET", "") MCP_SECRET = os.environ.get("MCP_SECRET", "")
# Gitea # Gitea
@ -37,6 +30,30 @@ def _auth_check(x_mcp_secret: Optional[str]):
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
def _opax_identity_token() -> str:
"""Hent identity token for opax.vauco.no (Cloud Run IAP/IAM)."""
metadata_url = (
"http://metadata.google.internal/computeMetadata/v1/instance"
f"/service-accounts/default/identity?audience={OPAX_IAP_CLIENT_ID or OPAX_BASE_URL}&format=full"
)
try:
resp = httpx.get(metadata_url, headers={"Metadata-Flavor": "Google"}, timeout=5)
if resp.status_code == 200 and resp.text.strip():
return resp.text.strip()
except Exception:
pass
credentials, _ = google.auth.default()
credentials.refresh(google.auth.transport.requests.Request())
return credentials.token
def _opax_headers() -> dict:
return {
"Authorization": f"Bearer {_opax_identity_token()}",
"Content-Type": "application/json",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Health # Health
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -79,19 +96,15 @@ async def list_tools(x_mcp_secret: Optional[str] = Header(default=None)):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _opax_get(path: str) -> Any: async def _opax_get(path: str) -> Any:
token = await _iap_token()
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{OPAX_BASE_URL}{path}", r = await client.get(f"{OPAX_BASE_URL}{path}", headers=_opax_headers())
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def _opax_post(path: str, body: dict) -> Any: async def _opax_post(path: str, body: dict) -> Any:
token = await _iap_token()
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{OPAX_BASE_URL}{path}", json=body, r = await client.post(f"{OPAX_BASE_URL}{path}", json=body, headers=_opax_headers())
headers={"Authorization": f"Bearer {token}"})
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@ -217,7 +230,7 @@ async def run_emma(p):
# Platform tools # Platform tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def get_health(p): return await _opax_get("/health") async def get_health(p): return await _opax_get("/health")
async def get_build_status(p): return await _opax_get("/opax/build-status") async def get_build_status(p): return await _opax_get("/opax/build-status")
async def get_state(p): return await _opax_get("/state") async def get_state(p): return await _opax_get("/state")
async def get_telemetry(p): return await _opax_get("/telemetry/history") async def get_telemetry(p): return await _opax_get("/telemetry/history")