Skip to content

Commit 08e42e7

Browse files
EpicSRN021claude
andauthored
feat: add native Azure OpenAI batch backend (#538) (#727)
Adds a native `backend="azure"` batch request processor, since Azure OpenAI was previously only reachable via the litellm backend, which doesn't support batch mode. Batch was already supported natively for OpenAI, Anthropic, Gemini, and Mistral, but not Azure. Azure's OpenAI-compatible "v1" API surface (https://<resource>.openai.azure.com/openai/v1/) lets AzureBatchRequestProcessor subclass OpenAIBatchRequestProcessor directly and reuse its file/batch lifecycle handling almost unmodified, rather than reimplementing it. It accounts for two documented differences from OpenAI's own API: the batches.create() `endpoint` argument omits the "/v1" prefix, and the request body's "model" field must be the Azure deployment name rather than curator's `model` config (which stays the underlying model name for litellm-based cost lookups). Also adds a `_AzureCostProcessor` (mirroring the existing KlusterAI/ InferenceNet pattern) to prefer litellm's `azure/`-prefixed pricing table, and a small refactor extracting OpenAIBatchRequestProcessor's hardcoded batch endpoint string into an overridable class attribute so Azure doesn't need to duplicate create_batch(). Not verified against a live Azure resource (no Azure credentials available in this environment) - the design follows Microsoft's current official documentation, and the integration test is left @pytest.mark.skip()-decorated pending a run against a real resource. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b346fdf commit 08e42e7

7 files changed

Lines changed: 226 additions & 1 deletion

File tree

‎src/bespokelabs/curator/cost.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,30 @@ def cost(self, *, completion_window="*", **kwargs):
125125
return super().cost(completion_window=completion_window, **kwargs) * times
126126

127127

128+
class _AzureCostProcessor(_LitellmCostProcessor):
129+
"""Cost processor for Azure OpenAI.
130+
131+
`config.model` stays the underlying model name (e.g. "gpt-4o") rather than the Azure
132+
deployment name, since deployment names are user-chosen and aren't in litellm's cost table.
133+
This prefers litellm's `azure/`-prefixed pricing (which can differ slightly from the bare
134+
OpenAI entry) when available, falling back to the default OpenAI-style lookup otherwise.
135+
"""
136+
137+
def cost(self, *, completion_window="*", **kwargs):
138+
azure_model = f"azure/{self.config.model}"
139+
if azure_model in litellm.model_cost:
140+
kwargs["model"] = azure_model
141+
cost_to_complete = litellm.completion_cost(**kwargs)
142+
if self.batch:
143+
cost_to_complete *= 0.5
144+
return cost_to_complete
145+
return super().cost(completion_window=completion_window, **kwargs)
146+
147+
128148
COST_PROCESSOR = defaultdict(lambda: _LitellmCostProcessor)
129149
COST_PROCESSOR["klusterai"] = _KlusterAICostProcessor
130150
COST_PROCESSOR["inference.net"] = _InferenceNetCostProcessor
151+
COST_PROCESSOR["azure"] = _AzureCostProcessor
131152

132153

133154
def cost_processor_factory(backend, config=None, batch=False):

‎src/bespokelabs/curator/request_processor/_factory.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,14 @@ def create(
177177

178178
return MistralBatchRequestProcessor(config)
179179

180+
if backend == "azure":
181+
config.api_key = config.api_key or os.getenv("AZURE_OPENAI_API_KEY")
182+
if not config.api_key:
183+
raise ValueError("AZURE_OPENAI_API_KEY is not set.")
184+
if not batch:
185+
raise ValueError("Only batch mode is supported with the Azure backend.")
186+
from bespokelabs.curator.request_processor.batch.azure_batch_request_processor import AzureBatchRequestProcessor
187+
188+
return AzureBatchRequestProcessor(config)
189+
180190
raise ValueError(f"Unknown backend: {backend}")
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from litellm import model_cost
2+
3+
from bespokelabs.curator.request_processor.batch.openai_batch_request_processor import OpenAIBatchRequestProcessor
4+
from bespokelabs.curator.request_processor.config import BatchRequestProcessorConfig
5+
from bespokelabs.curator.types.generic_request import GenericRequest
6+
7+
8+
class AzureBatchRequestProcessor(OpenAIBatchRequestProcessor):
9+
"""Azure OpenAI-specific implementation of the BatchRequestProcessor.
10+
11+
Uses Azure OpenAI's OpenAI-SDK-compatible `v1` API surface
12+
(`https://<resource>.openai.azure.com/openai/v1/`), so file/batch lifecycle handling is
13+
inherited from OpenAIBatchRequestProcessor unmodified. Azure diverges from OpenAI in two ways
14+
this class accounts for:
15+
16+
- `client.batches.create()`'s `endpoint` argument omits the "/v1" prefix ("/chat/completions",
17+
not "/v1/chat/completions"), even though the batch file's per-line "url" keeps it.
18+
- the request body's "model" field must be set to the Azure deployment name, not curator's
19+
`model` config (which stays the underlying model name, e.g. "gpt-4o", used for cost lookups).
20+
"""
21+
22+
_BATCH_ENDPOINT = "/chat/completions"
23+
24+
def __init__(self, config: BatchRequestProcessorConfig) -> None:
25+
"""Initialize the AzureBatchRequestProcessor."""
26+
if not config.base_url:
27+
raise ValueError("Azure OpenAI backend requires `base_url` set to your resource's v1 endpoint, e.g. https://<resource>.openai.azure.com/openai/v1/")
28+
if not config.azure_deployment:
29+
raise ValueError("Azure OpenAI backend requires `azure_deployment` set to your batch deployment name.")
30+
31+
super().__init__(config, compatible_provider="azure")
32+
self.web_dashboard = "https://oai.azure.com/resource/batch"
33+
34+
@property
35+
def backend(self):
36+
"""Backend property."""
37+
return "azure"
38+
39+
def create_api_specific_request_batch(self, generic_request: GenericRequest) -> dict:
40+
"""Creates an Azure-specific request body, targeting the configured deployment name."""
41+
request = super().create_api_specific_request_batch(generic_request)
42+
request["body"]["model"] = self.config.azure_deployment
43+
return request
44+
45+
def set_model_cost(self):
46+
"""Set tracker cost information, preferring litellm's `azure/`-prefixed pricing table."""
47+
azure_model = f"azure/{self.prompt_formatter.model_name}"
48+
if azure_model in model_cost:
49+
self.tracker.input_cost_per_million = (model_cost[azure_model]["input_cost_per_token"] * 1_000_000) * 0.5
50+
self.tracker.output_cost_per_million = (model_cost[azure_model]["output_cost_per_token"] * 1_000_000) * 0.5
51+
self.tracker.start_tracker(self._tracker_console)
52+
else:
53+
super().set_model_cost()

‎src/bespokelabs/curator/request_processor/batch/openai_batch_request_processor.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class OpenAIBatchRequestProcessor(BaseBatchRequestProcessor, OpenAIRequestMixin)
3131
file uploads, batch submissions, and result retrieval.
3232
"""
3333

34+
# Overridable so OpenAI-compatible providers with a slightly different batch endpoint
35+
# convention (e.g. Azure's, which omits the "/v1" prefix) don't need to duplicate create_batch.
36+
_BATCH_ENDPOINT = "/v1/chat/completions"
37+
3438
def __init__(self, config: BatchRequestProcessorConfig, compatible_provider=None) -> None:
3539
"""Initialize the OpenAIBatchRequestProcessor."""
3640
super().__init__(config)
@@ -295,7 +299,7 @@ async def create_batch(self, batch_file_id: str, metadata: dict) -> Batch:
295299
try:
296300
batch = await self.client.batches.create(
297301
input_file_id=batch_file_id,
298-
endpoint="/v1/chat/completions",
302+
endpoint=self._BATCH_ENDPOINT,
299303
completion_window=self.config.completion_window,
300304
metadata=metadata,
301305
)

‎src/bespokelabs/curator/request_processor/config.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,18 @@ class BatchRequestProcessorConfig(RequestProcessorConfig):
7070
delete_successful_batch_files: Whether to delete batch files after successful processing
7171
delete_failed_batch_files: Whether to delete batch files after failed processing
7272
completion_window: Time window to wait for batch completion
73+
azure_deployment: Azure OpenAI deployment name to target (required for the Azure backend).
74+
This is distinct from `model`, which stays the underlying model name (e.g. "gpt-4o")
75+
used for litellm-based cost lookups, since Azure deployment names are user-chosen and
76+
don't map 1:1 to a model name.
7377
"""
7478

7579
batch_size: t.Union[int, str] = Field(default=10_000)
7680
batch_check_interval: int = Field(default=60, gt=0)
7781
delete_successful_batch_files: bool = False
7882
delete_failed_batch_files: bool = False
7983
completion_window: str = "24h"
84+
azure_deployment: str | None = None
8085

8186
def __post_init__(self):
8287
"""Post-initialization hook to validate batch size."""
@@ -165,6 +170,7 @@ class BatchBackendParams(BaseBackendParams, total=False):
165170
batch_check_interval: t.Optional[int]
166171
delete_successful_batch_files: t.Optional[bool]
167172
delete_failed_batch_files: t.Optional[bool]
173+
azure_deployment: t.Optional[str]
168174

169175

170176
class OfflineBackendParams(BaseBackendParams, total=False):
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import os
2+
3+
import pytest
4+
from dotenv import load_dotenv
5+
6+
from bespokelabs import curator
7+
8+
load_dotenv(dotenv_path=".env", verbose=True)
9+
10+
11+
def generate_test_prompts():
12+
"""Generate a batch of 4 test prompts to test batch processing."""
13+
return [
14+
"Describe the impact of quantum computing on AI in 2 sentences.",
15+
"Give a creative business idea for a startup in 2025 in 1 sentence.",
16+
"What is 2*10?",
17+
"Who is the current prime-minister of India?",
18+
]
19+
20+
21+
@pytest.mark.skip()
22+
def test_azure_batch_response():
23+
"""Test sending a batch of requests to an Azure OpenAI deployment via Curator.
24+
25+
Requires AZURE_OPENAI_API_KEY, AZURE_OPENAI_BASE_URL (your resource's v1 endpoint, e.g.
26+
https://<resource>.openai.azure.com/openai/v1/), and AZURE_OPENAI_DEPLOYMENT to be set.
27+
"""
28+
llm = curator.LLM(
29+
model_name="gpt-4o",
30+
backend="azure",
31+
batch=True,
32+
backend_params={
33+
"base_url": os.environ["AZURE_OPENAI_BASE_URL"],
34+
"azure_deployment": os.environ["AZURE_OPENAI_DEPLOYMENT"],
35+
},
36+
)
37+
38+
prompts = generate_test_prompts()
39+
responses = llm(prompts).dataset
40+
41+
df_responses = responses.to_pandas()
42+
43+
for i, prompt in enumerate(prompts):
44+
response_text = df_responses.iloc[i]["response"]
45+
assert isinstance(response_text, str), f"Response for prompt {i} should be a string"
46+
assert len(response_text.strip()) > 0, f"Response for prompt {i} should not be empty"
47+
print(f"\nPrompt: {prompt}\nResponse: {response_text}\n{'-' * 50}")
48+
49+
print("\nBatch job completed successfully.")
50+
51+
52+
if __name__ == "__main__":
53+
test_azure_batch_response()

‎tests/unittests/test_batch.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pydantic import BaseModel, Field
77

88
from bespokelabs import curator
9+
from bespokelabs.curator.request_processor.batch.azure_batch_request_processor import AzureBatchRequestProcessor
910
from bespokelabs.curator.request_processor.batch.mistral_batch_request_processor import MistralBatchRequestProcessor
1011
from bespokelabs.curator.request_processor.config import BatchRequestProcessorConfig
1112
from bespokelabs.curator.types.generic_batch import GenericBatch, GenericBatchRequestCounts, GenericBatchStatus
@@ -84,6 +85,83 @@ def test_mistral_batch_response_includes_normalized_finish_reason() -> None:
8485
assert response.finish_reason == "length"
8586

8687

88+
def test_azure_batch_requires_base_url() -> None:
89+
"""Azure batch backend needs a resource-specific base_url; there's no sensible default."""
90+
config = BatchRequestProcessorConfig(model="gpt-4o", azure_deployment="my-gpt4o-deployment")
91+
with pytest.raises(ValueError, match="base_url"):
92+
AzureBatchRequestProcessor(config)
93+
94+
95+
def test_azure_batch_requires_deployment_name() -> None:
96+
"""Azure batch backend needs an explicit deployment name distinct from the model name."""
97+
config = BatchRequestProcessorConfig(model="gpt-4o", base_url="https://my-resource.openai.azure.com/openai/v1/")
98+
with pytest.raises(ValueError, match="azure_deployment"):
99+
AzureBatchRequestProcessor(config)
100+
101+
102+
def test_azure_batch_endpoint_omits_v1_prefix() -> None:
103+
"""Azure's batches.create() `endpoint` argument omits the "/v1" prefix OpenAI's API uses."""
104+
assert AzureBatchRequestProcessor._BATCH_ENDPOINT == "/chat/completions"
105+
106+
107+
def test_azure_batch_request_uses_deployment_name_not_model() -> None:
108+
"""The request body's `model` field must be the Azure deployment name, not curator's `model` config.
109+
110+
`model` stays the underlying model name (e.g. "gpt-4o") since that's what's used for litellm-based
111+
cost lookups, while Azure requires the deployment name to route the actual API request.
112+
"""
113+
processor = AzureBatchRequestProcessor.__new__(AzureBatchRequestProcessor)
114+
processor.config = BatchRequestProcessorConfig(
115+
model="gpt-4o",
116+
base_url="https://my-resource.openai.azure.com/openai/v1/",
117+
azure_deployment="my-gpt4o-deployment",
118+
)
119+
120+
api_request = processor.create_api_specific_request_batch(_generic_request())
121+
122+
assert api_request["body"]["model"] == "my-gpt4o-deployment"
123+
assert api_request["url"] == "/v1/chat/completions"
124+
125+
126+
def test_azure_cost_processor_prefixes_model_for_litellm_lookup(monkeypatch) -> None:
127+
"""_AzureCostProcessor should query litellm's `azure/`-prefixed pricing table, not the bare model name."""
128+
import litellm
129+
130+
from bespokelabs.curator.cost import _AzureCostProcessor
131+
132+
config = BatchRequestProcessorConfig(model="gpt-4o", base_url="https://x.openai.azure.com/openai/v1/", azure_deployment="d")
133+
processor = _AzureCostProcessor(config=config, batch=False)
134+
135+
monkeypatch.setitem(litellm.model_cost, "azure/gpt-4o", {"input_cost_per_token": 1.0, "output_cost_per_token": 1.0})
136+
137+
calls = {}
138+
139+
def fake_completion_cost(**kwargs):
140+
calls.update(kwargs)
141+
return 1.23
142+
143+
monkeypatch.setattr(litellm, "completion_cost", fake_completion_cost)
144+
145+
cost = processor.cost(model="gpt-4o", prompt="hi", completion="there")
146+
147+
assert calls["model"] == "azure/gpt-4o"
148+
assert cost == 1.23
149+
150+
151+
def test_azure_cost_processor_falls_back_without_azure_specific_pricing() -> None:
152+
"""When litellm has no `azure/`-prefixed entry, fall back to the default bare-model lookup."""
153+
from bespokelabs.curator.cost import _AzureCostProcessor
154+
155+
config = BatchRequestProcessorConfig(model="some-unlisted-model", base_url="https://x.openai.azure.com/openai/v1/", azure_deployment="d")
156+
processor = _AzureCostProcessor(config=config, batch=False)
157+
158+
# Neither "azure/some-unlisted-model" nor "some-unlisted-model" are real litellm models,
159+
# so the fallback lookup should just return 0.0 rather than raising.
160+
cost = processor.cost(model="some-unlisted-model", prompt="hi", completion="there")
161+
162+
assert cost == 0.0
163+
164+
87165
def batch_call(model_name, prompts):
88166
dataset: Dataset = Dataset.from_dict({"prompt": prompts})
89167
llm = curator.LLM(

0 commit comments

Comments
 (0)