Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "agentops-accelerator",
"source": "../../plugins/agentops",
"description": "Copilot agent skills for running standardized evaluation workflows with AgentOps Toolkit and Microsoft Foundry agents.",
"version": "0.8.0",
"version": "0.8.1",
"keywords": [
"agentops",
"evaluation",
Expand Down
2 changes: 1 addition & 1 deletion .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "agentops-accelerator",
"source": "../../plugins/agentops",
"description": "Copilot agent skills for running standardized evaluation workflows with AgentOps Toolkit and Microsoft Foundry agents.",
"version": "0.8.0",
"version": "0.8.1",
"keywords": [
"agentops",
"evaluation",
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres

## [Unreleased]

## [0.8.1] - 2026-07-16

### Fixed
- **`agentops doctor` no longer prints noisy non-fatal errors to the console.**
Three low-level probes that already degrade gracefully were leaking their
transport errors to the screen during readiness checks:
- The **LLM judge** called the Foundry project OpenAI client on the legacy
`/openai/` route, which returns HTTP 404 for chat completions, printing
`WARNING: llm_assist: judge call failed: Error code: 404`. The client is now
normalized to the stable `/openai/v1/` route (with the injected
`api-version` query parameter cleared) using the same reused credential and
token refresh, so the judge runs instead of 404ing. Non-Foundry endpoints
are left untouched.
- The **OpenAI data-plane RBAC check** built an ARM role-assignment filter
(`atScopeAndAbove() and assignedTo('<oid>')`) that ARM rejects with
`UnsupportedQuery`. It now sends the supported `assignedTo('<oid>')` filter,
which already returns assignments at the target scope and every ancestor
scope, so the check runs instead of skipping with a console error.
- The **Application Insights** REST probe caught only `urllib.error.URLError`,
so a read timeout (a `socket.timeout`, which is an `OSError` but not a
`URLError`) escaped and surfaced
`INFO: Rate-limit App Insights probe failed (non-fatal): The read operation
timed out`. It now catches `OSError` (covering both) and the request timeout
was raised from 10s to 30s, so a slow App Insights degrades quietly.

## [0.8.0] - 2026-07-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion plugins/agentops/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "agentops-accelerator",
"displayName": "AgentOps Accelerator — Skills for GitHub Copilot",
"description": "Copilot agent skills for running standardized evaluation workflows with AgentOps Accelerator and Microsoft Foundry agents.",
"version": "0.8.0",
"version": "0.8.1",
"publisher": "AgentOpsAccelerator",
"icon": "icon.png",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion plugins/agentops/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "agentops-accelerator",
"description": "Copilot agent skills for running standardized evaluation workflows with AgentOps Accelerator and Microsoft Foundry agents.",
"version": "0.8.0",
"version": "0.8.1",
"author": {
"name": "AgentOps Accelerator",
"url": "https://github.com/Azure/agentops"
Expand Down
13 changes: 8 additions & 5 deletions src/agentops/agent/checks/_rbac_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ def list_principal_role_definition_ids(
"""List role definition GUIDs assigned to the principal at/above scope.

Uses ``RoleAssignmentsOperations.list_for_scope`` with the
``atScopeAndAbove() and assignedTo('<oid>')`` filter so management-plane
inheritance (subscription, resource group, account) is honoured.
``assignedTo('<oid>')`` filter. That filter already returns assignments at
the given scope and every ancestor scope (subscription, resource group,
account), including those inherited through group membership, so
management-plane inheritance is honoured. The ARM RBAC API rejects the
combined ``atScopeAndAbove() and assignedTo(...)`` form
(``UnsupportedQuery``); ``assignedTo(...)`` on its own is the supported
equivalent.
"""
try:
from azure.mgmt.authorization import AuthorizationManagementClient
Expand Down Expand Up @@ -95,9 +100,7 @@ def list_principal_role_definition_ids(
assignments = list(
client.role_assignments.list_for_scope(
scope=scope,
filter=(
f"atScopeAndAbove() and assignedTo('{principal_object_id}')"
),
filter=f"assignedTo('{principal_object_id}')",
)
)
except Exception as exc: # noqa: BLE001
Expand Down
52 changes: 50 additions & 2 deletions src/agentops/agent/llm_assist/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,51 @@ class JudgementMeta:
model_deployment: Optional[str] = None


def _normalize_foundry_openai_client(client: Any) -> Any:
"""Point a Foundry project OpenAI client at the ``/openai/v1/`` route.

``AIProjectClient.get_openai_client()`` returns a client whose
``base_url`` ends in ``/openai/`` and which injects an ``api-version``
query parameter. On that legacy route Foundry chat completions return
HTTP 404. The stable surface is the OpenAI v1 route (``/openai/v1/``)
served *without* an ``api-version`` parameter.

We rewrite the client in place with the public ``with_options`` API so
the reused credential and its token refresh keep working (no second
credential, no endpoint string-munging). The client is returned
unchanged when its ``base_url`` is not the recognised Foundry project
shape, so custom or already-normalised endpoints are left untouched.
"""
try:
base_url = str(getattr(client, "base_url", "") or "")
except Exception: # pragma: no cover - defensive
return client
if not base_url:
return client

normalized = base_url if base_url.endswith("/") else base_url + "/"
if normalized.endswith("/openai/v1/"):
# Already on the v1 surface; only ensure api-version is dropped.
pass
elif normalized.endswith("/openai/"):
normalized = normalized + "v1/"
else:
# Unknown shape (non-Foundry route). Leave routing untouched.
return client

with_options = getattr(client, "with_options", None)
if with_options is None: # pragma: no cover - defensive
return client
try:
return with_options(
base_url=normalized,
default_query={"api-version": None},
)
except Exception as exc: # pragma: no cover - defensive
log.info("llm_assist: could not normalize OpenAI route: %s", exc)
return client


class LLMJudge:
"""Thin wrapper around the Foundry project's OpenAI client."""

Expand Down Expand Up @@ -230,6 +275,7 @@ def _get_client(self) -> Optional[Any]:
if not endpoint:
log.info("llm_assist: project endpoint not configured")
return None

try:
from azure.ai.projects import AIProjectClient # type: ignore
from azure.identity import DefaultAzureCredential # type: ignore
Expand All @@ -240,7 +286,9 @@ def _get_client(self) -> Optional[Any]:
try:
project_client = AIProjectClient(
endpoint=endpoint,
credential=DefaultAzureCredential(exclude_developer_cli_credential=True, process_timeout=30),
credential=DefaultAzureCredential(
exclude_developer_cli_credential=True, process_timeout=30
),
)
# Foundry exposes get_openai_client without an api_version arg;
# never pass one (the SDK picks the right version).
Expand All @@ -251,7 +299,7 @@ def _get_client(self) -> Optional[Any]:
if get_openai_client is None:
log.info("llm_assist: AIProjectClient has no OpenAI client helper")
return None
self._client = get_openai_client()
self._client = _normalize_foundry_openai_client(get_openai_client())
except Exception as exc: # pragma: no cover
log.warning("llm_assist: openai client init failed: %s", exc)
return None
Expand Down
10 changes: 7 additions & 3 deletions src/agentops/agent/sources/azure_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from urllib import error, request
from urllib import request

from agentops.agent.config import AzureMonitorSourceConfig

Expand Down Expand Up @@ -504,9 +504,13 @@ def _query_application_insights(
method="POST",
)
try:
with request.urlopen(req, timeout=10) as resp: # noqa: S310
with request.urlopen(req, timeout=30) as resp: # noqa: S310
parsed = json.loads(resp.read())
except (error.URLError, ValueError, KeyError) as exc:
except (OSError, ValueError, KeyError) as exc:
# OSError covers urllib.error.URLError *and* socket.timeout /
# TimeoutError. A read timeout is not a URLError, so catching only
# URLError here would let it propagate to the caller and surface a
# noisy non-fatal INFO line on the console. Degrade quietly instead.
log.debug("App Insights REST query failed: %s", exc)
return None

Expand Down
8 changes: 6 additions & 2 deletions tests/unit/test_agent_checks_rbac_openai_data_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,12 @@ def test_list_role_definition_ids_extracts_guid_suffix() -> None:
role_assignments.list_for_scope.assert_called_once()
kwargs = role_assignments.list_for_scope.call_args.kwargs
assert kwargs["scope"] == _ACCOUNT_TARGET
assert _PRINCIPAL_OID in kwargs["filter"]
assert "atScopeAndAbove" in kwargs["filter"]
# The ARM RBAC API rejects the combined
# ``atScopeAndAbove() and assignedTo(...)`` filter with UnsupportedQuery.
# ``assignedTo(...)`` alone is the supported equivalent and already
# returns assignments at scope and every ancestor scope.
assert kwargs["filter"] == f"assignedTo('{_PRINCIPAL_OID}')"
assert "atScopeAndAbove" not in kwargs["filter"]


def test_list_role_definition_ids_raises_when_sdk_missing() -> None:
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/test_llm_assist.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,85 @@ def __init__(self, **kwargs):
judge = LLMJudge(config=_enabled_config(), workspace=tmp_path)

assert judge._get_client() == "legacy-openai-client"


# ---------------------------------------------------------------------------
# _normalize_foundry_openai_client -- Foundry /openai/v1/ route fix
# ---------------------------------------------------------------------------


class _FakeOpenAIClient:
"""Minimal stand-in exposing ``base_url`` and ``with_options``."""

def __init__(self, base_url: str) -> None:
self.base_url = base_url
self.with_options_kwargs: Optional[Dict[str, Any]] = None

def with_options(self, **kwargs: Any) -> "_FakeOpenAIClient":
self.with_options_kwargs = kwargs
new = _FakeOpenAIClient(kwargs.get("base_url", self.base_url))
new.with_options_kwargs = kwargs
return new


def test_normalize_rewrites_openai_route_to_v1_and_drops_api_version() -> None:
from agentops.agent.llm_assist._client import (
_normalize_foundry_openai_client,
)

client = _FakeOpenAIClient(
"https://acct.services.ai.azure.com/api/projects/p/openai/"
)
normalized = _normalize_foundry_openai_client(client)

assert normalized.with_options_kwargs is not None
assert (
normalized.with_options_kwargs["base_url"]
== "https://acct.services.ai.azure.com/api/projects/p/openai/v1/"
)
# The injected ``api-version`` query param must be cleared, otherwise the
# Foundry v1 route returns HTTP 404.
assert normalized.with_options_kwargs["default_query"] == {
"api-version": None
}


def test_normalize_leaves_v1_route_but_still_drops_api_version() -> None:
from agentops.agent.llm_assist._client import (
_normalize_foundry_openai_client,
)

client = _FakeOpenAIClient(
"https://acct.services.ai.azure.com/api/projects/p/openai/v1/"
)
normalized = _normalize_foundry_openai_client(client)

assert (
normalized.with_options_kwargs["base_url"]
== "https://acct.services.ai.azure.com/api/projects/p/openai/v1/"
)
assert normalized.with_options_kwargs["default_query"] == {
"api-version": None
}


def test_normalize_leaves_unknown_route_untouched() -> None:
from agentops.agent.llm_assist._client import (
_normalize_foundry_openai_client,
)

client = _FakeOpenAIClient("https://custom.example.com/v1/")
normalized = _normalize_foundry_openai_client(client)

# Non-Foundry routes are returned as-is; with_options is never called.
assert normalized is client
assert client.with_options_kwargs is None


def test_normalize_ignores_client_without_base_url() -> None:
from agentops.agent.llm_assist._client import (
_normalize_foundry_openai_client,
)

sentinel = object()
assert _normalize_foundry_openai_client(sentinel) is sentinel
40 changes: 40 additions & 0 deletions tests/unit/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,46 @@ def test_azure_monitor_skipped_when_connection_string_lacks_application_id(
assert payload.diagnostics["discovery_reason"]


def test_query_application_insights_returns_none_on_read_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A read timeout must degrade quietly, not raise.

``socket.timeout`` (aka ``TimeoutError``) is an ``OSError`` but NOT a
``urllib.error.URLError``, so the previous narrow ``except URLError``
let it escape and surface a noisy non-fatal INFO line on the console.
The fix catches ``OSError`` and returns ``None``.
"""
import socket

def _raise_timeout(*_args, **_kwargs):
raise socket.timeout("The read operation timed out")

monkeypatch.setattr(azure_monitor.request, "urlopen", _raise_timeout)

assert (
azure_monitor._query_application_insights("app-id", "bearer", "kql")
is None
)


def test_query_application_insights_returns_none_on_urlerror(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Transport failures (URLError) also degrade quietly to None."""
from urllib.error import URLError

def _raise_urlerror(*_args, **_kwargs):
raise URLError("connection refused")

monkeypatch.setattr(azure_monitor.request, "urlopen", _raise_urlerror)

assert (
azure_monitor._query_application_insights("app-id", "bearer", "kql")
is None
)


def _app_insights_result(row: dict[str, object]) -> dict[str, object]:
return {
"tables": [
Expand Down
Loading