from functools import lru_cache from typing import Literal from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict ProviderName = Literal[ "mistral", "github_models", "groq", "ollama", "openrouter", "perplexity", "gracekelly", "zen", "grok_cli", "claude_cli", "local_vllm", ] class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="NL_SQL_", extra="ignore", case_sensitive=False, populate_by_name=True, ) log_level: str = "INFO" default_provider: ProviderName = "mistral" frontier_provider: ProviderName = "groq" # GitHub Models needs fine-grained PAT local_provider: ProviderName = "ollama" mistral_gen_model: str = "codestral-latest" mistral_nl_model: str = "mistral-large-latest" mistral_embed_model: str = "mistral-embed" mistral_base_url: str = "https://api.mistral.ai/v1" github_models_model: str = "openai/gpt-4o-mini" github_models_base_url: str = "https://models.github.ai/inference" groq_model: str = "llama-3.3-70b-versatile" groq_base_url: str = "https://api.groq.com/openai/v1" ollama_gen_model: str = "qwen2.5-coder:7b-instruct" ollama_base_url: str = "http://localhost:11434/v1" ollama_timeout_seconds: float = 180.0 # OpenRouter — heterogeneous-CSC slot. Default = deepseek-v4-flash:free # (DeepSeek family, ≠ Mistral — needed so self-consistency votes don't # collapse into one model's blind spots, as happened in config F + CSC # merge-revision saturation on homogeneous codestral). Earlier picks # rejected during 2026-05-20 probe: # - z-ai/glm-4.5-air:free → reasoning model, 2186 reasoning_tokens # consumed the whole budget, content=empty (smoke5 → 0% EA). # - qwen/qwen3-coder:free → Venice provider 429-loop (free quota). # deepseek-v4-flash:free returned valid JSON+SQL on probe (LIMIT/OFFSET # correct for 7th-row case). Other live free models cycle; check # `D:\TXT\Free API Keys.txt` / smoke before switching. # `deepseek/deepseek-v4-flash:free` used to live here and no longer exists — # OpenRouter answers 404 and points at the paid slug, so this provider had # been quietly dead. qwen3-coder is free, code-specialised and long-context; # note the free tier is rate-limited upstream (429) and needs retry patience. openrouter_model: str = "qwen/qwen3-coder:free" openrouter_base_url: str = "https://openrouter.ai/api/v1" # OpenCode Zen — free generator slot (see llm/providers/zen.py). Only the # `*-free` catalogue is reachable: the workspace has no payment method, so # paid ids answer CreditsError. Free ids as of 2026-07-14, all of which # emitted valid SQL on probe: deepseek-v4-flash-free, north-mini-code-free, # nemotron-3-ultra-free, mimo-v2.5-free, hy3-free, big-pickle. zen_model: str = "deepseek-v4-flash-free" zen_base_url: str = "https://opencode.ai/zen/v1" zen_timeout_seconds: float = 180.0 # Grok Build CLI — subscription path (weekly quota, like GraceKelly), no key. # `grok models` advertises grok-4.20-0309-reasoning but the backend rejects it; # the tier serves grok-composer-2.5-fast. See llm/providers/grok_cli.py. grok_cli_path: str = "grok" grok_cli_model: str = "grok-composer-2.5-fast" grok_cli_timeout_seconds: float = 600.0 grok_cli_max_turns: int = 4 """Must be > 1. Grok spends its first turn announcing what it is about to do; `--max-turns 1` returns that preamble instead of the SQL (47.0% / 31% invalid on the first n=200 run). See llm/providers/grok_cli.py.""" grok_cli_effort: str | None = None """`--reasoning-effort`. None leaves the CLI on its own default.""" # Claude Code CLI — subscription path (plan limits, no key). `--system-prompt` # replaces the harness prompt, so the model answers as a completion, not an # agent. See llm/providers/claude_cli.py. claude_cli_path: str = "claude" claude_cli_model: str = "claude-sonnet-5" claude_cli_timeout_seconds: float = 600.0 claude_cli_max_turns: int = 4 """Must be > 1: a denied tool call eats a turn and the CLI then answers `error_max_turns` instead of the SQL.""" claude_cli_effort: str | None = None """`--effort` (low|medium|high|xhigh|max). None leaves the CLI on its own default. The spawned process does NOT inherit the effort of the session that launched it, so this is the only way to run a max-effort ablation.""" # Local vLLM — rented-GPU student-model slot (S2, plan_autotune.md; see # llm/providers/local_vllm.py). No fixed host like Ollama's :11434: the # box only exists for the duration of a GPU rental, so `base_url` must be # set in `.env` each session. `NL_SQL_LOCAL_LLM_API_KEY` defaults to the # literal "dummy" — vLLM's OpenAI-compatible server does not check a key # unless started with `--api-key`, but the openai SDK requires a # non-empty string. local_llm_base_url: str = "http://localhost:8000/v1" local_llm_model: str = "Qwen/Qwen2.5-Coder-7B-Instruct" local_llm_api_key: str = "dummy" local_llm_timeout_seconds: float = 180.0 # Perplexity browser path via local GraceKelly (D:\GraceKelly). Free # because it rides the user's Perplexity Pro subscription via Playwright. # `claude-sonnet-4-6` here is the Perplexity menu label, not the # Anthropic API model id — GraceKelly resolves it to the browser path. perplexity_browser_model: str = "claude-sonnet-4-6" perplexity_base_url: str = "http://127.0.0.1:8011" # GraceKelly orchestrate path — the AgentFlow branch runs NL->SQL on # Claude Sonnet 5 via GraceKelly (AgentFlow ADR 0008), not Mistral. The # portfolio default_provider stays `mistral`; this slot is selected # explicitly via build_provider("gracekelly"). `claude-sonnet-5` resolves # through GraceKelly's live catalog to "Claude Sonnet 5.0". gracekelly_model: str = "claude-sonnet-5" gracekelly_base_url: str = "http://127.0.0.1:8011" gracekelly_timeout_seconds: float = 900.0 """Browser path, so this is a human-scale wait, not an API round-trip. Measured on the NL_SQL generation prompt (schema + few-shot + rules, ~6k tokens) against Perplexity: 150-436s per call. The former 180s default timed out mid-benchmark and surfaced as `pipeline_exception`, which scores as a miss and silently understates the model. 900s leaves headroom.""" mistral_api_key: str = Field(default="", validation_alias="MISTRAL_API_KEY") github_token: str = Field(default="", validation_alias="GITHUB_TOKEN") groq_api_key: str = Field(default="", validation_alias="GROQ_API_KEY") openrouter_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY") zen_api_key: str = Field(default="", validation_alias="OPENCODE_API_KEY") """One or more OpenCode Zen keys, comma-separated. Free tiers meter per key, so extra keys are extra headroom: the provider rotates through them on 429.""" # Optional API auth for the FastAPI surface. When empty, /ask and /databases # are open — but the rate limiter still applies (keyed by client IP), so a # keyless public deploy is not unthrottled. Read here (not os.environ) so a # value in .env is honoured, per the api/main module docstring. api_key: str = Field(default="", validation_alias="NL_SQL_API_KEY") # Optional Postgres target. When `pg_dsn` is set, the registry registers a # Postgres-backed database under `pg_db_id`. Point it at the read-only role # (nl_sql_ro) for defence in depth — the engine also forces read-only # transactions on top (see db/connection.py). Empty = SQLite-only (default). pg_dsn: str = Field(default="", validation_alias="NL_SQL_PG_DSN") pg_db_id: str = "pg_codebase_community" pg_description: str = "StackExchange-derived BIRD codebase_community, loaded into Postgres 16." # diskcache for LLM generate/embed responses (per docs/02_architecture_v2.md §6.5). # Two subdirs ("gen", "embed") are created under this root by `nl_sql.llm.cache`. llm_cache_dir: str = ".cache/llm" llm_cache_size_limit_gb: int = 4 @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings()