Skip to content

Commit c0e68aa

Browse files
ShreeBoharaclaude
andcommitted
Add Azure OpenAI as a first-class LLM and embedding provider
Both factories advertised multi-provider support behind real ABCs but only ever constructed public-OpenAI clients. This adds azure_openai to each. Approach: target Azure's v1 OpenAI-compatible surface (<endpoint>/openai/v1) and reuse the standard AsyncOpenAI client rather than AsyncAzureOpenAI, whose static types the openai SDK's own README warns "can be incorrect". So the Azure branch is a different base_url and a deployment name, not a second client implementation. - config.py: azure_openai_endpoint / _api_key / _deployment / _embedding_deployment / _tokenizer_model, plus azure_openai_base_url() which normalises the endpoint to /openai/v1 idempotently (accepts bare host, trailing slash, or an already-complete URL). - llm/factory.py + embeddings/factory.py: azure_openai branch, failing fast with a named variable when endpoint / key / deployment are missing. - openai_llm.py + openai_embeddings.py: api_key widened to str | Callable[[], str] so an Entra token provider can be passed without either class knowing how the credential is obtained. Nothing here supplies one yet -- auth is API-key only. - requirements.txt: openai>=1.106.0, the floor Microsoft documents for the v1 surface and callable token providers, and the first version exporting the error classes the health check now discriminates on. Was >=1.12.0. Two Azure divergences that would otherwise be silent: - health_check no longer treats a missing /models route as unhealthy. On Azure that route enumerates *deployments* and some configurations omit it entirely; a 404 means the endpoint answered, so credentials and networking are fine. 401/403 and unexpected statuses still fail. Previously /api/health would have reported a working Azure deployment as degraded. - openai_embeddings.py takes tokenizer_model separately, because tiktoken resolves an encoding from a model id and on Azure `model` is a deployment name. Note honestly that the old bare `except KeyError: cl100k_base` was *accidentally* correct: every current OpenAI embedding model resolves to cl100k_base anyway. So this is a latent correctness fix plus a warning where there was silence, not a live bug fix. It would have mattered on an o200k_base embedding model or a non-OpenAI base_url. Also, because Azure makes them reachable: - openai_embedding_dimensions is now configurable and threaded through both factories. It was hardcoded to 1536 while the model was configurable, so a text-embedding-3-large deployment (3072) only failed when Chroma rejected the insert. - embeddings/factory.py's unknown-provider branch now raises instead of falling back to OpenAI whenever a key happened to be set. That fallback dropped all seven rate-limit, batching and pacing arguments, so a typo in EMBEDDING_PROVIDER silently produced a differently-behaving client with no error. llm/factory.py already raised. docker-compose.yml forwards the six new variables (55 total), and .env.example plus docker/README.md document deployment-names-not-model-ids, the tokenizer requirement, the dimensions match, and that Entra is not wired up. Verified: 22 new unit tests covering URL normalisation, factory wiring, missing-config errors, tokenizer resolution, dimensions and all five health-check branches; 111 tests pass (was 89); ruff clean; both providers construct correctly from environment alone and the default OpenAI path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8209625 commit c0e68aa

10 files changed

Lines changed: 459 additions & 24 deletions

File tree

.env.example

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,37 @@
2424
# For local LLM (optional, no key needed)
2525
# OLLAMA_BASE_URL=http://localhost:11434
2626

27+
# -----------------------
28+
# Azure OpenAI (optional)
29+
# -----------------------
30+
# Uses Azure's v1 OpenAI-compatible surface, so the standard OpenAI client talks to
31+
# it directly -- the endpoint below is normalised to <endpoint>/openai/v1 for you.
32+
#
33+
# The two things that differ from public OpenAI:
34+
# 1. You pass DEPLOYMENT NAMES, not model ids. Azure sends the deployment name
35+
# where a model id normally goes.
36+
# 2. Because a deployment name is not a model id, tiktoken cannot derive an
37+
# encoding from it. Name the underlying model in AZURE_OPENAI_TOKENIZER_MODEL
38+
# so token counting (used for truncation and batch splitting) stays exact.
39+
#
40+
# LLM_PROVIDER=azure_openai
41+
# EMBEDDING_PROVIDER=azure_openai
42+
# AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com
43+
# AZURE_OPENAI_API_KEY=...
44+
# AZURE_OPENAI_DEPLOYMENT=my-gpt4o-deployment
45+
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=my-embedding-deployment
46+
# AZURE_OPENAI_TOKENIZER_MODEL=text-embedding-3-small
47+
#
48+
# Set this to your deployed embedding model's output size, or Chroma will reject the
49+
# insert: text-embedding-3-small is 1536, text-embedding-3-large is 3072.
50+
# OPENAI_EMBEDDING_DIMENSIONS=1536
51+
2752
# -----------------------
2853
# Embedding Providers
2954
# -----------------------
3055
# Uses OpenAI by default. Uncomment to use alternatives:
31-
# EMBEDDING_PROVIDER=openai # openai or ollama
32-
# LLM_PROVIDER=openai # openai, anthropic, or ollama
56+
# EMBEDDING_PROVIDER=openai # openai, azure_openai, or ollama
57+
# LLM_PROVIDER=openai # openai, azure_openai, anthropic, or ollama
3358
# OPENAI_EMBEDDING_MAX_TOKENS_PER_REQUEST=250000
3459
# OPENAI_EMBEDDING_MAX_TEXTS_PER_REQUEST=128
3560
# OPENAI_EMBEDDING_REQUEST_CONCURRENCY=1

apps/api/requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ aiosqlite>=0.19.0
2020
chromadb>=1.0.0
2121

2222
# LLM Providers
23-
openai>=1.12.0
23+
# >=1.106.0 is the floor Microsoft documents for Azure OpenAI's v1 surface and for
24+
# passing a callable token provider as api_key. NotFoundError/PermissionDeniedError,
25+
# used by the provider-aware health check, are also only exported on modern versions.
26+
openai>=1.106.0
2427
anthropic>=0.18.0
2528
tiktoken>=0.6.0
2629

apps/api/src/config.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"chroma_persist_dir",
3030
"repos_dir",
3131
"vector_db_type",
32+
"azure_openai_tokenizer_model",
3233
)
3334

3435

@@ -64,18 +65,40 @@ class Settings(BaseSettings):
6465
qdrant_api_key: Optional[str] = None
6566

6667
# LLM Providers
67-
llm_provider: str = "openai" # "openai", "anthropic", "ollama"
68+
llm_provider: str = "openai" # "openai", "azure_openai", "anthropic", "ollama"
6869
openai_api_key: Optional[str] = None
6970
openai_model: str = "gpt-4o"
7071
anthropic_api_key: Optional[str] = None
7172
anthropic_model: str = "claude-sonnet-4-20250514"
7273
ollama_base_url: str = "http://localhost:11434"
7374
ollama_model: str = "llama3.1"
7475

76+
# Azure OpenAI
77+
#
78+
# Targets Azure's v1 OpenAI-compatible surface, so the standard OpenAI client is
79+
# used rather than AzureOpenAI (whose static types the openai SDK README warns
80+
# "can be incorrect"). azure_openai_base_url() below appends /openai/v1.
81+
#
82+
# Note that on Azure the *deployment name* takes the place of the model name in
83+
# API calls. It is frequently not a model id, which is why the tokenizer must be
84+
# named separately -- see azure_openai_tokenizer_model.
85+
azure_openai_endpoint: Optional[str] = None # e.g. https://my-resource.openai.azure.com
86+
azure_openai_api_key: Optional[str] = None
87+
azure_openai_deployment: Optional[str] = None # chat deployment name
88+
azure_openai_embedding_deployment: Optional[str] = None # embedding deployment name
89+
# tiktoken cannot resolve an encoding from a deployment name; without this it
90+
# silently falls back to cl100k_base, which is wrong for o200k_base models and
91+
# makes every token count (and therefore every truncation) quietly inaccurate.
92+
azure_openai_tokenizer_model: str = "text-embedding-3-small"
93+
7594
# Embedding Providers
76-
embedding_provider: str = "openai" # "openai" or "ollama"
95+
embedding_provider: str = "openai" # "openai", "azure_openai" or "ollama"
7796
openai_embedding_model: str = "text-embedding-3-small"
7897
openai_base_url: Optional[str] = None # Optional: OpenAI-compatible endpoint (e.g., LM Studio)
98+
# Must match the deployed model's output size. text-embedding-3-small is 1536,
99+
# text-embedding-3-large is 3072; a mismatch is only discovered when Chroma
100+
# rejects the insert, so it is configurable rather than hardcoded.
101+
openai_embedding_dimensions: int = 1536
79102
openai_embedding_max_tokens_per_request: int = 250000
80103
openai_embedding_max_texts_per_request: int = 128
81104
openai_embedding_request_concurrency: int = 1
@@ -223,6 +246,26 @@ def _blank_falls_back_to_default(cls, value, info: ValidationInfo):
223246
return field.default
224247
return value
225248

249+
def azure_openai_base_url(self) -> str:
250+
"""
251+
Base URL for Azure's v1 OpenAI-compatible surface.
252+
253+
Azure exposes an OpenAI-compatible API at <endpoint>/openai/v1, which lets the
254+
standard OpenAI client talk to it directly. Accepts an endpoint with or without
255+
a trailing slash, and is idempotent if the caller already included /openai/v1.
256+
"""
257+
endpoint = (self.azure_openai_endpoint or "").strip().rstrip("/")
258+
if not endpoint:
259+
raise ValueError(
260+
"AZURE_OPENAI_ENDPOINT is required when using the azure_openai provider "
261+
"(e.g. https://my-resource.openai.azure.com)"
262+
)
263+
if endpoint.endswith("/openai/v1"):
264+
return endpoint
265+
if endpoint.endswith("/openai"):
266+
return f"{endpoint}/v1"
267+
return f"{endpoint}/openai/v1"
268+
226269
@property
227270
def cors_origins(self) -> List[str]:
228271
"""

apps/api/src/core/embeddings/factory.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,37 @@ def create_embedding_service() -> BaseEmbeddings:
88
"""Factory function to create embedding service based on configuration."""
99
provider = settings.embedding_provider.lower()
1010

11-
if provider == "openai":
11+
if provider in ("azure_openai", "azure"):
12+
# Same client, Azure v1 base_url, deployment name in place of the model id.
13+
# tokenizer_model is passed separately because tiktoken cannot resolve an
14+
# encoding from a deployment name.
15+
if not settings.azure_openai_api_key:
16+
raise ValueError("AZURE_OPENAI_API_KEY required for the azure_openai provider")
17+
if not settings.azure_openai_embedding_deployment:
18+
raise ValueError(
19+
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT required for the azure_openai "
20+
"embedding provider (the embedding deployment name)"
21+
)
22+
return OpenAIEmbeddings(
23+
api_key=settings.azure_openai_api_key,
24+
model=settings.azure_openai_embedding_deployment,
25+
base_url=settings.azure_openai_base_url(),
26+
dimensions=settings.openai_embedding_dimensions,
27+
tokenizer_model=settings.azure_openai_tokenizer_model,
28+
max_tokens_per_request=settings.openai_embedding_max_tokens_per_request,
29+
max_texts_per_request=settings.openai_embedding_max_texts_per_request,
30+
request_concurrency=settings.openai_embedding_request_concurrency,
31+
min_seconds_between_requests=settings.openai_embedding_min_seconds_between_requests,
32+
rate_limit_max_retries=settings.openai_embedding_rate_limit_max_retries,
33+
rate_limit_base_backoff_seconds=settings.openai_embedding_rate_limit_base_backoff_seconds,
34+
rate_limit_max_backoff_seconds=settings.openai_embedding_rate_limit_max_backoff_seconds,
35+
)
36+
elif provider == "openai":
1237
return OpenAIEmbeddings(
1338
api_key=settings.openai_api_key,
1439
model=settings.openai_embedding_model,
1540
base_url=settings.openai_base_url,
41+
dimensions=settings.openai_embedding_dimensions,
1642
max_tokens_per_request=settings.openai_embedding_max_tokens_per_request,
1743
max_texts_per_request=settings.openai_embedding_max_texts_per_request,
1844
request_concurrency=settings.openai_embedding_request_concurrency,
@@ -31,11 +57,12 @@ def create_embedding_service() -> BaseEmbeddings:
3157
max_failure_ratio=settings.ollama_embedding_max_failure_ratio,
3258
)
3359
else:
34-
# Fallback/Default or Raise
35-
# For now, if unknown, default to OpenAI if key exists, else error
36-
if settings.openai_api_key:
37-
return OpenAIEmbeddings(
38-
api_key=settings.openai_api_key,
39-
model=settings.openai_embedding_model
40-
)
41-
raise ValueError(f"Unknown embedding provider: {provider}")
60+
# Fail fast, matching src/core/llm/factory.py. This previously fell back to
61+
# OpenAI whenever a key happened to be present, which meant a typo in
62+
# EMBEDDING_PROVIDER silently produced an OpenAI client with *none* of the
63+
# rate-limit, batching or pacing settings applied -- so indexing behaved
64+
# differently from the configured provider with no error anywhere.
65+
raise ValueError(
66+
f"Unknown embedding provider: {provider!r}. "
67+
"Expected one of: openai, azure_openai, ollama."
68+
)

apps/api/src/core/embeddings/openai_embeddings.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import random
99
import threading
1010
import time
11-
from typing import List, Sequence
11+
from typing import Callable, List, Sequence
1212

1313
import tiktoken
1414
from openai import AsyncOpenAI, RateLimitError
@@ -23,9 +23,11 @@ class OpenAIEmbeddings(BaseEmbeddings):
2323

2424
def __init__(
2525
self,
26-
api_key: str = None,
26+
api_key: str | Callable[[], str] | None = None,
2727
model: str = "text-embedding-3-small",
2828
base_url: str | None = None,
29+
dimensions: int = 1536,
30+
tokenizer_model: str | None = None,
2931
max_tokens_per_request: int = 250000,
3032
max_texts_per_request: int = 128,
3133
request_concurrency: int = 1,
@@ -39,7 +41,7 @@ def __init__(
3941
client_kwargs["base_url"] = base_url
4042
self._client = AsyncOpenAI(**client_kwargs)
4143
self._model = model
42-
self._dimensions = 1536
44+
self._dimensions = int(dimensions)
4345
self._max_tokens = 8000 # Leave some buffer from 8192 limit
4446
self._max_tokens_per_request = max(1, max_tokens_per_request)
4547
self._max_texts_per_request = max(1, max_texts_per_request)
@@ -54,10 +56,23 @@ def __init__(
5456
)
5557
self._request_pacing_lock = threading.Lock()
5658
self._next_request_time = 0.0
59+
# tiktoken resolves an encoding from a *model id*. On Azure `model` is a
60+
# deployment name, which will not resolve -- and the bare fallback below is
61+
# silent, so every token count (and therefore every truncation in
62+
# _truncate_text and every batch split in _split_batches) would be computed
63+
# with the wrong encoding without any signal. tokenizer_model lets the caller
64+
# name the real model; the fallback now warns instead of hiding it.
65+
resolve_from = tokenizer_model or model
5766
try:
58-
self._tokenizer = tiktoken.encoding_for_model(model)
67+
self._tokenizer = tiktoken.encoding_for_model(resolve_from)
5968
except KeyError:
6069
self._tokenizer = tiktoken.get_encoding("cl100k_base")
70+
logger.warning(
71+
"tiktoken has no encoding for %r; falling back to cl100k_base. Token "
72+
"counts will be approximate. Set AZURE_OPENAI_TOKENIZER_MODEL (or pass "
73+
"tokenizer_model) to the underlying model id to fix this.",
74+
resolve_from,
75+
)
6176

6277
@property
6378
def dimensions(self) -> int:

apps/api/src/core/llm/factory.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,22 @@ def create_llm() -> BaseLLM:
1515
model=settings.openai_model,
1616
base_url=settings.openai_base_url,
1717
)
18+
elif provider in ("azure_openai", "azure"):
19+
# Azure's v1 surface is OpenAI-compatible, so the same client is reused with a
20+
# different base_url. The deployment name takes the place of the model name.
21+
if not settings.azure_openai_api_key:
22+
raise ValueError("AZURE_OPENAI_API_KEY required for the azure_openai provider")
23+
if not settings.azure_openai_deployment:
24+
raise ValueError(
25+
"AZURE_OPENAI_DEPLOYMENT required for the azure_openai provider "
26+
"(the chat deployment name, which Azure uses in place of a model id)"
27+
)
28+
return OpenAILLM(
29+
api_key=settings.azure_openai_api_key,
30+
model=settings.azure_openai_deployment,
31+
base_url=settings.azure_openai_base_url(),
32+
provider_label="azure_openai",
33+
)
1834
elif provider == "anthropic":
1935
if not settings.anthropic_api_key:
2036
# Don't raise immediately, allow app to start but fail on use if key missing

apps/api/src/core/llm/openai_llm.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44

55
import asyncio
66
import logging
7-
from typing import AsyncGenerator, Dict, List
7+
from typing import AsyncGenerator, Callable, Dict, List
88

9-
from openai import AsyncOpenAI
9+
from openai import (
10+
APIStatusError,
11+
AsyncOpenAI,
12+
AuthenticationError,
13+
NotFoundError,
14+
PermissionDeniedError,
15+
)
1016

1117
from src.core.llm.base import BaseLLM, stream_error_text
1218

@@ -16,13 +22,23 @@
1622
class OpenAILLM(BaseLLM):
1723
"""OpenAI LLM service with retry logic."""
1824

19-
def __init__(self, api_key: str = None, model: str = "gpt-4o", base_url: str | None = None):
25+
def __init__(
26+
self,
27+
api_key: str | Callable[[], str] | None = None,
28+
model: str = "gpt-4o",
29+
base_url: str | None = None,
30+
provider_label: str = "openai",
31+
):
32+
# api_key accepts a callable so a token provider (e.g. Entra ID) can be passed
33+
# without this class needing to know how the credential is obtained.
2034
client_kwargs = {"api_key": api_key}
2135
if base_url:
2236
client_kwargs["base_url"] = base_url
2337
self._client = AsyncOpenAI(**client_kwargs)
2438
self._model = model
2539
self._max_retries = 3
40+
# Only used for log messages; behaviour is identical across OpenAI-compatible hosts.
41+
self._provider_label = provider_label
2642

2743
async def _retry_with_backoff(self, func, *args, **kwargs):
2844
"""Retry with exponential backoff."""
@@ -122,11 +138,36 @@ async def generate_stream(
122138
return
123139

124140
async def health_check(self) -> bool:
125-
"""Check OpenAI API availability."""
141+
"""
142+
Check provider availability.
143+
144+
Distinguishes "cannot reach the provider" from "provider does not implement
145+
/models". Azure serves an OpenAI-compatible surface but /models enumerates
146+
*deployments*, and some configurations do not expose it at all -- a 404 there
147+
means the endpoint answered, so credentials and networking are fine and the
148+
service is usable. Treating that as unhealthy would report a working Azure
149+
deployment as down.
150+
"""
126151
try:
127-
# Simple models list call to verify API key
128152
await self._client.models.list()
129153
return True
154+
except NotFoundError:
155+
logger.info(
156+
"%s does not expose /models; treating as reachable (endpoint responded)",
157+
self._provider_label,
158+
)
159+
return True
160+
except (AuthenticationError, PermissionDeniedError) as e:
161+
logger.warning("%s health check failed: bad credentials: %s", self._provider_label, e)
162+
return False
163+
except APIStatusError as e:
164+
# Any other HTTP status still proves the endpoint is reachable, but an
165+
# unexpected status is worth surfacing rather than silently passing.
166+
logger.warning(
167+
"%s health check got unexpected status %s: %s",
168+
self._provider_label, e.status_code, e,
169+
)
170+
return False
130171
except Exception as e:
131-
logger.warning(f"OpenAI health check failed: {e}")
172+
logger.warning("%s health check failed: %s", self._provider_label, e)
132173
return False

0 commit comments

Comments
 (0)