Skip to content

Commit b4edba7

Browse files
shrutiyerShruti IyerCopilot
authored
Route BYO admin-connected judge models through the project Responses API (prompty judge path) (#48002)
* Prototype: BYO admin-connected judge models via project Responses API shim AzureOpenAIGrader routes admin-connected judge models ('connection/deployment') through the Foundry project Responses API using a chat.completions-compatible shim (_byo_judge.py), so the platform resolves the connection. Judges cannot use chat.completions for BYO (404 DeploymentNotFound). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Route admin-connected (BYO) judge models through the project Responses API (prompty judges) Enables the 18 prompty-based LLM-as-a-judge builtin evaluators (coherence, relevance, fluency, groundedness, intent_resolution, similarity, retrieval, task_adherence, task_completion, tool_call_accuracy/success, tool_input_accuracy, tool_output_utilization, tool_selection, response_completeness, customer_satisfaction, deflection_rate, quality_grader) to use admin-connected models referenced as "connection/deployment". - _byo_judge.py: AsyncByoProjectResponsesClient shims chat.completions -> project Responses API; _Usage adapts Responses token usage to chat shape. - _common/utils.py: validate_model_config passes BYO configs through (they omit azure_endpoint/azure_deployment). - _legacy/prompty/_prompty.py: __call__ routes BYO configs to the async shim. - Scope: score_model/label_model (AOAI graders) are NOT supported - reverted the grader changes and removed the sync client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Forward response_format as text.format in BYO judge Responses shim The BYO prompty-judge shim dropped the chat.completions `response_format` param, so admin-connected judge models were not held to JSON output mode (coherence/relevance/etc. prompts request json_object). The Responses API exposes the same capability under `text.format`, so translate it: response_format={'type':'json_object'} -> text={'format':{'type':'json_object'}} response_format json_schema (nested) -> text.format json_schema (flattened) This restores enforced-JSON parity with the AzureOpenAI path and prevents json.loads failures when a BYO model would otherwise drift to prose output. Added 6 unit tests for _map_response_format and the _map_params wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix black formatting in test_byo_judge.py Collapse a multi-line AsyncByoProjectResponsesClient constructor call onto one line (fits within the 120-char limit) to satisfy the azpysdk black check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix: require both BYO markers (byo_model + project_endpoint) is_byo_model_config returned True on byo_model alone, but the prompty branch reads configuration['project_endpoint'] -> a partial config raised KeyError instead of taking the normal (non-BYO) path. Require both markers, matching the control-plane contract (BYO active iff both present). Adds coverage for the byo_model-only and project_endpoint-only cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix: surface real finish_reason from the Responses result The shim hardcoded finish_reason='stop', so a judge output truncated at max_output_tokens was reported as complete and then failed json.loads downstream with no signal. Map the Responses status/incomplete_details.reason to a chat.completions finish_reason (max_output_tokens -> 'length', else 'stop'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix: honor the per-request timeout in the BYO shim with_options(timeout=...) was a no-op, so the prompty runner's per-request timeout was dropped and a hung Responses call could block on openai's default (~600s). Capture a concrete numeric timeout and forward it to responses.create (openai's NotGiven sentinel, used when no timeout is set, is ignored). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix: compute total_tokens fallback in the usage adapter _Usage reported total_tokens=0 when the Responses usage omits it. Fall back to prompt_tokens + completion_tokens so token telemetry stays correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix: await-safe project client creation; drop dead responses property get_openai_client() is synchronous in some azure-ai-projects versions and a coroutine in others; the shim assumed sync, so a version bump would assign a coroutine to self._client and break responses.create. Create the client in an async helper that awaits it when needed. Also removes the unused 'responses' property (leftover from the removed grader path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Surface clear MissingRequiredPackage error when azure-ai-projects is absent azure-ai-projects is an optional dependency (not in install_requires/extras), so the BYO judge path's lazy `from azure.ai.projects.aio import AIProjectClient` raised a raw ModuleNotFoundError when it was not installed. Wrap the import in try/except and raise the SDK's MissingRequiredPackage with an actionable install message, matching the existing optional-dependency idiom (azure-ai-inference, promptflow, openai). Adds a unit test that simulates the missing package. Addresses PR #48002 reviewer comment on _byo_judge.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Require BYO markers to be non-empty strings in is_byo_model_config Truthiness alone accepted invalid configs such as {"byo_model": 1, "project_endpoint": 2}: those markers are truthy, so the config was treated as BYO, bypassed validate_model_config, and failed deep inside the project/OpenAI client instead of producing the evaluator's normal validation error. Require both markers to be non-empty strings before activating the BYO path. Extends the is_byo_model_config unit test with non-string and empty-string cases. Addresses PR #48002 reviewer comment on _byo_judge.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Support optional additional headers in AsyncByoProjectResponsesClient ACA may forward additional request headers (e.g. correlation / telemetry headers) when running continuous evaluations. Add an optional extra_headers parameter to AsyncByoProjectResponsesClient that is forwarded on every Responses API call via responses.create(extra_headers=...). The headers are copied on construction so later caller mutations do not leak into the client. AsyncPrompty threads them from the BYO model config (configuration["extra_headers"]) so the end-to-end path stays unchanged for callers that do not supply headers. Adds unit tests covering header forwarding and the no-headers default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Preserve server-provided created timestamp in BYO chat-completion shim _ChatCompletion always set `created` to the local wall-clock time, discarding any server `created` timestamp on the Responses result. Preserve the server value when it is a real number (isinstance-guarded so it falls back to now for missing/non-numeric values), keeping the chat-completions shape faithful and avoiding surprising timestamp drift in logs/traces. Adds a unit test. Addresses PR #48002 reviewer comment on _byo_judge.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Preserve SDK default headers on the BYO prompty path The BYO prompty branch forwarded only caller-supplied extra_headers to the Responses API, dropping the SDK default headers (e.g. User-Agent) that the non-BYO OpenAI/AzureOpenAI paths always set via default_headers. Merge default_headers with the caller-supplied extra_headers (caller headers take precedence) so BYO judge calls keep telemetry parity with the other paths. Addresses PR #48002 reviewer comment on _prompty.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Cast BYO model config to Any in validate_model_config The BYO branch cast the config to AzureOpenAIModelConfiguration, a differently shaped TypedDict (no azure_endpoint/azure_deployment), which is misleading to type checkers and readers. Cast to Any instead, accurately reflecting that BYO configs bypass TypedDict validation. Addresses PR #48002 reviewer comment on _common/utils.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Forward per-request extra_headers in the BYO chat-completions shim Match the native OpenAI/AzureOpenAI client contract: headers passed per-request to chat.completions.create(extra_headers=...) are now merged over the client-level headers (per-request wins) and forwarded to responses.create as a fresh copy, instead of being dropped by _map_params. Previously only the client-level extra_headers were forwarded. Adds a unit test covering merge, per-request precedence, and copy semantics (no mutation leak). Addresses PR #48002 reviewer comments on _byo_judge.py and test_byo_judge.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Make test_evaluate_output_path xdist-safe (fixes sdist/whl CI legs) test_evaluate_output_path[False] used a bare relative output_path ("eval_test_results.jsonl"), written and os.remove'd relative to the process CWD. Under pytest-xdist (used by the whl/sdist test legs, unlike the serial coverage leg) the CWD is shared across worker processes, so the write/exists/ remove of a relative file races with other workers and intermittently fails with FileNotFoundError on cleanup. Adding the new BYO unit tests shifted the xdist test distribution and deterministically exposed this pre-existing race. chdir into the per-test tmpdir for the relative-path case so the bare filename resolves to an isolated per-test directory, preserving the relative-path behavior under test while removing the cross-worker race. The absolute-path case is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix BYO shim: read Responses created_at and disable client-side retries - created timestamp: OpenAI Responses objects expose the server timestamp as created_at, not created. The shim read created, so real responses always took the local-time fallback (and the test mocked the wrong field). Read created_at first, keep created as a fallback for older/mocked shapes. - retries: AsyncPrompty disables the OpenAI client's built-in retries and runs its own observable retry loop (max_retries=0). The BYO shim built the project client without that, so each outer attempt could trigger hidden per-request retries and exceed the intended budget. Pass max_retries=0 to get_openai_client (kwargs are forwarded to the underlying AsyncOpenAI client). Tests: cover created_at + created fallback via _ChatCompletion directly, and assert get_openai_client is called with max_retries=0. Also add an AsyncPrompty integration test that loads a prompty with a BYO configuration and verifies the BYO branch routes through the project Responses API end-to-end (not just the shim in isolation). Addresses PR #48002 reviewer comments on _byo_judge.py and test_byo_judge.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Represent the BYO caller contract with a BYOModelConfiguration TypedDict validate_model_config returned a BYO config cast to Any, which hid the newly supported caller shape from type checkers. Add a BYOModelConfiguration TypedDict (byo_model + project_endpoint, optional extra_headers) and include it in the validate_model_config return union and the construct_prompty_model_config parameter union, casting the BYO branch to it. Runtime behavior is unchanged (TypedDicts are dicts; the cast is identity). Addresses PR #48002 reviewer comment on _common/utils.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Add CHANGELOG entry for admin-connected (BYO) judge models Document the new BYO model configuration (byo_model + project_endpoint), its routing through the Foundry project Responses API, and its optional azure-ai-projects requirement, under the upcoming 1.18.1 Features Added section. Addresses PR #48002 reviewer comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Keep BYOModelConfiguration internal (_BYOModelConfiguration) Admin-connected (BYO) model configs are constructed server-side by the Foundry platform, not hand-authored by SDK users, and the feature is still preview/gated. Rename the TypedDict to _BYOModelConfiguration and reframe its docstring as internal plumbing so it is not advertised as a public, supported type. It stays out of azure.ai.evaluation / __all__; the public model-config union will be widened to advertise BYO once the feature reaches GA. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Drop BYO CHANGELOG entry; require azure-ai-projects>=2.0.0b1 for tests Remove the admin-connected (BYO) Features Added entry from the 1.18.1 section (the release entry will be added when the feature ships). Bump the dev/test pin from azure-ai-projects<=1.0.0b10 to >=2.0.0b1: the BYO judge path uses AIProjectClient.get_openai_client().responses (the Foundry project Responses API), which the ACA data plane runs on 2.0.0b1, and Red Team already documents azure-ai-projects>=2.0.0b1 as the minimum in TROUBLESHOOTING.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Make BYO inline comments concise Tighten verbose inline comments across the BYO judge shim, prompty branch, model-config validation, and their tests to one or two lines each, keeping the essential rationale. Docstrings/function explanations are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Revert azure-ai-projects test pin to <=1.0.0b10 The bump to >=2.0.0b1 broke the whl leg: (1) tests/e2etests/test_remote_evaluation.py imports EvaluatorConfiguration/Evaluation/Dataset from azure.ai.projects.models, which exist only in the 1.x API (removed in 2.x) -> collection ImportError; and (2) azure-ai-projects 2.x requires openai>=2.8.0 and azure-core>=1.35.0, which conflict with this SDK's pinned openai==1.108.0 / azure-core==1.31.0, making the resolve unsatisfiable. The test environment must stay on azure-ai-projects 1.x. BYO's 2.x runtime requirement is a data-plane/ACA concern (already documented in TROUBLESHOOTING.md); BYO unit tests mock azure-ai-projects, so they pass on 1.0.0b10. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Fix OTel event-logging test guard to detect the Events API submodule The 22 TestLogEvents*/TestEmitEvalResultShutdown tests failed on the sk_ CI leg with ModuleNotFoundError: No module named 'opentelemetry._events'. The App Insights event-logging code they cover imports opentelemetry._events / opentelemetry.sdk._events (the OpenTelemetry Events API, added in opentelemetry 1.26), but the MISSING_OPENTELEMETRY guard only checked the top-level opentelemetry package. On the sk_ leg semantic-kernel pins an older opentelemetry (<1.26) that lacks the Events API, so the guard reported present, the tests ran, and the code crashed on the missing submodule. Guard on the Events API submodules the code actually imports so the tests skip cleanly where the API is unavailable (sk_ leg) and still run where it is present (all other legs). Verified locally: 22 pass with the API present, 22 skip when it is simulated absent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 * Build Responses input via openai SDK EasyInputMessageParam Address PR review (posaninagendra): construct the Responses API input with the openai SDK's typed entity (openai.types.responses.EasyInputMessageParam) and return the SDK's ResponseInputParam, instead of a hand-written dict. This keeps the shim in sync with the SDK's input schema (type-checked against it) rather than a hand-authored shape that drifts silently on SDK upgrades. Runtime output is identical (EasyInputMessageParam is a TypedDict -> plain dict), so existing input-shape assertions hold. The output side already reads the SDK's typed Response via its output_text convenience aggregator. openai is a hard dependency (openai>=1.108.0), so the import is safe. 29 byo tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11202e6-6a52-4777-bed1-9c48730a63c3 --------- Co-authored-by: Shruti Iyer <you@example.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cbc3f7f commit b4edba7

6 files changed

Lines changed: 723 additions & 31 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# ---------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# ---------------------------------------------------------
4+
"""Use admin-connected (BYO) models as LLM-as-a-judge evaluator models.
5+
6+
Admin-connected models (Foundry "ModelGateway" / "API Management" connections, referenced
7+
as ``"connection-name/deployment-name"``) are only invokable through the Foundry project
8+
**Responses API** — the platform resolves the connection and handles every auth type
9+
(API key / managed identity / OAuth2), ``deploymentInPath``, api-version and custom headers.
10+
11+
Prompty-based judge evaluators (coherence, relevance, fluency, groundedness, etc.) call
12+
``client.chat.completions.create(...)``. This module provides a small OpenAI-compatible async
13+
**shim** that routes those calls to the project Responses API for a BYO model, so evaluator
14+
code can use admin-connected connections **without any change to its calling code**.
15+
"""
16+
import inspect
17+
import time
18+
from typing import Any, Dict, List, Optional
19+
20+
from openai.types.responses import EasyInputMessageParam, ResponseInputParam
21+
22+
23+
def _to_responses_input(messages: Optional[List[Dict[str, Any]]]) -> ResponseInputParam:
24+
"""Map chat-completions messages ({role, content}) to Responses API input items."""
25+
items: ResponseInputParam = []
26+
for message in messages or []:
27+
items.append(
28+
EasyInputMessageParam(
29+
type="message",
30+
role=message.get("role", "user"),
31+
content=message.get("content", ""),
32+
)
33+
)
34+
return items
35+
36+
37+
def _map_response_format(response_format: Any) -> Optional[Dict[str, Any]]:
38+
"""Translate a chat-completions ``response_format`` into a Responses API ``text.format`` value.
39+
40+
The Responses API exposes the same JSON-output capability as chat.completions, but under
41+
``text.format`` instead of ``response_format``:
42+
43+
================================================ ===========================================
44+
chat.completions ``response_format`` Responses API ``text.format``
45+
================================================ ===========================================
46+
``{"type": "text"}`` ``{"type": "text"}``
47+
``{"type": "json_object"}`` ``{"type": "json_object"}``
48+
``{"type": "json_schema",`` ``{"type": "json_schema", "name": ...,``
49+
`` "json_schema": {"name", "schema", ...}}`` `` "schema": ..., "strict": ...}``
50+
================================================ ===========================================
51+
52+
Note the json_schema shape difference: chat.completions **nests** ``name``/``schema``/``strict``
53+
under a ``json_schema`` key, whereas the Responses API **flattens** them directly under
54+
``format``. Returns ``None`` for anything unrecognized so the param is simply omitted.
55+
"""
56+
if not isinstance(response_format, dict):
57+
return None
58+
rf_type = response_format.get("type")
59+
if rf_type in ("text", "json_object"):
60+
return {"type": rf_type}
61+
if rf_type == "json_schema":
62+
# chat.completions nests the schema spec under "json_schema"; the Responses API flattens it.
63+
schema_spec = response_format.get("json_schema", response_format)
64+
fmt: Dict[str, Any] = {"type": "json_schema"}
65+
for key in ("name", "schema", "strict", "description"):
66+
if key in schema_spec:
67+
fmt[key] = schema_spec[key]
68+
return fmt
69+
return None
70+
71+
72+
def _map_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
73+
"""Map a curated set of chat-completions sampling params to Responses API params."""
74+
mapped: Dict[str, Any] = {}
75+
if "temperature" in kwargs:
76+
mapped["temperature"] = kwargs["temperature"]
77+
if "top_p" in kwargs:
78+
mapped["top_p"] = kwargs["top_p"]
79+
for key in ("max_output_tokens", "max_completion_tokens", "max_tokens"):
80+
if key in kwargs:
81+
mapped["max_output_tokens"] = kwargs[key]
82+
break
83+
if "response_format" in kwargs:
84+
text_format = _map_response_format(kwargs["response_format"])
85+
if text_format is not None:
86+
mapped["text"] = {"format": text_format}
87+
return mapped
88+
89+
90+
class _Usage:
91+
"""chat.completions-shaped usage view over a Responses API usage object.
92+
93+
The Responses API reports ``input_tokens`` / ``output_tokens`` / ``total_tokens``; judge/grader
94+
code (and the prompty response formatter) expects ``prompt_tokens`` / ``completion_tokens`` /
95+
``total_tokens``. This adapts the former to the latter.
96+
"""
97+
98+
def __init__(self, response_usage: Any) -> None:
99+
self.prompt_tokens = getattr(response_usage, "input_tokens", 0) or 0
100+
self.completion_tokens = getattr(response_usage, "output_tokens", 0) or 0
101+
# Fall back to prompt + completion when the Responses usage omits total_tokens.
102+
self.total_tokens = (getattr(response_usage, "total_tokens", 0) or 0) or (
103+
self.prompt_tokens + self.completion_tokens
104+
)
105+
106+
107+
class _ChatMessage:
108+
def __init__(self, content: str) -> None:
109+
self.role = "assistant"
110+
self.content = content
111+
self.tool_calls = None
112+
113+
114+
def _finish_reason(response: Any) -> str:
115+
"""Map a Responses result's status to a chat-completions ``finish_reason``.
116+
117+
A Responses call that stops early reports ``status="incomplete"`` with an
118+
``incomplete_details.reason`` (e.g. ``"max_output_tokens"`` / ``"content_filter"``).
119+
Chat.completions callers expect ``"length"`` for truncation; surfacing it lets the prompty
120+
formatter distinguish a truncated (invalid-JSON-prone) judge output from a complete one instead
121+
of always seeing ``"stop"``.
122+
"""
123+
if getattr(response, "status", None) != "incomplete":
124+
return "stop"
125+
details = getattr(response, "incomplete_details", None)
126+
reason = getattr(details, "reason", None) if details is not None else None
127+
if reason == "max_output_tokens":
128+
return "length"
129+
return reason or "stop"
130+
131+
132+
class _Choice:
133+
def __init__(self, content: str, finish_reason: str = "stop") -> None:
134+
self.index = 0
135+
self.message = _ChatMessage(content)
136+
self.finish_reason = finish_reason
137+
138+
139+
class _ChatCompletion:
140+
"""Minimal chat.completions-shaped view over a Responses API result."""
141+
142+
def __init__(self, response: Any) -> None:
143+
self.id = getattr(response, "id", None)
144+
self.model = getattr(response, "model", None)
145+
_usage = getattr(response, "usage", None)
146+
self.usage = _Usage(_usage) if _usage is not None else None
147+
self.object = "chat.completion"
148+
# Server timestamp: Responses uses created_at (older/mocked shapes may use created); else now.
149+
_created = getattr(response, "created_at", None)
150+
if _created is None:
151+
_created = getattr(response, "created", None)
152+
self.created = (
153+
int(_created) if isinstance(_created, (int, float)) and not isinstance(_created, bool) else int(time.time())
154+
)
155+
self.choices = [_Choice(getattr(response, "output_text", "") or "", _finish_reason(response))]
156+
157+
158+
class _AsyncChatCompletions:
159+
def __init__(self, owner: "AsyncByoProjectResponsesClient") -> None:
160+
self._owner = owner
161+
162+
async def create(
163+
self, *, model: Optional[str] = None, messages: Optional[List[Dict[str, Any]]] = None, **kwargs: Any
164+
) -> _ChatCompletion:
165+
# ``model`` from the caller is ignored — the BYO model is fixed by the shim's config.
166+
response = await self._owner._responses_create(messages=messages, **kwargs)
167+
return _ChatCompletion(response)
168+
169+
170+
class _AsyncChat:
171+
def __init__(self, owner: "AsyncByoProjectResponsesClient") -> None:
172+
self.completions = _AsyncChatCompletions(owner)
173+
174+
175+
class AsyncByoProjectResponsesClient:
176+
"""Async OpenAI-compatible shim that routes ``chat.completions.create()`` to the Foundry project
177+
Responses API for an admin-connected (BYO) model.
178+
179+
Used by the prompty judge path (LLM-as-a-judge evaluators such as coherence, relevance, fluency,
180+
groundedness, etc.), whose async runner calls ``client.with_options(...).chat.completions.create(...)``.
181+
Those calls are served by the platform, which resolves the connection and every auth type
182+
(API key / managed identity / OAuth2), so evaluator code is unchanged.
183+
"""
184+
185+
def __init__(
186+
self,
187+
byo_model: str,
188+
project_endpoint: str,
189+
credential: Any,
190+
extra_headers: Optional[Dict[str, str]] = None,
191+
) -> None:
192+
self._byo_model = byo_model
193+
self._project_endpoint = project_endpoint
194+
self._credential = credential
195+
# Headers forwarded on every Responses call; used when ACA runs continuous evaluations.
196+
self._extra_headers: Optional[Dict[str, str]] = dict(extra_headers) if extra_headers else None
197+
self._client: Any = None
198+
self._timeout: Optional[float] = None
199+
self.chat = _AsyncChat(self)
200+
201+
def with_options(self, *, timeout: Any = None, **_kwargs: Any) -> "AsyncByoProjectResponsesClient":
202+
# Capture a numeric per-request timeout so it reaches responses.create; openai's NotGiven
203+
# sentinel (no timeout configured) is non-numeric and is ignored.
204+
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool):
205+
self._timeout = float(timeout)
206+
return self
207+
208+
async def _ensure_client(self) -> Any:
209+
if self._client is None:
210+
try:
211+
from azure.ai.projects.aio import AIProjectClient
212+
except ImportError as ex:
213+
from azure.ai.evaluation._legacy._adapters._errors import MissingRequiredPackage
214+
215+
raise MissingRequiredPackage(
216+
message="Please install the 'azure-ai-projects' package to use admin-connected "
217+
"(BYO) judge models."
218+
) from ex
219+
220+
# AsyncPrompty runs its own retry loop, so use max_retries=0 to avoid compounding retries.
221+
# get_openai_client forwards kwargs to the underlying AsyncOpenAI client.
222+
client = AIProjectClient(endpoint=self._project_endpoint, credential=self._credential).get_openai_client(
223+
max_retries=0
224+
)
225+
# get_openai_client() is sync in some azure-ai-projects versions, async in others; await if needed.
226+
if inspect.isawaitable(client):
227+
client = await client
228+
self._client = client
229+
return self._client
230+
231+
async def _responses_create(self, messages: Optional[List[Dict[str, Any]]] = None, **kwargs: Any) -> Any:
232+
client = await self._ensure_client()
233+
# Merge per-request extra_headers over the client-level headers (per-request wins),
234+
# forwarding a fresh copy so neither source dict is mutated.
235+
request_headers = kwargs.pop("extra_headers", None)
236+
merged_headers: Optional[Dict[str, str]] = None
237+
if self._extra_headers or request_headers:
238+
merged_headers = {**(self._extra_headers or {}), **(request_headers or {})}
239+
extra: Dict[str, Any] = {}
240+
if self._timeout is not None:
241+
extra["timeout"] = self._timeout
242+
if merged_headers:
243+
extra["extra_headers"] = merged_headers
244+
return await client.responses.create(
245+
model=self._byo_model,
246+
input=_to_responses_input(messages),
247+
**_map_params(kwargs),
248+
**extra,
249+
)
250+
251+
252+
def is_byo_model_config(model_config: Dict[str, Any]) -> bool:
253+
"""Return True if the model configuration references an admin-connected (BYO) model.
254+
255+
Requires **both** markers — ``byo_model`` (``"connection/deployment"``) and ``project_endpoint``
256+
— to be present **non-empty strings**, because the prompty branch needs the project endpoint to
257+
route the call. Requiring both matches the control-plane contract (BYO is active iff both markers
258+
are present) and avoids a ``KeyError`` when only a partial config is supplied. Enforcing the string
259+
type prevents truthy-but-invalid values (e.g. ``{"byo_model": 1, "project_endpoint": 2}``) from
260+
bypassing the evaluator's normal model-config validation and failing deep inside the client instead.
261+
"""
262+
if not model_config:
263+
return False
264+
byo_model = model_config.get("byo_model")
265+
project_endpoint = model_config.get("project_endpoint")
266+
return (
267+
isinstance(byo_model, str) and bool(byo_model) and isinstance(project_endpoint, str) and bool(project_endpoint)
268+
)

sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_common/utils.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from azure.ai.evaluation._model_configurations import (
1919
AzureAIProject,
2020
AzureOpenAIModelConfiguration,
21+
_BYOModelConfiguration,
2122
OpenAIModelConfiguration,
2223
)
2324

@@ -222,7 +223,7 @@ def parse_model_config_type(
222223

223224

224225
def construct_prompty_model_config(
225-
model_config: Union[AzureOpenAIModelConfiguration, OpenAIModelConfiguration],
226+
model_config: Union[AzureOpenAIModelConfiguration, OpenAIModelConfiguration, _BYOModelConfiguration],
226227
default_api_version: str,
227228
user_agent: str,
228229
) -> dict:
@@ -309,7 +310,15 @@ def validate_azure_ai_project(o: object) -> AzureAIProject:
309310
return cast(AzureAIProject, o)
310311

311312

312-
def validate_model_config(config: dict) -> Union[AzureOpenAIModelConfiguration, OpenAIModelConfiguration]:
313+
def validate_model_config(
314+
config: dict,
315+
) -> Union[AzureOpenAIModelConfiguration, OpenAIModelConfiguration, _BYOModelConfiguration]:
316+
# BYO judge configs (connection/deployment) omit azure_endpoint/azure_deployment and route via
317+
# the Foundry Responses API, so skip the AzureOpenAI/OpenAI TypedDict validation.
318+
from azure.ai.evaluation._byo_judge import is_byo_model_config
319+
320+
if is_byo_model_config(config):
321+
return cast(_BYOModelConfiguration, config)
313322
try:
314323
return _validate_typed_dict(config, AzureOpenAIModelConfiguration)
315324
except TypeError:

0 commit comments

Comments
 (0)