codelith 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +1 -0
- backend/agents/__init__.py +6 -0
- backend/agents/assessment_agent.py +273 -0
- backend/agents/coding_agent.py +779 -0
- backend/agents/concept_categories.py +131 -0
- backend/agents/concept_detector.py +1217 -0
- backend/agents/debug_agent.py +166 -0
- backend/agents/teacher_agent.py +179 -0
- backend/cli/__init__.py +1 -0
- backend/cli/config_cmd.py +135 -0
- backend/cli/main.py +606 -0
- backend/daemon/__init__.py +1 -0
- backend/daemon/launcher.py +243 -0
- backend/daemon/server.py +453 -0
- backend/daemon/state.py +110 -0
- backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
- backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
- backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
- backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
- backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
- backend/daemon/static/assets/array-BifhSqXX.js +1 -0
- backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
- backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
- backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
- backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
- backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
- backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
- backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
- backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
- backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
- backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
- backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
- backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
- backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
- backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
- backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
- backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
- backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
- backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
- backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
- backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
- backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
- backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
- backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
- backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
- backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
- backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
- backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
- backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
- backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
- backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
- backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
- backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
- backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
- backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
- backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
- backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
- backend/daemon/static/assets/index-D3vj8REa.js +63 -0
- backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
- backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
- backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
- backend/daemon/static/assets/init-C-OQMol4.js +1 -0
- backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
- backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
- backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
- backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
- backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
- backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
- backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
- backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
- backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
- backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
- backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
- backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
- backend/daemon/static/assets/path-fybaL0A-.js +1 -0
- backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
- backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
- backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
- backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
- backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
- backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
- backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
- backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
- backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
- backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
- backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
- backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
- backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
- backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
- backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
- backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
- backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
- backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
- backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
- backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
- backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
- backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
- backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
- backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
- backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
- backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
- backend/daemon/static/index.html +49 -0
- backend/database/__init__.py +1 -0
- backend/database/concept_slug.py +39 -0
- backend/database/concepts.py +804 -0
- backend/llm/__init__.py +5 -0
- backend/llm/client.py +333 -0
- backend/llm/config.py +254 -0
- backend/llm/key_setup.py +237 -0
- backend/main.py +13 -0
- backend/orchestrator/__init__.py +1 -0
- backend/orchestrator/events.py +52 -0
- backend/orchestrator/graph.py +316 -0
- backend/orchestrator/modes.py +125 -0
- codelith-0.1.0.dist-info/METADATA +301 -0
- codelith-0.1.0.dist-info/RECORD +129 -0
- codelith-0.1.0.dist-info/WHEEL +5 -0
- codelith-0.1.0.dist-info/entry_points.txt +2 -0
- codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelith-0.1.0.dist-info/top_level.txt +1 -0
backend/llm/__init__.py
ADDED
backend/llm/client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""LLM chat clients for the CodeLith daemon.
|
|
2
|
+
|
|
3
|
+
Two independent providers are used:
|
|
4
|
+
|
|
5
|
+
- **Groq** — the teaching-side models (teacher agent, assessment grading,
|
|
6
|
+
concept detection, dashboard questions). Key: ``GROQ_API_KEY``.
|
|
7
|
+
- **OpenRouter** — the workhorse coding models (coding agent, debug
|
|
8
|
+
agent). Key: ``OPENROUTER_API_KEY``. The model is chosen with
|
|
9
|
+
``CODELITH_AGENT_MODEL`` (default: ``qwen/qwen3-coder-next``, a cheap
|
|
10
|
+
code-specialised model with a 262k context and native tool calling).
|
|
11
|
+
|
|
12
|
+
Both providers are OpenAI-compatible, so a single ``openai`` SDK client
|
|
13
|
+
is used with a different ``base_url`` per provider.
|
|
14
|
+
|
|
15
|
+
API keys are resolved from, in order:
|
|
16
|
+
|
|
17
|
+
1. the environment variable (``GROQ_API_KEY`` / ``OPENROUTER_API_KEY``),
|
|
18
|
+
2. a ``.env`` file in the repository root,
|
|
19
|
+
3. a ``.env`` file in the daemon state directory (``~/.codelith/``).
|
|
20
|
+
|
|
21
|
+
Files are re-read on every request, so adding a key to a ``.env`` file
|
|
22
|
+
takes effect without restarting the daemon. Usage::
|
|
23
|
+
|
|
24
|
+
from backend.llm.client import generate_reply
|
|
25
|
+
|
|
26
|
+
reply = generate_reply("What is a closure?")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Optional
|
|
35
|
+
|
|
36
|
+
from openai import OpenAI
|
|
37
|
+
|
|
38
|
+
from backend.llm.config import get_model
|
|
39
|
+
|
|
40
|
+
GROQ_API_KEY_ENV = "GROQ_API_KEY"
|
|
41
|
+
# Kept for backwards compatibility with existing imports; teaching-side
|
|
42
|
+
# model selection now goes through backend.llm.config.get_model(role).
|
|
43
|
+
DEFAULT_MODEL = "openai/gpt-oss-120b"
|
|
44
|
+
MAX_COMPLETION_TOKENS = 4096
|
|
45
|
+
GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
|
46
|
+
|
|
47
|
+
OPENROUTER_API_KEY_ENV = "OPENROUTER_API_KEY"
|
|
48
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
49
|
+
# Coding/debug agent workhorse model. Qwen3-Coder-Next: code-specialised,
|
|
50
|
+
# native tool calling, 262k context, generous output budget. Override with
|
|
51
|
+
# the CODELITH_AGENT_MODEL env var, e.g. "anthropic/claude-sonnet-4.5" for
|
|
52
|
+
# the strongest agentic coder (paid) or any OpenRouter "...:free" model.
|
|
53
|
+
DEFAULT_AGENT_MODEL = "qwen/qwen3-coder-next"
|
|
54
|
+
AGENT_MODEL_ENV = "CODELITH_AGENT_MODEL"
|
|
55
|
+
# Per-round completion budget for the coding/debug agents. OpenRouter
|
|
56
|
+
# models (unlike Groq) expose this via the ``max_tokens`` parameter.
|
|
57
|
+
AGENT_MAX_TOKENS = 8192
|
|
58
|
+
|
|
59
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
60
|
+
ENV_FILES = (
|
|
61
|
+
REPO_ROOT / ".env",
|
|
62
|
+
Path.home() / ".codelith" / ".env",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# OS credential store (Windows Credential Manager, macOS Keychain,
|
|
66
|
+
# Secret Service). Keys land here when saved through `codelith setup`;
|
|
67
|
+
# env vars and .env files keep working unchanged for users who prefer
|
|
68
|
+
# them. keyring is an optional dependency: absence or an unlocked/
|
|
69
|
+
# missing backend degrades gracefully to the .env path below.
|
|
70
|
+
KEYRING_SERVICE = "codelith"
|
|
71
|
+
KEYRING_ACCOUNTS = {
|
|
72
|
+
GROQ_API_KEY_ENV: "groq",
|
|
73
|
+
OPENROUTER_API_KEY_ENV: "openrouter",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _keyring_get(env_name: str) -> Optional[str]:
|
|
78
|
+
"""Return a key from the OS credential store, or None.
|
|
79
|
+
|
|
80
|
+
Never raises: any keyring failure (module missing, no backend,
|
|
81
|
+
locked keychain) just means "not stored here" and resolution falls
|
|
82
|
+
through to the env-var/.env layers.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
import keyring
|
|
86
|
+
except ImportError:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
secret = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNTS[env_name])
|
|
90
|
+
except Exception: # noqa: BLE001 - keyring backends raise many shapes
|
|
91
|
+
return None
|
|
92
|
+
return secret.strip() if secret and secret.strip() else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _keyring_set(env_name: str, api_key: str) -> bool:
|
|
96
|
+
"""Store a key in the OS credential store. Returns True on success."""
|
|
97
|
+
try:
|
|
98
|
+
import keyring
|
|
99
|
+
except ImportError:
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNTS[env_name], api_key.strip())
|
|
103
|
+
return True
|
|
104
|
+
except Exception: # noqa: BLE001 - no backend / locked store / ACL error
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def store_api_key(provider: str, api_key: str) -> bool:
|
|
109
|
+
"""Securely persist *provider*'s API key (``"groq"``/``"openrouter"``).
|
|
110
|
+
|
|
111
|
+
Public entry point for the first-run setup flow. Returns False when
|
|
112
|
+
no usable keyring backend exists — the caller should then fall back
|
|
113
|
+
to advising a .env file instead of storing the key in plaintext.
|
|
114
|
+
"""
|
|
115
|
+
env_name = {v: k for k, v in KEYRING_ACCOUNTS.items()}.get(provider)
|
|
116
|
+
if not env_name:
|
|
117
|
+
raise ValueError(f"unknown provider: {provider!r} (known: {', '.join(sorted(KEYRING_ACCOUNTS.values()))})")
|
|
118
|
+
if not api_key.strip():
|
|
119
|
+
raise ValueError("API key must not be empty")
|
|
120
|
+
return _keyring_set(env_name, api_key)
|
|
121
|
+
|
|
122
|
+
SYSTEM_PROMPT = (
|
|
123
|
+
"You are CodeLith, an AI mentor that blends coding assistance with "
|
|
124
|
+
"adaptive teaching. The user is learning to code. Teach at their level: "
|
|
125
|
+
"explain concepts clearly, use concrete examples, and guide them toward "
|
|
126
|
+
"solutions instead of just giving the answer. Keep answers focused and "
|
|
127
|
+
"conversational, and ask a question now and then to check understanding."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
GRADING_SYSTEM_PROMPT = (
|
|
131
|
+
"You are CodeLith, an AI mentor grading a learner's answer to a concept "
|
|
132
|
+
"question. Judge whether the answer shows real understanding of the "
|
|
133
|
+
"concept. Be fair: accept correct answers even if they are worded "
|
|
134
|
+
"differently from a textbook, but reject answers that are wrong or "
|
|
135
|
+
"miss the point. Respond ONLY with a JSON object of the form "
|
|
136
|
+
'{"correct": true or false, "feedback": "..."}. Keep feedback to '
|
|
137
|
+
"1-2 sentences: if the answer is wrong, name the key idea the learner "
|
|
138
|
+
"missed without giving the full answer away."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_dotenv(path: Path) -> None:
|
|
143
|
+
"""Load ``KEY=VALUE`` pairs from ``path`` without overriding existing vars."""
|
|
144
|
+
try:
|
|
145
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
146
|
+
except OSError:
|
|
147
|
+
return
|
|
148
|
+
for line in lines:
|
|
149
|
+
stripped = line.strip()
|
|
150
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
151
|
+
continue
|
|
152
|
+
key, _, value = stripped.partition("=")
|
|
153
|
+
key = key.strip()
|
|
154
|
+
value = value.strip().strip('"').strip("'")
|
|
155
|
+
if key and key not in os.environ:
|
|
156
|
+
os.environ[key] = value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_api_key() -> Optional[str]:
|
|
160
|
+
"""Return the Groq API key, or None if it is not configured anywhere.
|
|
161
|
+
|
|
162
|
+
Resolution order: env var → OS keyring → .env files. Existing
|
|
163
|
+
setups (env var or .env) keep working unchanged; the keyring layer
|
|
164
|
+
is only consulted when those are empty.
|
|
165
|
+
"""
|
|
166
|
+
if os.environ.get(GROQ_API_KEY_ENV):
|
|
167
|
+
return os.environ[GROQ_API_KEY_ENV].strip()
|
|
168
|
+
stored = _keyring_get(GROQ_API_KEY_ENV)
|
|
169
|
+
if stored:
|
|
170
|
+
return stored
|
|
171
|
+
for path in ENV_FILES:
|
|
172
|
+
_load_dotenv(path)
|
|
173
|
+
key = os.environ.get(GROQ_API_KEY_ENV)
|
|
174
|
+
if key:
|
|
175
|
+
return key.strip()
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_agent_api_key() -> Optional[str]:
|
|
180
|
+
"""Return the OpenRouter API key, or None if it is not configured anywhere.
|
|
181
|
+
|
|
182
|
+
Same resolution order as :func:`resolve_api_key`.
|
|
183
|
+
"""
|
|
184
|
+
if os.environ.get(OPENROUTER_API_KEY_ENV):
|
|
185
|
+
return os.environ[OPENROUTER_API_KEY_ENV].strip()
|
|
186
|
+
stored = _keyring_get(OPENROUTER_API_KEY_ENV)
|
|
187
|
+
if stored:
|
|
188
|
+
return stored
|
|
189
|
+
for path in ENV_FILES:
|
|
190
|
+
_load_dotenv(path)
|
|
191
|
+
key = os.environ.get(OPENROUTER_API_KEY_ENV)
|
|
192
|
+
if key:
|
|
193
|
+
return key.strip()
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def resolve_agent_model() -> str:
|
|
198
|
+
"""Return the coding-agent model slug (CODELITH_AGENT_MODEL or default)."""
|
|
199
|
+
return (os.environ.get(AGENT_MODEL_ENV) or DEFAULT_AGENT_MODEL).strip()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_client() -> OpenAI:
|
|
203
|
+
"""Return an OpenAI-compatible client pointed at Groq."""
|
|
204
|
+
api_key = resolve_api_key()
|
|
205
|
+
if not api_key:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"No Groq API key found. Set GROQ_API_KEY "
|
|
208
|
+
"environment variable, or add it to a .env file."
|
|
209
|
+
)
|
|
210
|
+
return OpenAI(
|
|
211
|
+
base_url=GROQ_BASE_URL,
|
|
212
|
+
api_key=api_key,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_agent_client() -> OpenAI:
|
|
217
|
+
"""Return an OpenAI-compatible client pointed at OpenRouter."""
|
|
218
|
+
api_key = resolve_agent_api_key()
|
|
219
|
+
if not api_key:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"No OpenRouter API key found. Set OPENROUTER_API_KEY "
|
|
222
|
+
"environment variable, or add it to a .env file."
|
|
223
|
+
)
|
|
224
|
+
return OpenAI(
|
|
225
|
+
base_url=OPENROUTER_BASE_URL,
|
|
226
|
+
api_key=api_key,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def generate_reply(
|
|
231
|
+
user_message: str,
|
|
232
|
+
model: Optional[str] = None,
|
|
233
|
+
history: Optional[list[dict]] = None,
|
|
234
|
+
) -> str:
|
|
235
|
+
"""Ask Groq for a reply to ``user_message`` using the CodeLith persona.
|
|
236
|
+
|
|
237
|
+
``model`` defaults to the resolved *teaching* role model (env var >
|
|
238
|
+
``config.toml`` > built-in default) when not given explicitly.
|
|
239
|
+
|
|
240
|
+
``history`` is an optional list of prior turns (``{"role", "content"}``
|
|
241
|
+
dicts, oldest first) so follow-up questions keep their context.
|
|
242
|
+
|
|
243
|
+
Never raises: a missing API key and API/network failures are converted
|
|
244
|
+
into a readable message so the CLI keeps working without a key.
|
|
245
|
+
"""
|
|
246
|
+
if model is None:
|
|
247
|
+
model = get_model("teaching")
|
|
248
|
+
api_key = resolve_api_key()
|
|
249
|
+
if not api_key:
|
|
250
|
+
return (
|
|
251
|
+
"I need a Groq API key to think. Set the GROQ_API_KEY "
|
|
252
|
+
"environment variable, or add it to a .env file in the project "
|
|
253
|
+
"root (see the README), then try again."
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
client = get_client()
|
|
257
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
258
|
+
for entry in history or []:
|
|
259
|
+
role = entry.get("role")
|
|
260
|
+
content = (entry.get("content") or "").strip()
|
|
261
|
+
if role in ("user", "assistant") and content:
|
|
262
|
+
messages.append({"role": role, "content": content})
|
|
263
|
+
messages.append({"role": "user", "content": user_message})
|
|
264
|
+
completion = client.chat.completions.create(
|
|
265
|
+
model=model,
|
|
266
|
+
messages=messages,
|
|
267
|
+
max_completion_tokens=MAX_COMPLETION_TOKENS,
|
|
268
|
+
)
|
|
269
|
+
except Exception as exc: # noqa: BLE001 - surface any API/network failure
|
|
270
|
+
return f"(I couldn't reach Groq: {exc})"
|
|
271
|
+
return completion.choices[0].message.content or ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def grade_answer(
|
|
275
|
+
question: str,
|
|
276
|
+
answer: str,
|
|
277
|
+
concept_name: str,
|
|
278
|
+
concept_category: str = "",
|
|
279
|
+
model: Optional[str] = None,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Grade a learner's answer to a concept question via the LLM.
|
|
282
|
+
|
|
283
|
+
``model`` defaults to the resolved *grading* role model (env var >
|
|
284
|
+
``config.toml`` > built-in default) when not given explicitly.
|
|
285
|
+
|
|
286
|
+
Returns ``{"correct": bool, "feedback": str}``. Never raises: any
|
|
287
|
+
failure (missing key, API error, unparseable output) is converted into
|
|
288
|
+
a conservative result — the answer is graded as not-correct with a
|
|
289
|
+
readable explanation, so a broken grader can never inflate progress.
|
|
290
|
+
"""
|
|
291
|
+
if model is None:
|
|
292
|
+
model = get_model("grading")
|
|
293
|
+
api_key = resolve_api_key()
|
|
294
|
+
if not api_key:
|
|
295
|
+
return {
|
|
296
|
+
"correct": False,
|
|
297
|
+
"feedback": (
|
|
298
|
+
"(Grading needs a Groq API key — set GROQ_API_KEY or add it "
|
|
299
|
+
"to a .env file, then submit again.)"
|
|
300
|
+
),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
category_note = f" (category: {concept_category})" if concept_category else ""
|
|
304
|
+
user_prompt = (
|
|
305
|
+
f"Concept: {concept_name}{category_note}\n"
|
|
306
|
+
f"Question: {question}\n\n"
|
|
307
|
+
f"Learner's answer: {answer}\n\n"
|
|
308
|
+
"Grade the answer. Respond ONLY with the JSON object."
|
|
309
|
+
)
|
|
310
|
+
try:
|
|
311
|
+
client = get_client()
|
|
312
|
+
completion = client.chat.completions.create(
|
|
313
|
+
model=model,
|
|
314
|
+
messages=[
|
|
315
|
+
{"role": "system", "content": GRADING_SYSTEM_PROMPT},
|
|
316
|
+
{"role": "user", "content": user_prompt},
|
|
317
|
+
],
|
|
318
|
+
max_completion_tokens=512,
|
|
319
|
+
)
|
|
320
|
+
raw = (completion.choices[0].message.content or "").strip()
|
|
321
|
+
# Tolerate code fences or prose around the JSON object.
|
|
322
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
323
|
+
if start == -1 or end <= start:
|
|
324
|
+
raise ValueError(f"no JSON object in grader output: {raw[:200]}")
|
|
325
|
+
parsed = json.loads(raw[start : end + 1])
|
|
326
|
+
correct = bool(parsed.get("correct", False))
|
|
327
|
+
feedback = str(parsed.get("feedback", "")).strip()
|
|
328
|
+
return {"correct": correct, "feedback": feedback or ("Correct!" if correct else "Not quite — try again.")}
|
|
329
|
+
except Exception as exc: # noqa: BLE001 - conservative failure
|
|
330
|
+
return {
|
|
331
|
+
"correct": False,
|
|
332
|
+
"feedback": f"(Grading failed: {exc}. Your answer was not recorded as correct — please submit again.)",
|
|
333
|
+
}
|
backend/llm/config.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Model-role resolution for CodeLith.
|
|
2
|
+
|
|
3
|
+
CodeLith's AI work is split into logical **roles** (coding, debugging,
|
|
4
|
+
teaching, ...). Each role maps to an LLM model slug; every role
|
|
5
|
+
resolves independently through the same three-layer chain:
|
|
6
|
+
|
|
7
|
+
1. the role's environment variable (``CODELITH_MODEL_CODING`` ...),
|
|
8
|
+
2. ``~/.codelith/config.toml`` under ``[models]``,
|
|
9
|
+
3. the built-in default for that role.
|
|
10
|
+
|
|
11
|
+
The first layer that is set wins, so a shell env var can override the
|
|
12
|
+
config file for one command (CI, scripts), while the config file is the
|
|
13
|
+
durable per-machine override.
|
|
14
|
+
|
|
15
|
+
**No file is ever created automatically.** A user who never customizes
|
|
16
|
+
models sees zero config behavior: all roles resolve to the built-in
|
|
17
|
+
defaults and nothing appears in ``~/.codelith/``. ``config.toml`` comes
|
|
18
|
+
into existence only when the user runs ``codelith config set`` (or
|
|
19
|
+
hand-writes the file).
|
|
20
|
+
|
|
21
|
+
Roles are logical, not physical: nothing stops two roles from using the
|
|
22
|
+
same model, and multiple roles currently share the built-in defaults.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from backend.llm.config import get_model
|
|
27
|
+
|
|
28
|
+
model = get_model("grading")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
if sys.version_info >= (3, 11):
|
|
38
|
+
import tomllib
|
|
39
|
+
else: # pragma: no cover - depends on the interpreter running the tests
|
|
40
|
+
try:
|
|
41
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
42
|
+
except ImportError: # pragma: no cover - degraded mode, see below
|
|
43
|
+
tomllib = None # type: ignore[assignment]
|
|
44
|
+
|
|
45
|
+
from backend.daemon.state import state_dir
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Role registry
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
#: Every logical model role, with its built-in default and env-var name.
|
|
52
|
+
#: Defaults are the models CodeLith ships with today; a role with no
|
|
53
|
+
#: override resolves to exactly what it used before this module existed.
|
|
54
|
+
MODEL_ROLES: dict[str, dict[str, str]] = {
|
|
55
|
+
"coding": {
|
|
56
|
+
"default": "qwen/qwen3-coder-next",
|
|
57
|
+
"env": "CODELITH_MODEL_CODING",
|
|
58
|
+
},
|
|
59
|
+
"debugging": {
|
|
60
|
+
"default": "qwen/qwen3-coder-next",
|
|
61
|
+
"env": "CODELITH_MODEL_DEBUGGING",
|
|
62
|
+
},
|
|
63
|
+
"teaching": {
|
|
64
|
+
"default": "openai/gpt-oss-120b",
|
|
65
|
+
"env": "CODELITH_MODEL_TEACHING",
|
|
66
|
+
},
|
|
67
|
+
"assessment": {
|
|
68
|
+
"default": "openai/gpt-oss-120b",
|
|
69
|
+
"env": "CODELITH_MODEL_ASSESSMENT",
|
|
70
|
+
},
|
|
71
|
+
"grading": {
|
|
72
|
+
"default": "openai/gpt-oss-120b",
|
|
73
|
+
"env": "CODELITH_MODEL_GRADING",
|
|
74
|
+
},
|
|
75
|
+
"detection": {
|
|
76
|
+
"default": "openai/gpt-oss-120b",
|
|
77
|
+
"env": "CODELITH_MODEL_DETECTION",
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
82
|
+
|
|
83
|
+
# mtime cache: the TOML file is parsed at most once per modification.
|
|
84
|
+
# Keeps the "edit the file, no daemon restart" behavior of the .env
|
|
85
|
+
# loader while avoiding a disk hit + reparse on every LLM call.
|
|
86
|
+
_cache: dict[str, object] = {"mtime": None, "models": {}}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def config_path() -> Path:
|
|
90
|
+
"""Return the config file path (``~/.codelith/config.toml``)."""
|
|
91
|
+
return state_dir() / CONFIG_FILE_NAME
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_toml(text: str) -> dict:
|
|
95
|
+
"""Parse *text* as TOML, or return {} when no parser is available."""
|
|
96
|
+
if tomllib is None: # pragma: no cover - Python < 3.11 without tomli
|
|
97
|
+
return {}
|
|
98
|
+
return tomllib.loads(text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_models_table(path: Path) -> dict[str, str]:
|
|
102
|
+
"""Return the ``[models]`` table from *path* as ``role -> slug``.
|
|
103
|
+
|
|
104
|
+
Returns {} when the file is missing, unreadable, unparsable, or has
|
|
105
|
+
no ``[models]`` table — a broken config file degrades to built-in
|
|
106
|
+
defaults rather than breaking every LLM call.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
data = _parse_toml(path.read_text(encoding="utf-8"))
|
|
110
|
+
except (OSError, ValueError): # missing file / TOML syntax error
|
|
111
|
+
return {}
|
|
112
|
+
models = data.get("models")
|
|
113
|
+
if not isinstance(models, dict):
|
|
114
|
+
return {}
|
|
115
|
+
return {
|
|
116
|
+
str(role): str(slug).strip()
|
|
117
|
+
for role, slug in models.items()
|
|
118
|
+
if str(slug).strip()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _file_models() -> dict[str, str]:
|
|
123
|
+
"""Return the parsed ``[models]`` table, re-reading only when the file changed."""
|
|
124
|
+
path = config_path()
|
|
125
|
+
try:
|
|
126
|
+
mtime = path.stat().st_mtime_ns
|
|
127
|
+
except OSError:
|
|
128
|
+
# No file (the common case) — reset the cache so a file created
|
|
129
|
+
# later is picked up instead of being shadowed by a stale table.
|
|
130
|
+
_cache["mtime"] = None
|
|
131
|
+
_cache["models"] = {}
|
|
132
|
+
return {}
|
|
133
|
+
if _cache["mtime"] != mtime:
|
|
134
|
+
_cache["models"] = _read_models_table(path)
|
|
135
|
+
_cache["mtime"] = mtime
|
|
136
|
+
models: dict[str, str] = _cache["models"] # type: ignore[assignment]
|
|
137
|
+
return models
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_model(role: str) -> str:
|
|
141
|
+
"""Return the model slug for *role*.
|
|
142
|
+
|
|
143
|
+
Resolution order: role env var → ``config.toml`` ``[models]`` →
|
|
144
|
+
built-in default. Unknown roles raise ``KeyError`` — a typo'd role
|
|
145
|
+
is a programming error, not a runtime condition.
|
|
146
|
+
"""
|
|
147
|
+
entry = MODEL_ROLES.get(role)
|
|
148
|
+
if entry is None:
|
|
149
|
+
raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
|
|
150
|
+
|
|
151
|
+
env_value = (os.environ.get(entry["env"]) or "").strip()
|
|
152
|
+
if env_value:
|
|
153
|
+
return env_value
|
|
154
|
+
|
|
155
|
+
file_value = _file_models().get(role, "").strip()
|
|
156
|
+
if file_value:
|
|
157
|
+
return file_value
|
|
158
|
+
|
|
159
|
+
return entry["default"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Explicit writes — the only way config.toml comes into existence
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_model(role: str, slug: str) -> None:
|
|
168
|
+
"""Persist *role* → *slug* in ``config.toml``, creating the file.
|
|
169
|
+
|
|
170
|
+
Preserves any other TOML content conservatively: when the existing
|
|
171
|
+
file parses, the ``[models]`` table is updated in place and the rest
|
|
172
|
+
of the document is re-emitted unchanged. When it does not parse,
|
|
173
|
+
the write is refused rather than destroying the user's file.
|
|
174
|
+
|
|
175
|
+
Unknown roles are rejected — ``codelith config set`` is the only
|
|
176
|
+
writer, and it validates against :data:`MODEL_ROLES` first.
|
|
177
|
+
"""
|
|
178
|
+
if role not in MODEL_ROLES:
|
|
179
|
+
raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
|
|
180
|
+
slug = slug.strip()
|
|
181
|
+
if not slug:
|
|
182
|
+
raise ValueError("model slug must not be empty")
|
|
183
|
+
|
|
184
|
+
path = config_path()
|
|
185
|
+
existing_text = ""
|
|
186
|
+
try:
|
|
187
|
+
existing_text = path.read_text(encoding="utf-8")
|
|
188
|
+
except OSError:
|
|
189
|
+
pass # new file
|
|
190
|
+
|
|
191
|
+
if existing_text.strip():
|
|
192
|
+
try:
|
|
193
|
+
data = _parse_toml(existing_text)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{path} is not valid TOML ({exc}); fix or remove it before running "
|
|
197
|
+
"`codelith config set`."
|
|
198
|
+
) from exc
|
|
199
|
+
data.setdefault("models", {})
|
|
200
|
+
data["models"][role] = slug
|
|
201
|
+
body = _dump_toml(data)
|
|
202
|
+
else:
|
|
203
|
+
body = _dump_toml({"models": {role: slug}})
|
|
204
|
+
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
path.write_text(body, encoding="utf-8")
|
|
207
|
+
_cache["mtime"] = None # force re-read on next resolve
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def unset_model(role: str) -> bool:
|
|
211
|
+
"""Remove *role* from ``config.toml``. Returns True if it was present.
|
|
212
|
+
|
|
213
|
+
The file itself is kept (other settings may live there); an empty
|
|
214
|
+
``[models]`` table is left behind rather than deleting the file,
|
|
215
|
+
which keeps hand-edited comments and unrelated sections intact.
|
|
216
|
+
"""
|
|
217
|
+
if role not in MODEL_ROLES:
|
|
218
|
+
raise KeyError(f"unknown model role: {role!r} (known: {', '.join(sorted(MODEL_ROLES))})")
|
|
219
|
+
|
|
220
|
+
path = config_path()
|
|
221
|
+
try:
|
|
222
|
+
data = _parse_toml(path.read_text(encoding="utf-8"))
|
|
223
|
+
except (OSError, ValueError):
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
models = data.get("models")
|
|
227
|
+
if not isinstance(models, dict) or role not in models:
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
del models[role]
|
|
231
|
+
path.write_text(_dump_toml(data), encoding="utf-8")
|
|
232
|
+
_cache["mtime"] = None
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _dump_toml(data: dict) -> str:
|
|
237
|
+
"""Serialize *data* to a readable TOML string (tables one key deep)."""
|
|
238
|
+
lines: list[str] = []
|
|
239
|
+
for key, value in data.items():
|
|
240
|
+
if isinstance(value, dict):
|
|
241
|
+
lines.append(f"[{key}]")
|
|
242
|
+
for sub_key, sub_value in value.items():
|
|
243
|
+
lines.append(f"{sub_key} = {_toml_string(str(sub_value))}")
|
|
244
|
+
lines.append("")
|
|
245
|
+
else:
|
|
246
|
+
lines.append(f"{key} = {_toml_string(str(value))}")
|
|
247
|
+
lines.append("")
|
|
248
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _toml_string(value: str) -> str:
|
|
252
|
+
"""Quote *value* as a basic TOML string."""
|
|
253
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
254
|
+
return f'"{escaped}"'
|