diff --git a/python/autonomous/github-trending/.env.template b/python/autonomous/github-trending/.env.template index 80051925..f71d9856 100644 --- a/python/autonomous/github-trending/.env.template +++ b/python/autonomous/github-trending/.env.template @@ -1,23 +1,37 @@ # Azure OpenAI +# Both classic Azure OpenAI (.openai.azure.com) and Foundry endpoints +# (services.ai.azure.com / cognitiveservices.azure.com) are supported. +# Any path you paste (e.g. /api/projects/.../openai/v1/responses) is stripped +# automatically by main.py. AZURE_OPENAI_ENDPOINT=<> AZURE_OPENAI_API_KEY=<> AZURE_OPENAI_DEPLOYMENT=gpt-4o +# Pick an API version supported by your Azure OpenAI / Foundry resource (e.g. 2024-10-21 GA, 2024-11-20, 2025-01-01-preview). +# Foundry endpoints use /openai/v1 and ignore this value. +AZURE_OPENAI_API_VERSION=2024-10-21 # Agent 365 Observability -AGENT365_TENANT_ID=<> -AGENT365_AGENT_ID=<> -AGENT365_BLUEPRINT_ID=<> -AGENT365_CLIENT_ID=<> -AGENT365_CLIENT_SECRET=<> -AGENT365_AGENT_NAME=<> -AGENT365_AGENT_DESCRIPTION=<> +# Canonical (preferred) keys: agent365Observability__* convention used by Microsoft Agents SDK. +# Legacy AGENT365_* names are still accepted as a fallback by main.py. +AGENT365OBSERVABILITY__TENANTID=<> +AGENT365OBSERVABILITY__AGENTID=<> +AGENT365OBSERVABILITY__AGENTBLUEPRINTID=<> +AGENT365OBSERVABILITY__CLIENTID=<> +AGENT365OBSERVABILITY__CLIENTSECRET=<> +AGENT365OBSERVABILITY__AGENTNAME=<> +AGENT365OBSERVABILITY__AGENTDESCRIPTION=<> # Auth strategy: set to "true" for production (MSI), "false" for local dev (client secret) AGENT365_USE_MANAGED_IDENTITY=false -# Enables the A365 exporter to send spans to the observability endpoint -ENABLE_A365_OBSERVABILITY_EXPORTER=true +# Observability feature flags +# ENABLE_A365_OBSERVABILITY — master switch for the OpenTelemetry pipeline. +# ENABLE_A365_OBSERVABILITY_EXPORTER — when true, spans are uploaded to the A365 +# observability backend. When false (default), +# spans are produced but only printed to the +# console exporter. Set to true to ship traces. ENABLE_A365_OBSERVABILITY=true +ENABLE_A365_OBSERVABILITY_EXPORTER=false # GitHub Trending GITHUB_TRENDING_LANGUAGE=python diff --git a/python/autonomous/github-trending/README.md b/python/autonomous/github-trending/README.md index dc89b76e..d91ccdd4 100644 --- a/python/autonomous/github-trending/README.md +++ b/python/autonomous/github-trending/README.md @@ -54,27 +54,42 @@ a365 setup permissions custom --agent-name --resource-app-id 9 ### Configuration -Copy `.env.template` to `.env` and fill in the values from `a365 setup all` output: +Copy `.env.template` to `.env`. Most values are auto-stamped by `a365 setup all`; fill in the Azure OpenAI fields manually. ```bash cp .env.template .env ``` +#### Azure OpenAI + | Variable | Set by | Description | |----------|--------|-------------| -| `AZURE_OPENAI_ENDPOINT` | Manual | Azure OpenAI resource endpoint | +| `AZURE_OPENAI_ENDPOINT` | Manual | Azure OpenAI resource endpoint. Both classic Azure OpenAI (`*.openai.azure.com`) and Foundry / AI Services (`*.services.ai.azure.com`, `*.cognitiveservices.azure.com`) are supported. Any path you paste from the Foundry portal (e.g. `/api/projects/.../openai/v1/responses`) is stripped automatically. | | `AZURE_OPENAI_API_KEY` | Manual | Azure OpenAI API key | | `AZURE_OPENAI_DEPLOYMENT` | Manual | Model deployment name (default: `gpt-4o`) | -| `AGENT365_TENANT_ID` | CLI | Entra tenant ID | -| `AGENT365_AGENT_ID` | CLI | Agent identity ID (separate from blueprint) | -| `AGENT365_BLUEPRINT_ID` | CLI | Blueprint app registration ID | -| `AGENT365_CLIENT_ID` | CLI | Blueprint app ID (same as blueprint ID) | -| `AGENT365_CLIENT_SECRET` | CLI | Blueprint client secret | -| `AGENT365_AGENT_NAME` | CLI | Display name shown in traces | -| `AGENT365_AGENT_DESCRIPTION` | CLI | Agent description shown in traces | -| `AGENT365_USE_MANAGED_IDENTITY` | Manual | `true` for production (MSI), `false` for local dev | -| `ENABLE_A365_OBSERVABILITY_EXPORTER` | Manual | `true` to enable the A365 span exporter. Required for exporting traces to the A365 observability endpoint. | -| `ENABLE_A365_OBSERVABILITY` | Manual | `true` to enable SDK scope spans (`InvokeAgentScope`, `InferenceScope`, `ExecuteToolScope`). Required for the Python SDK. | +| `AZURE_OPENAI_API_VERSION` | Manual | API version for **classic** Azure OpenAI endpoints only. Foundry endpoints use `/openai/v1` and ignore this value. Default: `2024-10-21`. | + +#### Agent 365 observability credentials + +The Agent 365 CLI stamps these values directly into `.env` when you run `a365 setup all`. Both the canonical `AGENT365OBSERVABILITY__*` keys and legacy `AGENT365_*` aliases are accepted; the canonical keys are preferred and take precedence when both are present. + +| Variable (canonical) | Legacy alias | Set by | Description | +|----------------------|--------------|--------|-------------| +| `AGENT365OBSERVABILITY__TENANTID` | `AGENT365_TENANT_ID` | CLI | Entra tenant ID | +| `AGENT365OBSERVABILITY__AGENTID` | `AGENT365_AGENT_ID` | CLI | Agent identity ID (separate from blueprint) | +| `AGENT365OBSERVABILITY__AGENTBLUEPRINTID` | `AGENT365_BLUEPRINT_ID` | CLI | Blueprint app registration ID | +| `AGENT365OBSERVABILITY__CLIENTID` | `AGENT365_CLIENT_ID` | CLI | Blueprint app ID (same as blueprint ID) | +| `AGENT365OBSERVABILITY__CLIENTSECRET` | `AGENT365_CLIENT_SECRET` | CLI | Blueprint client secret | +| `AGENT365OBSERVABILITY__AGENTNAME` | `AGENT365_AGENT_NAME` | CLI | Display name shown in traces | +| `AGENT365OBSERVABILITY__AGENTDESCRIPTION` | `AGENT365_AGENT_DESCRIPTION` | CLI | Agent description shown in traces | +| `AGENT365_USE_MANAGED_IDENTITY` | — | Manual | `true` for production (MSI), `false` for local dev. **Defaults to `true` when unset.** | + +#### Observability feature flags + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENABLE_A365_OBSERVABILITY` | `true` | Master switch for the OpenTelemetry pipeline. Set `false` to skip `use_microsoft_opentelemetry()` entirely (no spans generated). | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | `false` | When `true`, spans are uploaded to the A365 observability backend. When `false`, spans are produced but only printed to the console exporter. **Set to `true` to ship traces to the A365 portal.** | ### GitHub Trending Configuration @@ -94,7 +109,11 @@ The GitHub Search API is unauthenticated — no API key required (rate limit: 10 You can run the agent with **just Azure OpenAI credentials** — no Agent 365 setup required. Create a minimal `.env`: ```bash +# Classic Azure OpenAI: AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +# — or Foundry / AI Services: +# AZURE_OPENAI_ENDPOINT=https://your-foundry-account.services.ai.azure.com/ + AZURE_OPENAI_API_KEY=your-api-key AZURE_OPENAI_DEPLOYMENT=gpt-4o ``` diff --git a/python/autonomous/github-trending/main.py b/python/autonomous/github-trending/main.py index b670a13b..7e13e6a0 100644 --- a/python/autonomous/github-trending/main.py +++ b/python/autonomous/github-trending/main.py @@ -11,11 +11,13 @@ import asyncio import logging import os +import re from datetime import datetime, timezone +from urllib.parse import urlparse from aiohttp import web from dotenv import load_dotenv -from openai import AsyncAzureOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from microsoft.opentelemetry import use_microsoft_opentelemetry from microsoft.opentelemetry.a365.core import AgentDetails @@ -43,20 +45,32 @@ if not AZURE_OPENAI_API_KEY: raise SystemExit("AZURE_OPENAI_API_KEY environment variable is required but not set.") AZURE_OPENAI_DEPLOYMENT = os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-4o") +# Only consumed by the classic AzureOpenAI client. Foundry's /openai/v1 path ignores +# (and in fact rejects) api-version, so this is effectively a default for non-Foundry endpoints. +AZURE_OPENAI_API_VERSION = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21") # Agent 365 Observability — optional. When these are missing or set to placeholders, # the agent runs without A365 observability export (spans go to console only). -TENANT_ID = os.environ.get("AGENT365_TENANT_ID", "") -AGENT_ID = os.environ.get("AGENT365_AGENT_ID", "") -BLUEPRINT_ID = os.environ.get("AGENT365_BLUEPRINT_ID", "") -CLIENT_ID = os.environ.get("AGENT365_CLIENT_ID", "") -CLIENT_SECRET = os.environ.get("AGENT365_CLIENT_SECRET", "") -AGENT_NAME = os.environ.get("AGENT365_AGENT_NAME", "github-trending") -AGENT_DESCRIPTION = os.environ.get("AGENT365_AGENT_DESCRIPTION", "") +# Read AGENT365OBSERVABILITY__* (canonical) with legacy AGENT365_* names as fallback +# for backward compatibility with older .env files. +TENANT_ID = os.environ.get("AGENT365OBSERVABILITY__TENANTID") or os.environ.get("AGENT365_TENANT_ID", "") +AGENT_ID = os.environ.get("AGENT365OBSERVABILITY__AGENTID") or os.environ.get("AGENT365_AGENT_ID", "") +BLUEPRINT_ID = os.environ.get("AGENT365OBSERVABILITY__AGENTBLUEPRINTID") or os.environ.get("AGENT365_BLUEPRINT_ID", "") +CLIENT_ID = os.environ.get("AGENT365OBSERVABILITY__CLIENTID") or os.environ.get("AGENT365_CLIENT_ID", "") +CLIENT_SECRET = os.environ.get("AGENT365OBSERVABILITY__CLIENTSECRET") or os.environ.get("AGENT365_CLIENT_SECRET", "") +AGENT_NAME = (os.environ.get("AGENT365OBSERVABILITY__AGENTNAME") or os.environ.get("AGENT365_AGENT_NAME", "github-trending")).strip('"') +AGENT_DESCRIPTION = os.environ.get("AGENT365OBSERVABILITY__AGENTDESCRIPTION") or os.environ.get("AGENT365_AGENT_DESCRIPTION", "") # Default to MSI in production (matches .NET appsettings.json default of true). # Local dev .env sets this to "false" to use client secret instead. USE_MANAGED_IDENTITY = os.environ.get("AGENT365_USE_MANAGED_IDENTITY", "true").lower() == "true" +# Observability feature flags (mirrors python/google-adk/sample-agent/main.py pattern). +# ENABLE_A365_OBSERVABILITY — master switch for the OpenTelemetry pipeline. +# ENABLE_A365_OBSERVABILITY_EXPORTER — when false, spans go to console only +# (no upload to the A365 backend). +ENABLE_A365_OBSERVABILITY = os.environ.get("ENABLE_A365_OBSERVABILITY", "true").lower() == "true" +ENABLE_A365_OBSERVABILITY_EXPORTER = os.environ.get("ENABLE_A365_OBSERVABILITY_EXPORTER", "false").lower() == "true" + def _has_a365_credentials() -> bool: """Check whether Agent 365 observability credentials are fully configured.""" required_values = [TENANT_ID, AGENT_ID, CLIENT_ID] @@ -85,18 +99,30 @@ def _has_a365_credentials() -> bool: ) A365_ENABLED = _has_a365_credentials() +# Exporter is only active when (a) the master observability flag is on, +# (b) credentials are configured, and (c) the exporter flag is on. +A365_EXPORTER_ENABLED = ENABLE_A365_OBSERVABILITY and A365_ENABLED and ENABLE_A365_OBSERVABILITY_EXPORTER # ── Microsoft OpenTelemetry Distro ─────────────────────────────────────────── # Equivalent to .NET's builder.UseMicrosoftOpenTelemetry(). # Token resolver reads from the in-memory cache populated by the background token service. -# When A365 credentials are not configured, the A365 exporter is disabled. - -use_microsoft_opentelemetry( - enable_a365=A365_ENABLED, - enable_azure_monitor=False, - a365_use_s2s_endpoint=True, - a365_token_resolver=lambda agent_id, tenant_id: token_cache.get_cached_token(agent_id, tenant_id) or "", -) +# When A365 credentials are not configured or observability is disabled, the +# A365 exporter is skipped and spans go to the console only. + +if ENABLE_A365_OBSERVABILITY: + use_microsoft_opentelemetry( + enable_a365=A365_EXPORTER_ENABLED, + enable_azure_monitor=False, + a365_use_s2s_endpoint=True, + a365_token_resolver=lambda agent_id, tenant_id: token_cache.get_cached_token(agent_id, tenant_id) or "", + ) + logger.info( + "Observability configured (a365_exporter=%s, credentials_present=%s)", + A365_EXPORTER_ENABLED, + A365_ENABLED, + ) +else: + logger.info("Observability disabled (ENABLE_A365_OBSERVABILITY=false)") # ── Health check endpoint ──────────────────────────────────────────────────── @@ -153,12 +179,40 @@ async def start_background_tasks(app: web.Application) -> None: # Heartbeat app["heartbeat_task"] = asyncio.create_task(heartbeat_loop(interval_seconds)) - # Azure OpenAI client — stored on the app for proper shutdown - client = AsyncAzureOpenAI( - azure_endpoint=AZURE_OPENAI_ENDPOINT, - api_key=AZURE_OPENAI_API_KEY, - api_version="2024-12-01-preview", + # OpenAI client — Foundry resources (services.ai.azure.com / cognitiveservices.azure.com) + # use the OpenAI-compatible /openai/v1 path which does NOT accept the api-version query. + # Classic Azure OpenAI resources (.openai.azure.com) use the legacy deployments path. + # This mirrors the Node.js sample at nodejs/autonomous/github-trending/src/github-trending-service.ts. + parsed = urlparse(AZURE_OPENAI_ENDPOINT) + if not parsed.scheme or not parsed.netloc: + raise SystemExit( + f"AZURE_OPENAI_ENDPOINT must be an absolute URL with a scheme (e.g. " + f"https://your-resource.openai.azure.com/ or " + f"https://your-foundry-account.services.ai.azure.com/). Got: {AZURE_OPENAI_ENDPOINT!r}" + ) + resource_endpoint = f"{parsed.scheme}://{parsed.netloc}" # strip any path pasted from the portal + use_foundry_v1_path = bool( + re.search(r"services\.ai\.azure\.com|cognitiveservices\.azure\.com", parsed.netloc, re.IGNORECASE) ) + + if use_foundry_v1_path: + # Foundry's /openai/v1 path expects authentication via the `api-key` header. + # The OpenAI SDK requires api_key to be set, so pass a placeholder — the real + # credential is sent via default_headers and Foundry ignores the placeholder Bearer token. + client = AsyncOpenAI( + base_url=f"{resource_endpoint}/openai/v1", + api_key="placeholder-foundry-uses-api-key-header", + default_headers={"api-key": AZURE_OPENAI_API_KEY}, + ) + logger.info("Using Foundry OpenAI-compatible client (base_url=%s/openai/v1)", resource_endpoint) + else: + client = AsyncAzureOpenAI( + azure_endpoint=resource_endpoint, + api_key=AZURE_OPENAI_API_KEY, + api_version=AZURE_OPENAI_API_VERSION, + ) + logger.info("Using classic Azure OpenAI client (endpoint=%s, api_version=%s)", resource_endpoint, AZURE_OPENAI_API_VERSION) + app["openai_client"] = client # Trending digest service