-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Route BYO admin-connected judge models through the project Responses API (prompty judge path) #48002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Route BYO admin-connected judge models through the project Responses API (prompty judge path) #48002
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
b9375f1
Prototype: BYO admin-connected judge models via project Responses API…
72ae554
Route admin-connected (BYO) judge models through the project Response…
c84d4c2
Forward response_format as text.format in BYO judge Responses shim
ba92b28
Fix black formatting in test_byo_judge.py
3f914ab
Fix: require both BYO markers (byo_model + project_endpoint)
6a11938
Fix: surface real finish_reason from the Responses result
8ae470a
Fix: honor the per-request timeout in the BYO shim
1c664e5
Fix: compute total_tokens fallback in the usage adapter
9ce64ff
Fix: await-safe project client creation; drop dead responses property
e38edba
Surface clear MissingRequiredPackage error when azure-ai-projects is …
428bd8d
Require BYO markers to be non-empty strings in is_byo_model_config
c1c6369
Support optional additional headers in AsyncByoProjectResponsesClient
c7f6aae
Preserve server-provided created timestamp in BYO chat-completion shim
dbd11aa
Preserve SDK default headers on the BYO prompty path
e858e2b
Cast BYO model config to Any in validate_model_config
5012db0
Forward per-request extra_headers in the BYO chat-completions shim
ec0f53e
Merge branch 'main' into shruti/byo-admin-judge
shrutiyer 10a2949
Make test_evaluate_output_path xdist-safe (fixes sdist/whl CI legs)
2b14b1a
Fix BYO shim: read Responses created_at and disable client-side retries
5403920
Represent the BYO caller contract with a BYOModelConfiguration TypedDict
9c2c6ae
Add CHANGELOG entry for admin-connected (BYO) judge models
d5185a7
Keep BYOModelConfiguration internal (_BYOModelConfiguration)
2736cca
Drop BYO CHANGELOG entry; require azure-ai-projects>=2.0.0b1 for tests
31e93d6
Make BYO inline comments concise
770ff82
Revert azure-ai-projects test pin to <=1.0.0b10
4a9782e
Fix OTel event-logging test guard to detect the Events API submodule
9c0a8c7
Build Responses input via openai SDK EasyInputMessageParam
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
266 changes: 266 additions & 0 deletions
266
sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_byo_judge.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| # --------------------------------------------------------- | ||
| # Copyright (c) Microsoft Corporation. All rights reserved. | ||
| # --------------------------------------------------------- | ||
| """Use admin-connected (BYO) models as LLM-as-a-judge evaluator models. | ||
|
|
||
| Admin-connected models (Foundry "ModelGateway" / "API Management" connections, referenced | ||
| as ``"connection-name/deployment-name"``) are only invokable through the Foundry project | ||
| **Responses API** — the platform resolves the connection and handles every auth type | ||
| (API key / managed identity / OAuth2), ``deploymentInPath``, api-version and custom headers. | ||
|
|
||
| Prompty-based judge evaluators (coherence, relevance, fluency, groundedness, etc.) call | ||
| ``client.chat.completions.create(...)``. This module provides a small OpenAI-compatible async | ||
| **shim** that routes those calls to the project Responses API for a BYO model, so evaluator | ||
| code can use admin-connected connections **without any change to its calling code**. | ||
| """ | ||
| import inspect | ||
| import time | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
|
|
||
| def _to_responses_input(messages: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]: | ||
|
shrutiyer marked this conversation as resolved.
Outdated
|
||
| """Map chat-completions messages ({role, content}) to Responses API input items.""" | ||
| items: List[Dict[str, Any]] = [] | ||
| for message in messages or []: | ||
| items.append( | ||
| { | ||
| "type": "message", | ||
| "role": message.get("role", "user"), | ||
| "content": message.get("content", ""), | ||
| } | ||
| ) | ||
| return items | ||
|
|
||
|
|
||
| def _map_response_format(response_format: Any) -> Optional[Dict[str, Any]]: | ||
| """Translate a chat-completions ``response_format`` into a Responses API ``text.format`` value. | ||
|
|
||
| The Responses API exposes the same JSON-output capability as chat.completions, but under | ||
| ``text.format`` instead of ``response_format``: | ||
|
|
||
| ================================================ =========================================== | ||
| chat.completions ``response_format`` Responses API ``text.format`` | ||
| ================================================ =========================================== | ||
| ``{"type": "text"}`` ``{"type": "text"}`` | ||
| ``{"type": "json_object"}`` ``{"type": "json_object"}`` | ||
| ``{"type": "json_schema",`` ``{"type": "json_schema", "name": ...,`` | ||
| `` "json_schema": {"name", "schema", ...}}`` `` "schema": ..., "strict": ...}`` | ||
| ================================================ =========================================== | ||
|
|
||
| Note the json_schema shape difference: chat.completions **nests** ``name``/``schema``/``strict`` | ||
| under a ``json_schema`` key, whereas the Responses API **flattens** them directly under | ||
| ``format``. Returns ``None`` for anything unrecognized so the param is simply omitted. | ||
| """ | ||
| if not isinstance(response_format, dict): | ||
| return None | ||
| rf_type = response_format.get("type") | ||
| if rf_type in ("text", "json_object"): | ||
| return {"type": rf_type} | ||
| if rf_type == "json_schema": | ||
| # chat.completions nests the schema spec under "json_schema"; the Responses API flattens it. | ||
| schema_spec = response_format.get("json_schema", response_format) | ||
| fmt: Dict[str, Any] = {"type": "json_schema"} | ||
| for key in ("name", "schema", "strict", "description"): | ||
| if key in schema_spec: | ||
| fmt[key] = schema_spec[key] | ||
| return fmt | ||
| return None | ||
|
|
||
|
|
||
| def _map_params(kwargs: Dict[str, Any]) -> Dict[str, Any]: | ||
| """Map a curated set of chat-completions sampling params to Responses API params.""" | ||
| mapped: Dict[str, Any] = {} | ||
| if "temperature" in kwargs: | ||
| mapped["temperature"] = kwargs["temperature"] | ||
| if "top_p" in kwargs: | ||
| mapped["top_p"] = kwargs["top_p"] | ||
| for key in ("max_output_tokens", "max_completion_tokens", "max_tokens"): | ||
| if key in kwargs: | ||
| mapped["max_output_tokens"] = kwargs[key] | ||
| break | ||
| if "response_format" in kwargs: | ||
| text_format = _map_response_format(kwargs["response_format"]) | ||
| if text_format is not None: | ||
| mapped["text"] = {"format": text_format} | ||
| return mapped | ||
|
|
||
|
|
||
| class _Usage: | ||
| """chat.completions-shaped usage view over a Responses API usage object. | ||
|
|
||
| The Responses API reports ``input_tokens`` / ``output_tokens`` / ``total_tokens``; judge/grader | ||
| code (and the prompty response formatter) expects ``prompt_tokens`` / ``completion_tokens`` / | ||
| ``total_tokens``. This adapts the former to the latter. | ||
| """ | ||
|
|
||
| def __init__(self, response_usage: Any) -> None: | ||
| self.prompt_tokens = getattr(response_usage, "input_tokens", 0) or 0 | ||
| self.completion_tokens = getattr(response_usage, "output_tokens", 0) or 0 | ||
| # Fall back to prompt + completion when the Responses usage omits total_tokens. | ||
| self.total_tokens = (getattr(response_usage, "total_tokens", 0) or 0) or ( | ||
| self.prompt_tokens + self.completion_tokens | ||
| ) | ||
|
|
||
|
|
||
| class _ChatMessage: | ||
| def __init__(self, content: str) -> None: | ||
| self.role = "assistant" | ||
| self.content = content | ||
| self.tool_calls = None | ||
|
|
||
|
|
||
| def _finish_reason(response: Any) -> str: | ||
| """Map a Responses result's status to a chat-completions ``finish_reason``. | ||
|
|
||
| A Responses call that stops early reports ``status="incomplete"`` with an | ||
| ``incomplete_details.reason`` (e.g. ``"max_output_tokens"`` / ``"content_filter"``). | ||
| Chat.completions callers expect ``"length"`` for truncation; surfacing it lets the prompty | ||
| formatter distinguish a truncated (invalid-JSON-prone) judge output from a complete one instead | ||
| of always seeing ``"stop"``. | ||
| """ | ||
| if getattr(response, "status", None) != "incomplete": | ||
| return "stop" | ||
| details = getattr(response, "incomplete_details", None) | ||
| reason = getattr(details, "reason", None) if details is not None else None | ||
| if reason == "max_output_tokens": | ||
| return "length" | ||
| return reason or "stop" | ||
|
|
||
|
|
||
| class _Choice: | ||
| def __init__(self, content: str, finish_reason: str = "stop") -> None: | ||
| self.index = 0 | ||
| self.message = _ChatMessage(content) | ||
| self.finish_reason = finish_reason | ||
|
|
||
|
|
||
| class _ChatCompletion: | ||
| """Minimal chat.completions-shaped view over a Responses API result.""" | ||
|
|
||
| def __init__(self, response: Any) -> None: | ||
| self.id = getattr(response, "id", None) | ||
| self.model = getattr(response, "model", None) | ||
| _usage = getattr(response, "usage", None) | ||
| self.usage = _Usage(_usage) if _usage is not None else None | ||
| self.object = "chat.completion" | ||
| # Server timestamp: Responses uses created_at (older/mocked shapes may use created); else now. | ||
| _created = getattr(response, "created_at", None) | ||
| if _created is None: | ||
| _created = getattr(response, "created", None) | ||
| self.created = ( | ||
| int(_created) if isinstance(_created, (int, float)) and not isinstance(_created, bool) else int(time.time()) | ||
| ) | ||
| self.choices = [_Choice(getattr(response, "output_text", "") or "", _finish_reason(response))] | ||
|
shrutiyer marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class _AsyncChatCompletions: | ||
| def __init__(self, owner: "AsyncByoProjectResponsesClient") -> None: | ||
| self._owner = owner | ||
|
|
||
| async def create( | ||
| self, *, model: Optional[str] = None, messages: Optional[List[Dict[str, Any]]] = None, **kwargs: Any | ||
| ) -> _ChatCompletion: | ||
| # ``model`` from the caller is ignored — the BYO model is fixed by the shim's config. | ||
| response = await self._owner._responses_create(messages=messages, **kwargs) | ||
| return _ChatCompletion(response) | ||
|
|
||
|
|
||
| class _AsyncChat: | ||
| def __init__(self, owner: "AsyncByoProjectResponsesClient") -> None: | ||
| self.completions = _AsyncChatCompletions(owner) | ||
|
|
||
|
|
||
| class AsyncByoProjectResponsesClient: | ||
| """Async OpenAI-compatible shim that routes ``chat.completions.create()`` to the Foundry project | ||
| Responses API for an admin-connected (BYO) model. | ||
|
|
||
| Used by the prompty judge path (LLM-as-a-judge evaluators such as coherence, relevance, fluency, | ||
| groundedness, etc.), whose async runner calls ``client.with_options(...).chat.completions.create(...)``. | ||
| Those calls are served by the platform, which resolves the connection and every auth type | ||
| (API key / managed identity / OAuth2), so evaluator code is unchanged. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| byo_model: str, | ||
| project_endpoint: str, | ||
| credential: Any, | ||
| extra_headers: Optional[Dict[str, str]] = None, | ||
| ) -> None: | ||
| self._byo_model = byo_model | ||
| self._project_endpoint = project_endpoint | ||
| self._credential = credential | ||
| # Headers forwarded on every Responses call; used when ACA runs continuous evaluations. | ||
| self._extra_headers: Optional[Dict[str, str]] = dict(extra_headers) if extra_headers else None | ||
| self._client: Any = None | ||
| self._timeout: Optional[float] = None | ||
| self.chat = _AsyncChat(self) | ||
|
|
||
| def with_options(self, *, timeout: Any = None, **_kwargs: Any) -> "AsyncByoProjectResponsesClient": | ||
| # Capture a numeric per-request timeout so it reaches responses.create; openai's NotGiven | ||
| # sentinel (no timeout configured) is non-numeric and is ignored. | ||
| if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): | ||
| self._timeout = float(timeout) | ||
| return self | ||
|
shrutiyer marked this conversation as resolved.
|
||
|
|
||
| async def _ensure_client(self) -> Any: | ||
| if self._client is None: | ||
| try: | ||
| from azure.ai.projects.aio import AIProjectClient | ||
|
shrutiyer marked this conversation as resolved.
|
||
| except ImportError as ex: | ||
| from azure.ai.evaluation._legacy._adapters._errors import MissingRequiredPackage | ||
|
|
||
| raise MissingRequiredPackage( | ||
| message="Please install the 'azure-ai-projects' package to use admin-connected " | ||
| "(BYO) judge models." | ||
| ) from ex | ||
|
|
||
| # AsyncPrompty runs its own retry loop, so use max_retries=0 to avoid compounding retries. | ||
| # get_openai_client forwards kwargs to the underlying AsyncOpenAI client. | ||
| client = AIProjectClient(endpoint=self._project_endpoint, credential=self._credential).get_openai_client( | ||
| max_retries=0 | ||
| ) | ||
| # get_openai_client() is sync in some azure-ai-projects versions, async in others; await if needed. | ||
| if inspect.isawaitable(client): | ||
| client = await client | ||
| self._client = client | ||
| return self._client | ||
|
|
||
| async def _responses_create(self, messages: Optional[List[Dict[str, Any]]] = None, **kwargs: Any) -> Any: | ||
| client = await self._ensure_client() | ||
| # Merge per-request extra_headers over the client-level headers (per-request wins), | ||
| # forwarding a fresh copy so neither source dict is mutated. | ||
| request_headers = kwargs.pop("extra_headers", None) | ||
| merged_headers: Optional[Dict[str, str]] = None | ||
| if self._extra_headers or request_headers: | ||
| merged_headers = {**(self._extra_headers or {}), **(request_headers or {})} | ||
| extra: Dict[str, Any] = {} | ||
| if self._timeout is not None: | ||
| extra["timeout"] = self._timeout | ||
| if merged_headers: | ||
| extra["extra_headers"] = merged_headers | ||
| return await client.responses.create( | ||
| model=self._byo_model, | ||
| input=_to_responses_input(messages), | ||
| **_map_params(kwargs), | ||
| **extra, | ||
| ) | ||
|
shrutiyer marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def is_byo_model_config(model_config: Dict[str, Any]) -> bool: | ||
| """Return True if the model configuration references an admin-connected (BYO) model. | ||
|
|
||
| Requires **both** markers — ``byo_model`` (``"connection/deployment"``) and ``project_endpoint`` | ||
| — to be present **non-empty strings**, because the prompty branch needs the project endpoint to | ||
| route the call. Requiring both matches the control-plane contract (BYO is active iff both markers | ||
| are present) and avoids a ``KeyError`` when only a partial config is supplied. Enforcing the string | ||
| type prevents truthy-but-invalid values (e.g. ``{"byo_model": 1, "project_endpoint": 2}``) from | ||
| bypassing the evaluator's normal model-config validation and failing deep inside the client instead. | ||
| """ | ||
| if not model_config: | ||
| return False | ||
| byo_model = model_config.get("byo_model") | ||
| project_endpoint = model_config.get("project_endpoint") | ||
| return ( | ||
| isinstance(byo_model, str) and bool(byo_model) and isinstance(project_endpoint, str) and bool(project_endpoint) | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.