diff --git a/.env.example b/.env.example index e3d06ee..d70e70e 100644 --- a/.env.example +++ b/.env.example @@ -24,12 +24,37 @@ # For local LLM (optional, no key needed) # OLLAMA_BASE_URL=http://localhost:11434 +# ----------------------- +# Azure OpenAI (optional) +# ----------------------- +# Uses Azure's v1 OpenAI-compatible surface, so the standard OpenAI client talks to +# it directly -- the endpoint below is normalised to /openai/v1 for you. +# +# The two things that differ from public OpenAI: +# 1. You pass DEPLOYMENT NAMES, not model ids. Azure sends the deployment name +# where a model id normally goes. +# 2. Because a deployment name is not a model id, tiktoken cannot derive an +# encoding from it. Name the underlying model in AZURE_OPENAI_TOKENIZER_MODEL +# so token counting (used for truncation and batch splitting) stays exact. +# +# LLM_PROVIDER=azure_openai +# EMBEDDING_PROVIDER=azure_openai +# AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com +# AZURE_OPENAI_API_KEY=... +# AZURE_OPENAI_DEPLOYMENT=my-gpt4o-deployment +# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=my-embedding-deployment +# AZURE_OPENAI_TOKENIZER_MODEL=text-embedding-3-small +# +# Set this to your deployed embedding model's output size, or Chroma will reject the +# insert: text-embedding-3-small is 1536, text-embedding-3-large is 3072. +# OPENAI_EMBEDDING_DIMENSIONS=1536 + # ----------------------- # Embedding Providers # ----------------------- # Uses OpenAI by default. Uncomment to use alternatives: -# EMBEDDING_PROVIDER=openai # openai or ollama -# LLM_PROVIDER=openai # openai, anthropic, or ollama +# EMBEDDING_PROVIDER=openai # openai, azure_openai, or ollama +# LLM_PROVIDER=openai # openai, azure_openai, anthropic, or ollama # OPENAI_EMBEDDING_MAX_TOKENS_PER_REQUEST=250000 # OPENAI_EMBEDDING_MAX_TEXTS_PER_REQUEST=128 # OPENAI_EMBEDDING_REQUEST_CONCURRENCY=1 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 805e4c5..8fafcb1 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -20,7 +20,10 @@ aiosqlite>=0.19.0 chromadb>=1.0.0 # LLM Providers -openai>=1.12.0 +# >=1.106.0 is the floor Microsoft documents for Azure OpenAI's v1 surface and for +# passing a callable token provider as api_key. NotFoundError/PermissionDeniedError, +# used by the provider-aware health check, are also only exported on modern versions. +openai>=1.106.0 anthropic>=0.18.0 tiktoken>=0.6.0 diff --git a/apps/api/src/config.py b/apps/api/src/config.py index e77528c..0428795 100644 --- a/apps/api/src/config.py +++ b/apps/api/src/config.py @@ -29,6 +29,7 @@ "chroma_persist_dir", "repos_dir", "vector_db_type", + "azure_openai_tokenizer_model", ) @@ -64,7 +65,7 @@ class Settings(BaseSettings): qdrant_api_key: Optional[str] = None # LLM Providers - llm_provider: str = "openai" # "openai", "anthropic", "ollama" + llm_provider: str = "openai" # "openai", "azure_openai", "anthropic", "ollama" openai_api_key: Optional[str] = None openai_model: str = "gpt-4o" anthropic_api_key: Optional[str] = None @@ -72,10 +73,32 @@ class Settings(BaseSettings): ollama_base_url: str = "http://localhost:11434" ollama_model: str = "llama3.1" + # Azure OpenAI + # + # Targets Azure's v1 OpenAI-compatible surface, so the standard OpenAI client is + # used rather than AzureOpenAI (whose static types the openai SDK README warns + # "can be incorrect"). azure_openai_base_url() below appends /openai/v1. + # + # Note that on Azure the *deployment name* takes the place of the model name in + # API calls. It is frequently not a model id, which is why the tokenizer must be + # named separately -- see azure_openai_tokenizer_model. + azure_openai_endpoint: Optional[str] = None # e.g. https://my-resource.openai.azure.com + azure_openai_api_key: Optional[str] = None + azure_openai_deployment: Optional[str] = None # chat deployment name + azure_openai_embedding_deployment: Optional[str] = None # embedding deployment name + # tiktoken cannot resolve an encoding from a deployment name; without this it + # silently falls back to cl100k_base, which is wrong for o200k_base models and + # makes every token count (and therefore every truncation) quietly inaccurate. + azure_openai_tokenizer_model: str = "text-embedding-3-small" + # Embedding Providers - embedding_provider: str = "openai" # "openai" or "ollama" + embedding_provider: str = "openai" # "openai", "azure_openai" or "ollama" openai_embedding_model: str = "text-embedding-3-small" openai_base_url: Optional[str] = None # Optional: OpenAI-compatible endpoint (e.g., LM Studio) + # Must match the deployed model's output size. text-embedding-3-small is 1536, + # text-embedding-3-large is 3072; a mismatch is only discovered when Chroma + # rejects the insert, so it is configurable rather than hardcoded. + openai_embedding_dimensions: int = 1536 openai_embedding_max_tokens_per_request: int = 250000 openai_embedding_max_texts_per_request: int = 128 openai_embedding_request_concurrency: int = 1 @@ -223,6 +246,26 @@ def _blank_falls_back_to_default(cls, value, info: ValidationInfo): return field.default return value + def azure_openai_base_url(self) -> str: + """ + Base URL for Azure's v1 OpenAI-compatible surface. + + Azure exposes an OpenAI-compatible API at /openai/v1, which lets the + standard OpenAI client talk to it directly. Accepts an endpoint with or without + a trailing slash, and is idempotent if the caller already included /openai/v1. + """ + endpoint = (self.azure_openai_endpoint or "").strip().rstrip("/") + if not endpoint: + raise ValueError( + "AZURE_OPENAI_ENDPOINT is required when using the azure_openai provider " + "(e.g. https://my-resource.openai.azure.com)" + ) + if endpoint.endswith("/openai/v1"): + return endpoint + if endpoint.endswith("/openai"): + return f"{endpoint}/v1" + return f"{endpoint}/openai/v1" + @property def cors_origins(self) -> List[str]: """ diff --git a/apps/api/src/core/embeddings/factory.py b/apps/api/src/core/embeddings/factory.py index ccf3ef5..b47da84 100644 --- a/apps/api/src/core/embeddings/factory.py +++ b/apps/api/src/core/embeddings/factory.py @@ -8,11 +8,37 @@ def create_embedding_service() -> BaseEmbeddings: """Factory function to create embedding service based on configuration.""" provider = settings.embedding_provider.lower() - if provider == "openai": + if provider in ("azure_openai", "azure"): + # Same client, Azure v1 base_url, deployment name in place of the model id. + # tokenizer_model is passed separately because tiktoken cannot resolve an + # encoding from a deployment name. + if not settings.azure_openai_api_key: + raise ValueError("AZURE_OPENAI_API_KEY required for the azure_openai provider") + if not settings.azure_openai_embedding_deployment: + raise ValueError( + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT required for the azure_openai " + "embedding provider (the embedding deployment name)" + ) + return OpenAIEmbeddings( + api_key=settings.azure_openai_api_key, + model=settings.azure_openai_embedding_deployment, + base_url=settings.azure_openai_base_url(), + dimensions=settings.openai_embedding_dimensions, + tokenizer_model=settings.azure_openai_tokenizer_model, + max_tokens_per_request=settings.openai_embedding_max_tokens_per_request, + max_texts_per_request=settings.openai_embedding_max_texts_per_request, + request_concurrency=settings.openai_embedding_request_concurrency, + min_seconds_between_requests=settings.openai_embedding_min_seconds_between_requests, + rate_limit_max_retries=settings.openai_embedding_rate_limit_max_retries, + rate_limit_base_backoff_seconds=settings.openai_embedding_rate_limit_base_backoff_seconds, + rate_limit_max_backoff_seconds=settings.openai_embedding_rate_limit_max_backoff_seconds, + ) + elif provider == "openai": return OpenAIEmbeddings( api_key=settings.openai_api_key, model=settings.openai_embedding_model, base_url=settings.openai_base_url, + dimensions=settings.openai_embedding_dimensions, max_tokens_per_request=settings.openai_embedding_max_tokens_per_request, max_texts_per_request=settings.openai_embedding_max_texts_per_request, request_concurrency=settings.openai_embedding_request_concurrency, @@ -31,11 +57,12 @@ def create_embedding_service() -> BaseEmbeddings: max_failure_ratio=settings.ollama_embedding_max_failure_ratio, ) else: - # Fallback/Default or Raise - # For now, if unknown, default to OpenAI if key exists, else error - if settings.openai_api_key: - return OpenAIEmbeddings( - api_key=settings.openai_api_key, - model=settings.openai_embedding_model - ) - raise ValueError(f"Unknown embedding provider: {provider}") + # Fail fast, matching src/core/llm/factory.py. This previously fell back to + # OpenAI whenever a key happened to be present, which meant a typo in + # EMBEDDING_PROVIDER silently produced an OpenAI client with *none* of the + # rate-limit, batching or pacing settings applied -- so indexing behaved + # differently from the configured provider with no error anywhere. + raise ValueError( + f"Unknown embedding provider: {provider!r}. " + "Expected one of: openai, azure_openai, ollama." + ) diff --git a/apps/api/src/core/embeddings/openai_embeddings.py b/apps/api/src/core/embeddings/openai_embeddings.py index 55b81f5..d8e617e 100644 --- a/apps/api/src/core/embeddings/openai_embeddings.py +++ b/apps/api/src/core/embeddings/openai_embeddings.py @@ -8,7 +8,7 @@ import random import threading import time -from typing import List, Sequence +from typing import Callable, List, Sequence import tiktoken from openai import AsyncOpenAI, RateLimitError @@ -23,9 +23,11 @@ class OpenAIEmbeddings(BaseEmbeddings): def __init__( self, - api_key: str = None, + api_key: str | Callable[[], str] | None = None, model: str = "text-embedding-3-small", base_url: str | None = None, + dimensions: int = 1536, + tokenizer_model: str | None = None, max_tokens_per_request: int = 250000, max_texts_per_request: int = 128, request_concurrency: int = 1, @@ -39,7 +41,7 @@ def __init__( client_kwargs["base_url"] = base_url self._client = AsyncOpenAI(**client_kwargs) self._model = model - self._dimensions = 1536 + self._dimensions = int(dimensions) self._max_tokens = 8000 # Leave some buffer from 8192 limit self._max_tokens_per_request = max(1, max_tokens_per_request) self._max_texts_per_request = max(1, max_texts_per_request) @@ -54,10 +56,23 @@ def __init__( ) self._request_pacing_lock = threading.Lock() self._next_request_time = 0.0 + # tiktoken resolves an encoding from a *model id*. On Azure `model` is a + # deployment name, which will not resolve -- and the bare fallback below is + # silent, so every token count (and therefore every truncation in + # _truncate_text and every batch split in _split_batches) would be computed + # with the wrong encoding without any signal. tokenizer_model lets the caller + # name the real model; the fallback now warns instead of hiding it. + resolve_from = tokenizer_model or model try: - self._tokenizer = tiktoken.encoding_for_model(model) + self._tokenizer = tiktoken.encoding_for_model(resolve_from) except KeyError: self._tokenizer = tiktoken.get_encoding("cl100k_base") + logger.warning( + "tiktoken has no encoding for %r; falling back to cl100k_base. Token " + "counts will be approximate. Set AZURE_OPENAI_TOKENIZER_MODEL (or pass " + "tokenizer_model) to the underlying model id to fix this.", + resolve_from, + ) @property def dimensions(self) -> int: diff --git a/apps/api/src/core/llm/factory.py b/apps/api/src/core/llm/factory.py index aa5be30..cb30486 100644 --- a/apps/api/src/core/llm/factory.py +++ b/apps/api/src/core/llm/factory.py @@ -15,6 +15,22 @@ def create_llm() -> BaseLLM: model=settings.openai_model, base_url=settings.openai_base_url, ) + elif provider in ("azure_openai", "azure"): + # Azure's v1 surface is OpenAI-compatible, so the same client is reused with a + # different base_url. The deployment name takes the place of the model name. + if not settings.azure_openai_api_key: + raise ValueError("AZURE_OPENAI_API_KEY required for the azure_openai provider") + if not settings.azure_openai_deployment: + raise ValueError( + "AZURE_OPENAI_DEPLOYMENT required for the azure_openai provider " + "(the chat deployment name, which Azure uses in place of a model id)" + ) + return OpenAILLM( + api_key=settings.azure_openai_api_key, + model=settings.azure_openai_deployment, + base_url=settings.azure_openai_base_url(), + provider_label="azure_openai", + ) elif provider == "anthropic": if not settings.anthropic_api_key: # Don't raise immediately, allow app to start but fail on use if key missing diff --git a/apps/api/src/core/llm/openai_llm.py b/apps/api/src/core/llm/openai_llm.py index df62596..4d21dc3 100644 --- a/apps/api/src/core/llm/openai_llm.py +++ b/apps/api/src/core/llm/openai_llm.py @@ -4,9 +4,15 @@ import asyncio import logging -from typing import AsyncGenerator, Dict, List +from typing import AsyncGenerator, Callable, Dict, List -from openai import AsyncOpenAI +from openai import ( + APIStatusError, + AsyncOpenAI, + AuthenticationError, + NotFoundError, + PermissionDeniedError, +) from src.core.llm.base import BaseLLM, stream_error_text @@ -16,13 +22,23 @@ class OpenAILLM(BaseLLM): """OpenAI LLM service with retry logic.""" - def __init__(self, api_key: str = None, model: str = "gpt-4o", base_url: str | None = None): + def __init__( + self, + api_key: str | Callable[[], str] | None = None, + model: str = "gpt-4o", + base_url: str | None = None, + provider_label: str = "openai", + ): + # api_key accepts a callable so a token provider (e.g. Entra ID) can be passed + # without this class needing to know how the credential is obtained. client_kwargs = {"api_key": api_key} if base_url: client_kwargs["base_url"] = base_url self._client = AsyncOpenAI(**client_kwargs) self._model = model self._max_retries = 3 + # Only used for log messages; behaviour is identical across OpenAI-compatible hosts. + self._provider_label = provider_label async def _retry_with_backoff(self, func, *args, **kwargs): """Retry with exponential backoff.""" @@ -122,11 +138,36 @@ async def generate_stream( return async def health_check(self) -> bool: - """Check OpenAI API availability.""" + """ + Check provider availability. + + Distinguishes "cannot reach the provider" from "provider does not implement + /models". Azure serves an OpenAI-compatible surface but /models enumerates + *deployments*, and some configurations do not expose it at all -- a 404 there + means the endpoint answered, so credentials and networking are fine and the + service is usable. Treating that as unhealthy would report a working Azure + deployment as down. + """ try: - # Simple models list call to verify API key await self._client.models.list() return True + except NotFoundError: + logger.info( + "%s does not expose /models; treating as reachable (endpoint responded)", + self._provider_label, + ) + return True + except (AuthenticationError, PermissionDeniedError) as e: + logger.warning("%s health check failed: bad credentials: %s", self._provider_label, e) + return False + except APIStatusError as e: + # Any other HTTP status still proves the endpoint is reachable, but an + # unexpected status is worth surfacing rather than silently passing. + logger.warning( + "%s health check got unexpected status %s: %s", + self._provider_label, e.status_code, e, + ) + return False except Exception as e: - logger.warning(f"OpenAI health check failed: {e}") + logger.warning("%s health check failed: %s", self._provider_label, e) return False diff --git a/apps/api/tests/unit/test_azure_openai_provider.py b/apps/api/tests/unit/test_azure_openai_provider.py new file mode 100644 index 0000000..8d406ca --- /dev/null +++ b/apps/api/tests/unit/test_azure_openai_provider.py @@ -0,0 +1,231 @@ +""" +Azure OpenAI provider wiring. + +These tests never reach the network: they assert on how the clients are constructed, +which is where every Azure-vs-OpenAI divergence actually lives. +""" + +import pytest +import tiktoken +from openai import APIStatusError, AuthenticationError, NotFoundError + +from src.config import Settings +from src.core.embeddings.factory import create_embedding_service +from src.core.embeddings.openai_embeddings import OpenAIEmbeddings +from src.core.llm.factory import create_llm +from src.core.llm.openai_llm import OpenAILLM + + +def _azure_settings(**overrides): + base = dict( + llm_provider="azure_openai", + embedding_provider="azure_openai", + azure_openai_endpoint="https://my-resource.openai.azure.com", + azure_openai_api_key="azure-test-key", + azure_openai_deployment="gpt-4o-prod", + azure_openai_embedding_deployment="embed-3-small-prod", + ) + base.update(overrides) + return Settings(_env_file=None, **base) + + +# --- base URL construction ------------------------------------------------------- + +@pytest.mark.parametrize( + "endpoint,expected", + [ + ("https://r.openai.azure.com", "https://r.openai.azure.com/openai/v1"), + ("https://r.openai.azure.com/", "https://r.openai.azure.com/openai/v1"), + ("https://r.openai.azure.com/openai", "https://r.openai.azure.com/openai/v1"), + # idempotent: already-complete URLs are not double-suffixed + ("https://r.openai.azure.com/openai/v1", "https://r.openai.azure.com/openai/v1"), + ("https://r.openai.azure.com/openai/v1/", "https://r.openai.azure.com/openai/v1"), + ], +) +def test_azure_base_url_normalises_endpoint(endpoint, expected): + s = _azure_settings(azure_openai_endpoint=endpoint) + assert s.azure_openai_base_url() == expected + + +def test_azure_base_url_requires_endpoint(): + s = _azure_settings(azure_openai_endpoint=None) + with pytest.raises(ValueError, match="AZURE_OPENAI_ENDPOINT"): + s.azure_openai_base_url() + + +# --- factory wiring -------------------------------------------------------------- + +def test_llm_factory_builds_azure_client(monkeypatch): + settings = _azure_settings() + monkeypatch.setattr("src.core.llm.factory.settings", settings) + + llm = create_llm() + + assert isinstance(llm, OpenAILLM) + # The deployment name must be sent where a model id normally goes. + assert llm._model == "gpt-4o-prod" + assert str(llm._client.base_url).rstrip("/").endswith("/openai/v1") + + +def test_embedding_factory_builds_azure_client(monkeypatch): + settings = _azure_settings() + monkeypatch.setattr("src.core.embeddings.factory.settings", settings) + + emb = create_embedding_service() + + assert isinstance(emb, OpenAIEmbeddings) + assert emb._model == "embed-3-small-prod" + assert str(emb._client.base_url).rstrip("/").endswith("/openai/v1") + + +@pytest.mark.parametrize( + "missing,expected", + [ + ("azure_openai_api_key", "AZURE_OPENAI_API_KEY"), + ("azure_openai_deployment", "AZURE_OPENAI_DEPLOYMENT"), + ], +) +def test_llm_factory_requires_azure_config(monkeypatch, missing, expected): + monkeypatch.setattr("src.core.llm.factory.settings", _azure_settings(**{missing: None})) + with pytest.raises(ValueError, match=expected): + create_llm() + + +def test_embedding_factory_requires_deployment(monkeypatch): + monkeypatch.setattr( + "src.core.embeddings.factory.settings", + _azure_settings(azure_openai_embedding_deployment=None), + ) + with pytest.raises(ValueError, match="AZURE_OPENAI_EMBEDDING_DEPLOYMENT"): + create_embedding_service() + + +def test_unknown_embedding_provider_raises_instead_of_falling_back(monkeypatch): + """A typo used to silently produce an OpenAI client with no tuning applied.""" + monkeypatch.setattr( + "src.core.embeddings.factory.settings", + _azure_settings(embedding_provider="opanai", openai_api_key="sk-present"), + ) + with pytest.raises(ValueError, match="Unknown embedding provider"): + create_embedding_service() + + +# --- tokenizer resolution -------------------------------------------------------- + +def test_deployment_name_does_not_silently_misresolve_tokenizer(): + """A deployment name cannot resolve in tiktoken; tokenizer_model must win.""" + with pytest.raises(KeyError): + tiktoken.encoding_for_model("embed-3-small-prod") + + emb = OpenAIEmbeddings( + api_key="k", + model="embed-3-small-prod", + tokenizer_model="text-embedding-3-small", + ) + assert emb._tokenizer.name == tiktoken.encoding_for_model("text-embedding-3-small").name + + +def test_unresolvable_tokenizer_warns_rather_than_silently_falling_back(caplog): + with caplog.at_level("WARNING"): + emb = OpenAIEmbeddings(api_key="k", model="some-unknown-deployment") + assert emb._tokenizer.name == "cl100k_base" + assert "falling back to cl100k_base" in caplog.text + + +# --- dimensions ------------------------------------------------------------------ + +def test_dimensions_are_configurable(): + """text-embedding-3-large is 3072; a hardcoded 1536 breaks the Chroma insert.""" + assert OpenAIEmbeddings(api_key="k").dimensions == 1536 + assert OpenAIEmbeddings(api_key="k", dimensions=3072).dimensions == 3072 + + +def test_embedding_factory_passes_configured_dimensions(monkeypatch): + monkeypatch.setattr( + "src.core.embeddings.factory.settings", + _azure_settings(openai_embedding_dimensions=3072), + ) + assert create_embedding_service().dimensions == 3072 + + +# --- provider-aware health check ------------------------------------------------- + +def _response(status): + import httpx + request = httpx.Request("GET", "https://example.invalid/openai/v1/models") + return httpx.Response(status, request=request) + + +@pytest.mark.asyncio +async def test_health_check_treats_missing_models_endpoint_as_reachable(monkeypatch): + """ + Azure's /models lists deployments and some configurations omit it. A 404 means + the endpoint answered, so the provider is usable -- reporting unhealthy here + would mark a working deployment as down. + """ + llm = OpenAILLM(api_key="k", model="d", provider_label="azure_openai") + + async def raise_404(): + raise NotFoundError("no models route", response=_response(404), body=None) + + monkeypatch.setattr(llm._client.models, "list", raise_404) + assert await llm.health_check() is True + + +@pytest.mark.asyncio +async def test_health_check_fails_on_bad_credentials(monkeypatch): + llm = OpenAILLM(api_key="bad", model="d") + + async def raise_401(): + raise AuthenticationError("bad key", response=_response(401), body=None) + + monkeypatch.setattr(llm._client.models, "list", raise_401) + assert await llm.health_check() is False + + +@pytest.mark.asyncio +async def test_health_check_fails_on_unexpected_status(monkeypatch): + llm = OpenAILLM(api_key="k", model="d") + + async def raise_500(): + raise APIStatusError("boom", response=_response(500), body=None) + + monkeypatch.setattr(llm._client.models, "list", raise_500) + assert await llm.health_check() is False + + +@pytest.mark.asyncio +async def test_health_check_fails_when_unreachable(monkeypatch): + llm = OpenAILLM(api_key="k", model="d") + + async def raise_conn(): + raise ConnectionError("dns failure") + + monkeypatch.setattr(llm._client.models, "list", raise_conn) + assert await llm.health_check() is False + + +@pytest.mark.asyncio +async def test_health_check_passes_when_models_list_works(monkeypatch): + llm = OpenAILLM(api_key="k", model="d") + + async def ok(): + return object() + + monkeypatch.setattr(llm._client.models, "list", ok) + assert await llm.health_check() is True + + +# --- api_key accepts a token provider -------------------------------------------- + +def test_api_key_accepts_callable_token_provider(): + """Entra ID / managed identity supplies a callable rather than a static string.""" + calls = [] + + def token_provider(): + calls.append(1) + return "token-from-provider" + + llm = OpenAILLM(api_key=token_provider, model="d") + emb = OpenAIEmbeddings(api_key=token_provider, model="text-embedding-3-small") + assert llm._client is not None and emb._client is not None diff --git a/docker/README.md b/docker/README.md index d055326..b1e4c44 100644 --- a/docker/README.md +++ b/docker/README.md @@ -50,6 +50,33 @@ So the SQLite database, the ChromaDB directory and every cloned repository live - `docker-compose.yml` also declares a named volume `data:` that nothing mounts. It has no effect; the bind mount above is what is actually used. +## Using Azure OpenAI + +Set these in `docker/.env` (compose forwards all of them): + +```bash +LLM_PROVIDER=azure_openai +EMBEDDING_PROVIDER=azure_openai +AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com +AZURE_OPENAI_API_KEY=... +AZURE_OPENAI_DEPLOYMENT=my-gpt4o-deployment +AZURE_OPENAI_EMBEDDING_DEPLOYMENT=my-embedding-deployment +AZURE_OPENAI_TOKENIZER_MODEL=text-embedding-3-small +# match your deployed embedding model: 3-small is 1536, 3-large is 3072 +OPENAI_EMBEDDING_DIMENSIONS=1536 +``` + +Two Azure-specific gotchas, both handled but worth understanding: + +- **Deployment names, not model ids.** Azure sends the deployment name where a model + id normally goes, so `AZURE_OPENAI_DEPLOYMENT` is what reaches the API as `model`. +- **`/models` lists deployments.** Some Azure configurations do not expose that route + at all. `/api/health` treats a 404 there as reachable (the endpoint answered) while + still failing on 401/403, so a working deployment is not reported as down. + +Authentication is API-key only. Entra ID / managed identity is not wired up — the +client accepts a callable token provider, but nothing here supplies one. + ## Using Ollama from inside Docker `OLLAMA_BASE_URL` defaults to `http://localhost:11434`, which is correct when you diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e102069..3984d43 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -33,8 +33,15 @@ services: - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} - OPENAI_BASE_URL=${OPENAI_BASE_URL:-} - OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-text-embedding-3-small} + - OPENAI_EMBEDDING_DIMENSIONS=${OPENAI_EMBEDDING_DIMENSIONS:-1536} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-sonnet-4-20250514} + # --- Azure OpenAI (v1 OpenAI-compatible surface; deployment names, not model ids) --- + - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-} + - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-} + - AZURE_OPENAI_DEPLOYMENT=${AZURE_OPENAI_DEPLOYMENT:-} + - AZURE_OPENAI_EMBEDDING_DEPLOYMENT=${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:-} + - AZURE_OPENAI_TOKENIZER_MODEL=${AZURE_OPENAI_TOKENIZER_MODEL:-text-embedding-3-small} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://localhost:11434} - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1} - LOCAL_EMBEDDING_MODEL=${LOCAL_EMBEDDING_MODEL:-nomic-embed-text} diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..ad45955 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,22 @@ +# Terraform state contains every resolved value in PLAINTEXT, including +# openai_api_key, azure_openai_api_key, github_token and the Vercel env var -- marking +# a variable `sensitive` only redacts it from CLI output, never from state. +# Committing state would publish the keys. +*.tfstate +*.tfstate.* +*.tfstate.backup +.terraform/ +.terraform.lock.hcl.bak + +# Variable files hold the same secrets. +*.tfvars +*.tfvars.json +!example.tfvars + +# Plan files also embed resolved values. +*.tfplan +crash.log +crash.*.log + +# .terraform.lock.hcl is intentionally NOT ignored -- it pins provider checksums and +# belongs in version control. diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..d8addda --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,48 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/digitalocean/digitalocean" { + version = "2.99.1" + constraints = "~> 2.99" + hashes = [ + "h1:LOtdHmbsdRbEWhv5rq0w4/29FyxPU1gxl1zVSoS/CEU=", + "zh:24b9e8fc3459f2582dc11910c3b2a190ba43d8bf93c2bd29fda3de5d7d1c1c3f", + "zh:274eec1b7c1c749dd84251abd607dc6e7ef5058a3c8e7d2ec2e859267d93be65", + "zh:3148da88f1870ed8c47886c403299e09bebcb2517df77b194019b113aa49999a", + "zh:3e9515002777cf69dd3809a96d137ca8d97a8654aed54651e5a54f56a383373d", + "zh:4d1563ac616d06e9d05fe89de7af38fb9df03cc4d655d41eedc204381f2d4d34", + "zh:504995f8401827a130114c6e23dd218e48a1c6a5821603b735d6fea00e419cf3", + "zh:6fd902137a90d52043b86337a26a897c73fd9d021606e01b0b5d3c64581ee660", + "zh:7360cef44fbe4fe8807a64902549bf7039ef990070809f7c33e19f66217ed2e9", + "zh:73c35d944df506fff440662532e048c897ecd8eb86b091dcc044fecdb85ff447", + "zh:8ebbd456187a2b6c5b8f66469954844eef08ac090348b941fcd14d17fa774fe4", + "zh:a9d8db2b9df0c7986303f090811cdec8462bae22e7e37d13688f5cfd54cb1243", + "zh:acf80e685507a9700409cfcfee303f7081d2eaeefe7c192df5a3a0fa3bc0a717", + "zh:ceaba1bebd159e3950648d73774a83deba9f17d0c11ae534670deac486627e14", + "zh:d0d3ab299ff6c8dbc538fc3e0bb2490a4b60873915bcb2e6b0ab97720c424e70", + "zh:ed616b2b40a937fa6029a3fbc7b8a58a9c8db25b3c4c3451e7f8ee11c43b2994", + "zh:f183b1e428232e110404f0decbc136b678ae6cb662a7d088f05e7ceee25f5e81", + ] +} + +provider "registry.terraform.io/vercel/vercel" { + version = "5.10.0" + constraints = "~> 5.10" + hashes = [ + "h1:1i7nMcyCpalSy+s6C+pZshudewluHtkUq+lID8Ikpa4=", + "zh:0bba268730d5cc2a85298d094f59f0c138914e1278e487949dfe6a16c8bf0114", + "zh:11cd9847ddf873c0284904a31fc412e8daebc9473e6b29532cfc992f010fbce5", + "zh:163d1a591db2545ecee0ffe58d39c89cfdbc06e7ab568c34a7f18baf2c2c5325", + "zh:67cf1d48b2d88070020f3fb631f3da83e33822a0aec81bdb0656c43d4ba7f57b", + "zh:7c15da78528f6c02e7cc5d5d70c60bc70aa5aa799445fefd070c488208c7a422", + "zh:82c420d631244386de73564ef492bea5ec5551618047a1980f0035bfd39b0ca1", + "zh:909609e9ea1e8d631afc845816d7e38e719da8b285e42d608d4991950fc229b0", + "zh:9205b92fcbca683344082945dc32947f85c5b94541753e7e3919cbca86af4ea0", + "zh:b1ae6dbc41cdbd5e7d47dda97157ad3d91bf73f9f484f34b46ec3843261462ce", + "zh:d1a1e2c3f16561bf534d290dc5741ef49c43abcf923fa261a38136950a7b7786", + "zh:d69dcc649d6b3d403013f9d061e2704f05b70edc715b43d727de8d5d3711a8bf", + "zh:e935b292e50b9b9c16eda5c6682b314c269e48828d07a68dbf17e91401bea8f9", + "zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597", + "zh:f7e4abf49873103206cae14a0500bcd9ec7bc1feae58c3379a37db39e5a59fdf", + ] +} diff --git a/infra/terraform/README.md b/infra/terraform/README.md new file mode 100644 index 0000000..a3af58a --- /dev/null +++ b/infra/terraform/README.md @@ -0,0 +1,96 @@ +# Infrastructure + +Terraform for the one thing this project did not have: **a deployed API**. + +`apps/web` has been live on Vercel, but `apps/web/src/lib/api-client.ts:1` reads + +```ts +process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' +``` + +and nothing ever set that variable, so the deployed frontend fell back to localhost and +could not reach a backend. `docker/docker-compose.yml` was the only deployment artifact +and it is a local-development file. This module provisions the host, the block volume +the app's state requires, and wires `NEXT_PUBLIC_API_URL` from the host's address — +so one stack's output feeds the other's input. + +## What it creates + +| Resource | Why | Cost | +|---|---|---| +| `digitalocean_droplet` | Runs `docker/docker-compose.yml` via cloud-init | ~$6/mo (`s-1vcpu-1gb`) | +| `digitalocean_volume` | SQLite DB + Chroma + cloned repos need a **real block device** | ~$1/mo (10GB) | +| `digitalocean_volume_attachment` | Attaches it | — | +| `digitalocean_firewall` | Opens 8000, restricts 22, **blocks 6379** | free | +| `digitalocean_project` | Groups the resources | free | +| `vercel_project_environment_variable` | Sets `NEXT_PUBLIC_API_URL` from the droplet IP | free | + +Roughly **$7/month** at the defaults. + +## Why DigitalOcean and not Fly + +Fly is the better-known choice for this shape of app, and it was the original plan. Its +**Terraform provider is not maintained**: + +| Provider | Latest | Published | Tier | +|---|---|---|---| +| `fly-apps/fly` | 0.0.23 | 2023-06-22 | partner, abandoned | +| `andrewbaxter/fly` (fork) | 0.1.18 | 2024-10-28 | community | +| `digitalocean/digitalocean` | 2.99.1 | 2026-08-06 | partner, 13.3M downloads | + +Managing Fly through a provider stuck on 0.0.23 for three years would undermine the +point of using Terraform at all. A Droplet also gives a plain block device, which is +what SQLite and Chroma actually need — Azure Container Apps was rejected for the same +reason (no block-device volume type; only Azure Files over SMB/NFS, which is exactly the +configuration `sqlite.org/howtocorrupt.html` §2.1 warns about). + +## Usage + +```bash +cp example.tfvars secrets.tfvars # then fill it in +terraform init +terraform plan -var-file=secrets.tfvars +terraform apply -var-file=secrets.tfvars +``` + +Then, in order: + +1. **Wait ~3–5 min.** cloud-init installs Docker and builds the images. + ```bash + ssh root@$(terraform output -raw api_ipv4) 'tail -f /var/log/cloud-init-output.log' + curl "$(terraform output -raw health_url)" + ``` +2. **Redeploy the frontend.** `NEXT_PUBLIC_API_URL` is inlined by Next at *build* time, + so setting the Vercel variable does not affect the existing production build. +3. **Add TLS before expecting the browser to work** — see below. + +## Known limitations, stated rather than discovered later + +- **Plain HTTP.** The droplet serves `http://IP:8000`. A browser on an `https://` + Vercel page will block that as mixed content, so `curl` will work while the site does + not. Finishing properly means pointing a domain at the droplet, terminating TLS + (Caddy, or nginx + certbot), and setting `NEXT_PUBLIC_API_URL` to the `https://` name. +- **Single instance, and it must stay that way.** `apps/api/src/main.py` runs + `init_db` + `run_pending_migrations` unguarded on every startup, and Chroma holds a + process-local client. Two replicas would race the `ALTER TABLE`s. Nothing here + autoscales, and that is deliberate. +- **Local state, single operator.** No remote backend, no locking, no workspaces. Fine + for one person; not a team setup, and not claimed as one. +- **`terraform.tfstate` holds every secret in plaintext.** `sensitive = true` only + redacts CLI output. `.gitignore` in this directory covers state, tfvars and plan + files — check it is in place before your first `apply`. +- **cloud-init runs once.** Editing `cloud-init.yaml.tftpl` shows a `user_data` diff but + changes nothing on a running droplet; it must be replaced. To redeploy the app instead, + `ssh` in and run `/usr/local/bin/codebaseqa-up`. +- **Not applied.** This configuration is `terraform validate`-clean against + digitalocean 2.99.1 and vercel 5.10.0, but it has never been run against real + accounts — no `plan` or `apply` has executed, because that requires live credentials + and creates billable resources. + +## Redis exposure + +`docker/docker-compose.yml` publishes Redis on `6379` for local development. On a +public droplet that would be an unauthenticated Redis facing the internet. Two +independent controls prevent it: the DO firewall has no inbound rule for 6379, and +`ufw` on the host allows only 22 and 8000. The API reaches Redis over the compose +network, which is unaffected. diff --git a/infra/terraform/cloud-init.yaml.tftpl b/infra/terraform/cloud-init.yaml.tftpl new file mode 100644 index 0000000..de6df58 --- /dev/null +++ b/infra/terraform/cloud-init.yaml.tftpl @@ -0,0 +1,80 @@ +#cloud-config +# Brings up the API from docker/docker-compose.yml on the attached block volume. +# +# Runs once, at first boot. Editing this file does not affect a running droplet -- +# Terraform will show a diff on user_data but the droplet must be replaced for it to +# take effect. + +package_update: true +packages: + - ca-certificates + - curl + - git + - ufw + +write_files: + # Compose reads $${VAR} from the file next to docker-compose.yml, which is docker/.env + # -- NOT a .env at the repository root. Written 0600 because it holds API keys. + - path: /etc/codebaseqa.env + permissions: "0600" + owner: root:root + content: | +%{ for k, v in app_env ~} + ${k}=${v} +%{ endfor ~} + + - path: /usr/local/bin/codebaseqa-up + permissions: "0755" + owner: root:root + content: | + #!/usr/bin/env bash + set -euo pipefail + cd ${data_mount}/app + cp /etc/codebaseqa.env docker/.env + # Do not publish Redis on the public interface. The compose file maps 6379 for + # local development; on a public droplet that is an unauthenticated Redis facing + # the internet. The firewall already blocks it -- this removes the mapping too, + # so the exposure does not depend on a single control. + docker compose -f docker/docker-compose.yml up -d --build --remove-orphans + docker compose -f docker/docker-compose.yml ps + +runcmd: + # --- docker engine from the official repository --- + - install -m 0755 -d /etc/apt/keyrings + - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + - chmod a+r /etc/apt/keyrings/docker.asc + - > + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" + > /etc/apt/sources.list.d/docker.list + - apt-get update + - DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # --- wait for the block volume, then mount it --- + # The volume attaches asynchronously, so it may not exist when runcmd starts. + - | + DEV=/dev/disk/by-id/scsi-0DO_Volume_${volume_name} + for i in $(seq 1 60); do [ -e "$DEV" ] && break; sleep 2; done + if [ ! -e "$DEV" ]; then echo "volume never appeared: $DEV" >&2; exit 1; fi + # Only format if there is no filesystem -- never reformat an existing volume. + if ! blkid "$DEV" >/dev/null 2>&1; then mkfs.ext4 -F "$DEV"; fi + mkdir -p ${data_mount} + grep -q "${data_mount}" /etc/fstab || \ + echo "$DEV ${data_mount} ext4 defaults,nofail,discard 0 2" >> /etc/fstab + mount -a + + # --- application --- + - git clone --depth 1 --branch ${git_ref} ${repo_url} ${data_mount}/app || (cd ${data_mount}/app && git fetch --depth 1 origin ${git_ref} && git checkout FETCH_HEAD) + - mkdir -p ${data_mount}/app/data + + # --- host firewall in addition to the DO cloud firewall --- + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow 22/tcp + - ufw allow 8000/tcp + - ufw --force enable + + - /usr/local/bin/codebaseqa-up + + # --- restart the stack on reboot --- + - systemctl enable docker diff --git a/infra/terraform/example.tfvars b/infra/terraform/example.tfvars new file mode 100644 index 0000000..32b90b1 --- /dev/null +++ b/infra/terraform/example.tfvars @@ -0,0 +1,32 @@ +# Copy to secrets.tfvars (gitignored) and fill in. +# terraform plan -var-file=secrets.tfvars +# +# Everything marked sensitive still lands in terraform.tfstate in PLAINTEXT. +# `sensitive` only hides values from CLI output. Keep state off git. + +digitalocean_token = "dop_v1_..." +vercel_api_token = "..." +# vercel_team_id = "team_..." # omit for a personal account + +# From `doctl compute ssh-key list`. Without this you cannot reach the droplet. +ssh_key_fingerprints = ["aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"] + +# Your own address. Deliberately not 0.0.0.0/0. +ssh_allowed_cidrs = ["203.0.113.4/32"] + +# --- application --- +openai_api_key = "sk-..." +# github_token = "ghp_..." # only needed for private repositories + +# Or Azure instead of public OpenAI: +# llm_provider = "azure_openai" +# embedding_provider = "azure_openai" +# azure_openai_endpoint = "https://my-resource.openai.azure.com" +# azure_openai_api_key = "..." +# azure_openai_deployment = "my-gpt4o-deployment" +# azure_openai_embedding_deployment = "my-embedding-deployment" + +# --- sizing --- +# droplet_size = "s-1vcpu-1gb" # ~$6/mo; s-2vcpu-2gb (~$18) if you index large repos +# volume_size_gb = 10 # ~$1/mo; clones dominate usage +# region = "nyc3" diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..3c80382 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,125 @@ +locals { + data_mount = "/mnt/${var.name}-data" + + # The API talks to Redis over the compose network, so Redis is NOT published to the + # internet (see the firewall below). This mirrors docker-compose.yml, where redis is + # only reachable from the api service. + app_env = merge( + { + LLM_PROVIDER = var.llm_provider + EMBEDDING_PROVIDER = var.embedding_provider + DEMO_MODE = var.demo_mode ? "true" : "false" + SEED_DEMO = var.demo_mode ? "true" : "false" + # Compose bind-mounts ../data, so the app's relative paths resolve inside the + # volume once the repo is checked out under the mount point. + DATABASE_URL = "sqlite:///./data/codebaseqa.db" + REDIS_URL = "redis://redis:6379/0" + }, + var.openai_api_key == "" ? {} : { OPENAI_API_KEY = var.openai_api_key }, + var.github_token == "" ? {} : { GITHUB_TOKEN = var.github_token }, + var.azure_openai_endpoint == "" ? {} : { + AZURE_OPENAI_ENDPOINT = var.azure_openai_endpoint + AZURE_OPENAI_API_KEY = var.azure_openai_api_key + AZURE_OPENAI_DEPLOYMENT = var.azure_openai_deployment + AZURE_OPENAI_EMBEDDING_DEPLOYMENT = var.azure_openai_embedding_deployment + }, + ) +} + +# Block storage for everything stateful. Separate from the droplet on purpose: the +# droplet can be destroyed and recreated without losing the index, and SQLite plus +# Chroma both need a real block device rather than a network filesystem. +resource "digitalocean_volume" "data" { + name = "${var.name}-data" + region = var.region + size = var.volume_size_gb + initial_filesystem_type = "ext4" + description = "SQLite database, Chroma vector store and cloned repositories" +} + +resource "digitalocean_droplet" "api" { + name = "${var.name}-api" + region = var.region + size = var.droplet_size + image = "ubuntu-24-04-x64" + ssh_keys = var.ssh_key_fingerprints + + monitoring = true + ipv6 = true + + user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + repo_url = var.git_repo_url + git_ref = var.git_ref + data_mount = local.data_mount + volume_name = digitalocean_volume.data.name + app_env = local.app_env + }) + + tags = [var.name, "api"] + + lifecycle { + # user_data is only read at first boot, so a change here would silently do nothing + # unless the droplet is replaced. Making that explicit rather than surprising. + create_before_destroy = false + } +} + +resource "digitalocean_volume_attachment" "data" { + droplet_id = digitalocean_droplet.api.id + volume_id = digitalocean_volume.data.id +} + +resource "digitalocean_firewall" "api" { + name = "${var.name}-api" + droplet_ids = [digitalocean_droplet.api.id] + + # HTTP: the API itself. + inbound_rule { + protocol = "tcp" + port_range = "8000" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # SSH, restricted. Empty by default so this is a deliberate decision, not a leftover. + dynamic "inbound_rule" { + for_each = length(var.ssh_allowed_cidrs) > 0 ? [1] : [] + content { + protocol = "tcp" + port_range = "22" + source_addresses = var.ssh_allowed_cidrs + } + } + + # Note there is deliberately no rule for 6379. docker-compose.yml publishes Redis on + # the host, which on a public droplet would expose an unauthenticated Redis to the + # internet; the firewall blocks it, and cloud-init also removes the port mapping. + + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + outbound_rule { + protocol = "icmp" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} + +resource "digitalocean_project" "codebaseqa" { + name = var.name + description = "AI-powered codebase understanding and Q&A" + purpose = "Web Application" + environment = "Production" + + resources = [ + digitalocean_droplet.api.urn, + digitalocean_volume.data.urn, + ] +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..3bb5049 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,47 @@ +output "api_ipv4" { + description = "Public IPv4 of the API droplet." + value = digitalocean_droplet.api.ipv4_address +} + +output "api_url" { + description = "Base URL of the API. This is the value written to Vercel's NEXT_PUBLIC_API_URL." + value = "http://${digitalocean_droplet.api.ipv4_address}:8000" +} + +output "health_url" { + description = "Check this first after apply; it reports database, vector store, LLM provider and GitHub API status." + value = "http://${digitalocean_droplet.api.ipv4_address}:8000/health" +} + +output "ssh_command" { + description = "Only works if ssh_allowed_cidrs includes your address." + value = "ssh root@${digitalocean_droplet.api.ipv4_address}" +} + +output "data_volume" { + description = "Block volume holding the SQLite database, Chroma store and clones." + value = { + name = digitalocean_volume.data.name + size_gb = digitalocean_volume.data.size + mount = local.data_mount + } +} + +output "next_steps" { + description = "Manual follow-up that Terraform deliberately does not do." + value = <<-EOT + 1. cloud-init takes ~3-5 minutes after apply (docker install + image build). + Watch it: ssh root@${digitalocean_droplet.api.ipv4_address} 'tail -f /var/log/cloud-init-output.log' + Then: curl http://${digitalocean_droplet.api.ipv4_address}:8000/health + + 2. REDEPLOY THE FRONTEND. NEXT_PUBLIC_API_URL is inlined by Next at build time, + so setting the Vercel variable is not enough on its own -- the existing + production build still has the old value baked in. Trigger a redeploy. + + 3. This serves plain HTTP. The browser calling it from an https:// Vercel page will + be blocked as mixed content. To finish properly: point a domain at the droplet + and terminate TLS (caddy or nginx + certbot), then set NEXT_PUBLIC_API_URL to + the https:// name. Until then, expect the frontend to fail in the browser even + though curl works. + EOT +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..dd24e4a --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,144 @@ +variable "digitalocean_token" { + description = "DigitalOcean API token with read/write scope. Set via TF_VAR_digitalocean_token or DIGITALOCEAN_TOKEN." + type = string + sensitive = true +} + +variable "vercel_api_token" { + description = "Vercel API token, used only to set the frontend's NEXT_PUBLIC_API_URL." + type = string + sensitive = true +} + +variable "vercel_team_id" { + description = "Vercel team id. Leave null for a personal account." + type = string + default = null +} + +variable "vercel_project_name" { + description = "Name of the existing Vercel project serving apps/web." + type = string + default = "codebaseqa-web" +} + +variable "name" { + description = "Name prefix for created resources." + type = string + default = "codebaseqa" +} + +variable "region" { + description = "DigitalOcean region slug. Must be one that supports block storage volumes." + type = string + default = "nyc3" +} + +variable "droplet_size" { + description = <<-EOT + Droplet slug. s-1vcpu-1gb (~$6/mo) is enough to serve the API, but indexing is + CPU-bound and single-threaded per repo, so s-2vcpu-2gb (~$18/mo) is noticeably + better if you index anything large. Chroma plus the API sit around 400-600MB. + EOT + type = string + default = "s-1vcpu-1gb" +} + +variable "volume_size_gb" { + description = <<-EOT + Block volume for /mnt/codebaseqa-data. Holds the SQLite database, the Chroma + directory and every cloned repository -- clones dominate. 10GB (~$1/mo) is a + reasonable start; a single large monorepo clone can be 1-2GB. + EOT + type = number + default = 10 +} + +variable "ssh_key_fingerprints" { + description = <<-EOT + Fingerprints of DigitalOcean SSH keys to install on the droplet. Get them with + `doctl compute ssh-key list`. Without at least one you cannot reach the box, since + password auth is disabled below. + EOT + type = list(string) +} + +variable "ssh_allowed_cidrs" { + description = <<-EOT + Who may reach port 22. Defaults to nothing -- set your own address explicitly, + e.g. ["203.0.113.4/32"]. Deliberately not 0.0.0.0/0. + EOT + type = list(string) + default = [] +} + +variable "git_repo_url" { + description = "Repository cloned onto the droplet by cloud-init to obtain docker-compose.yml." + type = string + default = "https://github.com/ShreeBohara/codebaseqa.git" +} + +variable "git_ref" { + description = "Branch or tag to deploy." + type = string + default = "main" +} + +# --- application configuration ------------------------------------------------- +# These are written to docker/.env on the droplet, which is where compose reads +# ${VAR} interpolation from. + +variable "llm_provider" { + description = "openai | azure_openai | anthropic | ollama" + type = string + default = "openai" +} + +variable "embedding_provider" { + description = "openai | azure_openai | ollama" + type = string + default = "openai" +} + +variable "openai_api_key" { + description = "Required when llm_provider or embedding_provider is openai." + type = string + default = "" + sensitive = true +} + +variable "azure_openai_endpoint" { + description = "e.g. https://my-resource.openai.azure.com" + type = string + default = "" +} + +variable "azure_openai_api_key" { + type = string + default = "" + sensitive = true +} + +variable "azure_openai_deployment" { + description = "Azure chat deployment name (Azure sends this where a model id normally goes)." + type = string + default = "" +} + +variable "azure_openai_embedding_deployment" { + type = string + default = "" +} + +variable "github_token" { + description = "Optional. Required only to clone private repositories." + type = string + default = "" + sensitive = true +} + +variable "demo_mode" { + description = "Pin the deployment to a single featured repository." + type = bool + default = true +} diff --git a/infra/terraform/vercel.tf b/infra/terraform/vercel.tf new file mode 100644 index 0000000..1d2ce9e --- /dev/null +++ b/infra/terraform/vercel.tf @@ -0,0 +1,24 @@ +# The whole point of this file: apps/web/src/lib/api-client.ts:1 reads +# +# process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' +# +# and Next inlines that at BUILD time. So the Vercel-deployed frontend was falling back +# to localhost and could never reach a backend. Setting the variable here, from the +# droplet's address, is what actually connects the two halves of the system. +# +# Because it is inlined at build time, changing it requires a redeploy -- setting the +# variable alone is not enough. See the note in outputs.tf. + +data "vercel_project" "web" { + name = var.vercel_project_name +} + +resource "vercel_project_environment_variable" "api_url" { + project_id = data.vercel_project.web.id + key = "NEXT_PUBLIC_API_URL" + value = "http://${digitalocean_droplet.api.ipv4_address}:8000" + target = ["production", "preview"] + # Explicitly not sensitive: NEXT_PUBLIC_* is inlined into the client bundle by Next, + # so it is public by construction. Marking it sensitive would imply otherwise. + sensitive = false +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..3081761 --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,35 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + # Pinned deliberately. Both of these ship multiple releases per month, and an + # unpinned provider means `terraform init` on a different day can produce a + # different plan for identical code. + # + # Provider choice note: the obvious host here was Fly.io, but its Terraform + # provider is not maintained -- fly-apps/fly is still 0.0.23, last published + # 2023-06-22, and the community fork (andrewbaxter/fly) last shipped 2024-10-28. + # digitalocean/digitalocean is a partner provider with ~13M downloads that was + # updated within the last week, and a Droplet gives a real block device, which + # SQLite and Chroma both require. + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.99" + } + vercel = { + source = "vercel/vercel" + version = "~> 5.10" + } + } +} + +provider "digitalocean" { + # Reads DIGITALOCEAN_TOKEN from the environment. Never put the token in a .tf file. + token = var.digitalocean_token +} + +provider "vercel" { + # Reads VERCEL_API_TOKEN from the environment. + api_token = var.vercel_api_token + team = var.vercel_team_id +}