0. Series Loop (Follow Along Without Public Source Code)
End-to-End Pipeline: Vue Frontend → api/routes/chat.py → Guide Multi-turn SSE → run_analysis_pipeline (Parsing → Analysis → Matching → Report) → tools/pdf_exporter PDF.
This Article: #17/17 · Configuration Loop · settings
| Stage | User Visible | Code Entry | Corresponding Article |
|---|---|---|---|
| Create Session | Welcome Message | POST /api/sessions | 09 |
| Multi-turn Dialogue | SSE Streaming | chat/stream → run_guide_single_turn | 06, 14 |
| Information Sufficient | Start Analysis | _run_analysis_background | 05, 07 |
| Resume Parsing | Progress 30% | run_resume_parser | 12 |
| Profile/RIASEC | Progress 50% | run_profile_analyzer | 03, 13 |
| Career Matching | Progress 70% | run_career_matcher | 02 |
| Report | Progress 90% | run_reporter | 11 |
| Download PDF | File | GET …/report/pdf | 11, 15 |
| Description | |
|---|---|
| Before Reading | Article 08/15 Environment Variables |
| After Reading | Can write how .env overrides Settings defaults |
| Next Loop | Back to Article 01: Loop Review (go back to Article 01 for review) |
Full series loop index: SERIES-LOOP.md
1. What Problem to Solve
iCan’s configuration scattered across multiple places quickly spirals out of control: LLM keys, MySQL URL, Embedding path, Chroma directory, chat round limit—local .env, Docker environment, CI-injected variables may all coexist simultaneously. Also need to avoid logging the full LLM_API_KEY.
The project consolidates all items into a single Settings class in config.py, with a module-level settings singleton, uniformly read by llm/providers.py, Agent, Docker compose. This article only covers iCan’s implementation, not a generic pydantic-settings tutorial.
2. Implementation Location
| Module | Relationship with Config |
|---|---|
config.py |
_ENV_FILE location, dotenv preload, Settings, get_settings(), settings singleton |
llm/providers.py |
from ican.config import settings, reads LLM_* to create ChatOpenAI |
agents/resume_parser.py |
Indirectly reads LLM_MODEL_LIGHT via get_light_model() |
.env.example |
Repository’s recommended local/DeepSeek example values |
docker-compose.yml |
Container environment variable overrides (default Ollama + MySQL DSN) |
3. _ENV_FILE Lookup: Two-level Path
config.py calculates the absolute path of .env before importing Settings:
1 | |
Explanation (__file__ is in src/ican/config.py):
- Preferred
project root/.env(up two levels toiCan/repository root); - Fallback one more level up (compatible with checkout layouts where
srcis nested one more directory).
If neither path exists, _ENV_FILE still points to the last candidate path; pydantic won’t error when it can’t read the file, fields will use code defaults.
Settings.model_config explicitly binds this path:
1 | |
case_sensitive=True means environment variable names must match field names exactly (LLM_API_KEY, not llm_api_key). extra="ignore" allows extra keys in .env without ValidationError.
4. dotenv Preload: Write to os.environ Before pydantic
Before the Settings class definition, if .env is found, it is manually injected into os.environ using python-dotenv:
1 | |
Design intent:
- Does not overwrite existing environment variables (
k not in os.environ)—Docker/K8s injected values take precedence over.envfile; - Makes
os.environvisible before pydantic parsing, avoiding edge cases where some libraries readenvirononly, notenv_file; except Exception: pass: silently skips ifpython-dotenvis missing or file is corrupted; still allows starting with pure environment variables.
Effective priority (consistent with comments):
1 | |
5. Settings Fields: Code Defaults vs .env Example
Code defaults (active when no env variable set):
| Field | Default | Usage |
|---|---|---|
LLM_MODEL_CHAT |
gpt-4o |
Guide / Analysis / Matching / Reporter |
LLM_MODEL_LIGHT |
gpt-4o-mini |
ResumeParser etc. |
LLM_BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible root path |
LLM_API_KEY |
"" |
Empty string, must fill locally |
LLM_MAX_TOKENS |
4096 |
Max generation tokens (compose default 8192 overrides) |
DB_URL |
sqlite:///./ican.db |
Development SQLite |
DEBUG |
False |
Production mode |
MAX_CHAT_ROUNDS |
15 |
Guide loop limit |
EMBEDDING_MODEL_PATH |
"" |
Empty means external mount or local configuration required |
CHROMA_PERSIST_DIR |
"" |
Empty means using project relative path logic |
.env.example shows common DeepSeek deployment examples, not code defaults:
1 | |
Saying “defaults to DeepSeek” in documentation would conflict with source code; config.py is the authority, .env / compose are override layers.
Docker docker-compose.yml is a third set of defaults (Ollama qwen3.5:9b, LLM_API_KEY=ollama), suitable for offline demos, not contradictory with .env.example‘s cloud API—both are environment overrides, not changing Settings class defaults.
6. Convenience Properties and llm_config_dict Redaction
Several @property on Settings are used by business side:
is_debug/is_production: based onDEBUG;log_level_value: mapsLOG_LEVELstring tologging.DEBUGinteger etc.;app_info: dictionary of app name, version, debug switch.
LLM-related aggregation in llm_config_dict:
1 | |
Key points:
- Log uses
safe_result, only exposes last four chars; - Return value is still the full
result(contains plaintextapi_key); caller must redact themselves if theyprintlater; - Key length ≤4 shows
***in logs to avoid short key leakage.
llm/providers.py does not go through llm_config_dict; it directly reads settings.LLM_API_KEY etc. to construct ChatOpenAI—single configuration entry point, but log redaction only triggers when accessing llm_config_dict property.
7. Module-level Singleton: get_settings() and settings
1 | |
Executed once at module import, shared process-wide via from ican.config import settings. If FastAPI needs dependency injection, get_settings() can be wrapped, but current codebase generally directly imports settings.
Note: get_settings() creates a new Settings() instance each call; only the module-level settings is a singleton. Business code should import settings, not call get_settings() repeatedly unless for test isolation.
Startup log prints DB_URL plaintext (including password); unlike API Key, database connection string is currently not redacted—production should control via LOG_LEVEL or external log filters.
8. How to Switch Environments
| Scenario | Approach |
|---|---|
| Local development | Copy .env.example → .env, fill in DeepSeek/OpenAI |
| Docker | compose environment block overrides; host .env can be passed via ${VAR} |
| CI-only secrets | No .env, pipeline injects LLM_API_KEY etc. as env variables |
| SQLite → MySQL | Change DB_URL, compose default is already MySQL DSN |
EMBEDDING_MODEL_PATH, CHROMA_PERSIST_DIR in compose default to /app/models/bge-m3, /app/chroma_data, corresponding to volume mounts from article 15. Empty string defaults in code mean “path must be specified externally” (Chroma comment says default may fallback to project root chroma_data).
Type validation done by pydantic: DEBUG=false string becomes False; DEBUG=abc causes ValidationError on startup. Same for LLM_TEMPERATURE, LLM_MAX_TOKENS.
9. Position in Pipeline
1 | |
Changing a model does not require changing Agent code; just modify LLM_BASE_URL + LLM_MODEL_*; consistent with article 8 “OpenAI-compatible interface + environment variable switching”. check_ollama_available in llm/providers.py also reads settings.LLM_BASE_URL and settings.LLM_MODEL_CHAT for health check.
10. Pitfalls and Edge Cases
Pitfall 1: Mistaking .env.example as the runtime default. Source code defaults are gpt-4o + OpenAI URL; DeepSeek is just an example file. When writing documentation or screenshots, distinguish between “class defaults” and “deployment examples”.
Pitfall 2: llm_config_dict redaction only protects the logger. The returned dict still contains plaintext key; serializing it into an API response would leak. Redaction logic should not be copied to external interfaces; separate DTO design is needed.
Pitfall 3: _ENV_FILE path is independent of cwd. Config calculates absolute path based on config.py location; starting uvicorn from /app/src still finds repo root .env (if mounted into container). But if .env is not COPY-ed into image and no compose variables set, only code defaults + compose injected items are used.
Pitfall 4: dotenv preload silence. except Exception: pass swallows dotenv failures; when troubleshooting “variable not taking effect”, check both os.environ, compose, and case_sensitive spelling.
Pitfall 5: Only one of dual model fields configured. .env.example often sets same DeepSeek model for chat/light; agents/resume_parser.py still goes through LLM_MODEL_LIGHT. Changing only LLM_MODEL_CHAT without light leads to inconsistency between two model paths.
11. Summary
- Two-level
_ENV_FILElookup +SettingsConfigDict(env_file=...)bind to a single.envpath. - dotenv preload before import, and does not overwrite existing env vars, suitable for Docker injection.
- Code defaults are OpenAI-based; DeepSeek/Ollama overridden via
.envor compose, not hardcoded inSettingsclass. llm_config_dictlogs redact last four chars, return body still contains plaintext, callers should use with caution.- Use module-level
settingssingleton;get_settings()mainly used for startup log and exception fallback.
Articles 16 (middleware) and 15 (Docker) in this series both pull environment variables from this module as the source.
Appendix: Key Source Code (Line-by-line Comments)
The following code is excerpted from iCan implementation, with Chinese comments above each line, readable without public repository.
Generation command: python3 bin/build-ican-annotated-snippets.py
Settings LLM/DB Fields
1 | |
llm_config_dict Redaction
1 | |
get_settings Singleton
1 | |
Series Navigation
| Article | Topic |
|---|---|
| 1 | System Overview |
| 2 | Five-Agent Collaboration |
| 3 | Holland RIASEC |
| 4–7 | State · Routing · Nesting · Fault Tolerance |
| 8–11 | LLM Layer · SSE/WS · DB Migration · PDF |
| 12–14 | JSON Prompt · RIASEC Prompt · Guide Prompt |
| 15–17 | Docker · Middleware · Config |