Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions cloudrun/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,12 @@ spec:
value: "text-embedding-3-small"
- name: EMBEDDING_DIMENSIONS
value: "1536"
- name: OPENAI_API_KEY
- name: PROVIDER_CONFIG_JSON
valueFrom:
secretKeyRef:
name: openai-api-key
name: provider-config
key: latest
# --- Search ---
- name: TAVILY_API_KEY
valueFrom:
secretKeyRef:
name: tavily-api-key
key: latest
# --- Graph store (Neo4j Aura) ---
# URI and username are not sensitive; only the password lives in Secret Manager.
- name: NEO4J_URI
Expand Down Expand Up @@ -93,16 +88,28 @@ spec:
- name: READINESS_REQUIRE_NEO4J
value: "true"
# --- Inngest ---
- name: INNGEST_EVENT_KEY
- name: INNGEST_CONFIG_JSON
valueFrom:
secretKeyRef:
name: inngest-event-key
name: inngest-config
key: latest
- name: INNGEST_SIGNING_KEY
# --- Billing (Stripe) ---
- name: BILLING_CONFIG_JSON
valueFrom:
secretKeyRef:
name: inngest-signing-key
name: billing-config
key: latest
# --- Redis cache ---
# REDIS_URL is bundled into PROVIDER_CONFIG_JSON to stay within the
# Secret Manager free active-version allowance.
- name: REDIS_CACHE_TTL_DB_LIST_SECONDS
value: "30"
- name: STRIPE_SUCCESS_URL
value: "https://cortex-ai-prod.vercel.app/billing/success"
- name: STRIPE_CANCEL_URL
value: "https://cortex-ai-prod.vercel.app/billing/cancel"
- name: STRIPE_PORTAL_RETURN_URL
value: "https://cortex-ai-prod.vercel.app"
# --- CORS ---
- name: CORS_ORIGINS
value: '["https://cortex-ai-prod.vercel.app"]'
Expand All @@ -113,9 +120,7 @@ spec:
name: internal-dispatch-secret
key: latest
# --- Observability ---
# ponytail: observability off for the free-tier deploy; re-add
# langsmith-api-key / langfuse-* secrets when tracing matters.
- name: LANGSMITH_TRACING
value: "false"
- name: LANGFUSE_ENABLED
value: "false"
value: "true"
8 changes: 6 additions & 2 deletions docs/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Cortex uses Stripe Hosted Checkout, signed webhooks, and the customer portal for

## Configuration

Required variables:
Required local-development variables:

- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
Expand All @@ -13,6 +13,11 @@ Required variables:
- `STRIPE_CANCEL_URL`
- `STRIPE_PORTAL_RETURN_URL`

In production, Cloud Run uses one `BILLING_CONFIG_JSON` Secret Manager value containing
`stripe_secret_key`, `stripe_webhook_secret`, and `stripe_pro_price_id`; the three redirect
URLs remain ordinary environment variables. This keeps the Stripe credentials within one
active secret version.

Store production secret values in Google Secret Manager. See [Production configuration](env-vars-production.md).

## Webhook setup
Expand Down Expand Up @@ -46,4 +51,3 @@ flowchart LR
```

Webhook events are the authoritative source for subscription state. The backend verifies the Stripe signature before updating `user_subscriptions`; research and chat quota guards then read the synchronized subscription and usage state.

26 changes: 9 additions & 17 deletions docs/env-vars-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va

| Env Var | Secret Manager | Notes |
|---|---|---|
| `OPENAI_API_KEY` | yes | Required when `LLM_PROVIDER=openai` |
| `PROVIDER_CONFIG_JSON` | yes | Secret JSON with provider, Redis, Langfuse, and LangSmith settings: `openai_api_key`, `tavily_api_key`, `redis_url`, `langfuse_public_key`, `langfuse_secret_key`, `langfuse_base_url`, `langsmith_api_key`, `langsmith_project`, `langsmith_endpoint`, `langsmith_redaction_mode`, `langsmith_sampling_rate`, and `langsmith_tracing`. Required when `LLM_PROVIDER=openai`. |
| `OPENROUTER_API_KEY` | yes | Required when `LLM_PROVIDER=openrouter` |
| `LLM_PROVIDER` | no | `openai` or `openrouter` |
| `OPENAI_MODEL` | no | e.g. `gpt-4o-mini` |
| `ROUTER_TIMEOUT_SECONDS` | no | Maximum duration of each router LLM request; production uses `10.0` |
| `EMBEDDING_PROVIDER` | no | `openai` |
| `EMBEDDING_MODEL` | no | `text-embedding-3-small` |
| `EMBEDDING_DIMENSIONS` | no | `1536` |
| `TAVILY_API_KEY` | yes | |
| `ALPHA_VANTAGE_API_KEY` | yes | Required when `ASSET_PRICE_PROVIDER=alphavantage_mcp` |
| `ASSET_PRICE_PROVIDER` | no | `alphavantage_mcp` |
| `ALPHA_VANTAGE_MCP_URL` | no | Optional full remote MCP URL override |
Expand All @@ -32,12 +31,9 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
| `READINESS_REQUIRE_SUPABASE` | no | Set to `true` in production; `/ready` returns `503` if Supabase is missing or unavailable |
| `READINESS_REQUIRE_NEO4J` | no | Set to `true` in production; `/ready` returns `503` if Neo4j is missing or unavailable |
| `READINESS_TIMEOUT_SECONDS` | no | Per-dependency probe timeout; defaults to `2.0` seconds |
| `INNGEST_EVENT_KEY` | yes | From Inngest dashboard → Keys |
| `INNGEST_SIGNING_KEY` | yes | From Inngest dashboard → Keys. **Must not be empty in prod.** |
| `REDIS_URL` | yes | Upstash `rediss://` URL |
| `STRIPE_SECRET_KEY` | yes | |
| `STRIPE_WEBHOOK_SECRET` | yes | Update Stripe webhook URL to `https://<cloud-run-url>/api/billing/webhook` after first deploy |
| `STRIPE_PRO_PRICE_ID` | no | |
| `INNGEST_CONFIG_JSON` | yes | Secret JSON with `inngest_event_key` and `inngest_signing_key` from the Inngest dashboard. |
| `REDIS_CACHE_TTL_DB_LIST_SECONDS` | no | Cache TTL for DB-heavy list/read-all responses; production uses `30`. `REDIS_URL` is supplied through `PROVIDER_CONFIG_JSON`. |
| `BILLING_CONFIG_JSON` | yes | Secret JSON with `stripe_secret_key`, `stripe_webhook_secret`, and `stripe_pro_price_id`. This bundles Stripe credentials into one Secret Manager version. |
| `STRIPE_SUCCESS_URL` | no | `https://<frontend>/billing/success` |
| `STRIPE_CANCEL_URL` | no | `https://<frontend>/billing/cancel` |
| `STRIPE_PORTAL_RETURN_URL` | no | `https://<frontend>` |
Expand All @@ -49,9 +45,8 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
| Env Var | Secret Manager | Notes |
|---|---|---|
| `COHERE_API_KEY` | yes | Enables cross-encoder reranking |
| `LANGFUSE_PUBLIC_KEY` | yes | |
| `LANGFUSE_SECRET_KEY` | yes | |
| `LANGFUSE_ENABLED` | no | Set to `true` only when both LangFuse keys are configured; otherwise feedback remains stored locally |
| `LANGFUSE_ENABLED` | no | Production sets this to `true`; Langfuse credentials and base URL are supplied through `PROVIDER_CONFIG_JSON`. |
| `LANGSMITH_*` | no | LangSmith tracing, API key, project, endpoint, redaction mode, and sampling rate are supplied through `PROVIDER_CONFIG_JSON`. |
| `LANGFUSE_ENV` | no | `prod` |
| `LANGFUSE_RELEASE` | no | Git SHA or semver, e.g. `v1.2.3` |
| `SENTRY_DSN` | yes | Error tracking; unset disables it entirely |
Expand Down Expand Up @@ -91,19 +86,16 @@ PROJECT=cortex-496709
REGION=your-region

secrets=(
openai-api-key
tavily-api-key
provider-config
cohere-api-key
neo4j-uri
neo4j-username
neo4j-password
supabase-secret-key
supabase-jwt-secret
inngest-event-key
inngest-signing-key
inngest-config
redis-url
stripe-secret-key
stripe-webhook-secret
billing-config
internal-dispatch-secret
langfuse-public-key
langfuse-secret-key
Expand Down
110 changes: 109 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
"""Application settings loaded from environment variables."""

from pydantic import AliasChoices, Field, field_validator
import json

from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


def _parse_bundled_secret(
raw: str,
config_name: str,
required: tuple[str, ...],
) -> dict[str, str]:
try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"{config_name} must be valid JSON.") from exc
if not isinstance(config, dict):
raise ValueError(f"{config_name} must be a JSON object.")

values: dict[str, str] = {}
for name in required:
value = config.get(name)
if not isinstance(value, str) or not value:
fields = ", ".join(required)
raise ValueError(f"{config_name} must include non-empty {fields} values.")
values[name] = value
return values


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
Expand All @@ -13,6 +37,10 @@ class Settings(BaseSettings):

# LLM
llm_provider: str = Field(default="openai", description="LLM provider: 'ollama' or 'openai'")
provider_config_json: str = Field(
default="",
description="JSON object containing provider, cache, and observability settings for production.",
)
openai_api_key: str = Field(default="", description="OpenAI API key")
openai_model: str = Field(default="gpt-4o-mini", description="OpenAI model name")
openrouter_api_key: str = Field(default="", description="OpenRouter API key")
Expand Down Expand Up @@ -331,6 +359,13 @@ def parse_cors_origins(cls, v: object) -> object:
)

# Billing / Stripe
billing_config_json: str = Field(
default="",
description=(
"JSON object containing Stripe secret_key, webhook_secret, and pro_price_id. "
"Used in production to keep billing credentials in one secret version."
),
)
stripe_secret_key: str = Field(default="", description="Stripe secret key.")
stripe_webhook_secret: str = Field(default="", description="Stripe webhook signing secret.")
stripe_pro_price_id: str = Field(default="", description="Stripe price id for the pro plan.")
Expand All @@ -347,6 +382,79 @@ def parse_cors_origins(cls, v: object) -> object:
description="Return URL from Stripe customer portal.",
)

@model_validator(mode="after")
def apply_provider_config(self) -> "Settings":
"""Load bundled provider/cache credentials while preserving local split-variable support."""
if not self.provider_config_json.strip():
return self
values = _parse_bundled_secret(
self.provider_config_json,
"PROVIDER_CONFIG_JSON",
("openai_api_key", "tavily_api_key"),
)
for name, value in values.items():
setattr(self, name, value)
redis_url = json.loads(self.provider_config_json).get("redis_url")
if redis_url is not None:
if not isinstance(redis_url, str) or not redis_url:
raise ValueError("PROVIDER_CONFIG_JSON redis_url must be a non-empty string.")
self.redis_url = redis_url
langfuse = json.loads(self.provider_config_json)
langfuse_fields = {
"langfuse_public_key": "langfuse_public_key",
"langfuse_secret_key": "langfuse_secret_key", # nosec B105 — schema field name, not a credential
"langfuse_base_url": "langfuse_host",
}
for source, target in langfuse_fields.items():
value = langfuse.get(source)
if value is not None:
if not isinstance(value, str) or not value:
raise ValueError(f"PROVIDER_CONFIG_JSON {source} must be a non-empty string.")
setattr(self, target, value)
langsmith_fields = {
"langsmith_api_key": "langsmith_api_key",
"langsmith_project": "langsmith_project",
"langsmith_endpoint": "langsmith_endpoint",
"langsmith_redaction_mode": "langsmith_redaction_mode",
}
for source, target in langsmith_fields.items():
value = langfuse.get(source)
if value is not None:
if not isinstance(value, str) or not value:
raise ValueError(f"PROVIDER_CONFIG_JSON {source} must be a non-empty string.")
setattr(self, target, value)
langsmith_tracing = langfuse.get("langsmith_tracing")
if langsmith_tracing is not None:
if isinstance(langsmith_tracing, bool):
self.langsmith_tracing = langsmith_tracing
elif isinstance(langsmith_tracing, str) and langsmith_tracing.lower() in {"true", "false"}:
self.langsmith_tracing = langsmith_tracing.lower() == "true"
else:
raise ValueError("PROVIDER_CONFIG_JSON langsmith_tracing must be true or false.")
langsmith_sampling_rate = langfuse.get("langsmith_sampling_rate")
if langsmith_sampling_rate is not None:
try:
self.langsmith_sampling_rate = float(langsmith_sampling_rate)
except (TypeError, ValueError) as exc:
raise ValueError(
"PROVIDER_CONFIG_JSON langsmith_sampling_rate must be a number."
) from exc
return self

@model_validator(mode="after")
def apply_billing_config(self) -> "Settings":
"""Load bundled Stripe credentials while preserving local split-variable support."""
if not self.billing_config_json.strip():
return self
values = _parse_bundled_secret(
self.billing_config_json,
"BILLING_CONFIG_JSON",
("stripe_secret_key", "stripe_webhook_secret", "stripe_pro_price_id"),
)
for name, value in values.items():
setattr(self, name, value)
return self

# Redis cache
redis_url: str = Field(
default="",
Expand Down
22 changes: 22 additions & 0 deletions src/inngest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,34 @@

from __future__ import annotations

import json
import os
from collections.abc import Mapping

import inngest
import inngest.fast_api


def _apply_bundled_inngest_config() -> None:
"""Expose bundled credentials under the names expected by the Inngest SDK."""
raw = os.environ.get("INNGEST_CONFIG_JSON", "").strip()
if not raw:
return
try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("INNGEST_CONFIG_JSON must be valid JSON.") from exc
if not isinstance(config, dict):
raise ValueError("INNGEST_CONFIG_JSON must be a JSON object.")
for name in ("inngest_event_key", "inngest_signing_key"):
value = config.get(name)
if not isinstance(value, str) or not value:
raise ValueError(f"INNGEST_CONFIG_JSON must include a non-empty {name} value.")
os.environ[name.upper()] = value


_apply_bundled_inngest_config()

_is_production = os.environ.get("INNGEST_DEV", "").strip() != "1"

inngest_client = inngest.Inngest(
Expand Down
Loading