Skip to content
Open
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
28 changes: 21 additions & 7 deletions apps/backend/app/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ def _configure_litellm_logging() -> None:
MAX_JSON_EXTRACTION_RECURSION = 10
MAX_JSON_CONTENT_SIZE = 1024 * 1024 # 1MB


# Default token budget for structured JSON completions (e.g. resume parsing).
# Chosen to accommodate large resumes while staying within most providers'
# output limits. Callers should use get_safe_max_tokens() so this is
# automatically clamped to the model's actual capacity.
DEFAULT_JSON_MAX_TOKENS = 8192


class LLMConfig(BaseModel):
"""LLM configuration model."""

Expand All @@ -59,6 +59,7 @@ class LLMConfig(BaseModel):
api_key: str
api_base: str | None = None
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
timeout_seconds: int | None = None


def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
Expand Down Expand Up @@ -397,6 +398,7 @@ def get_llm_config() -> LLMConfig:
api_key=api_key,
api_base=stored.get("api_base", settings.llm_api_base),
reasoning_effort=reasoning_effort,
timeout_seconds=stored.get("timeout_seconds"),
)


Expand Down Expand Up @@ -672,7 +674,7 @@ async def complete(
"model": "primary",
"messages": messages,
"max_tokens": max_tokens,
"timeout": _calculate_timeout("completion", max_tokens, config.provider),
"timeout": _calculate_timeout("completion", max_tokens, config.provider, config.timeout_seconds),
}
if _supports_temperature(model_name, temperature):
kwargs["temperature"] = temperature
Expand Down Expand Up @@ -730,10 +732,9 @@ def _supports_json_mode(model_name: str) -> bool:
logging.debug("Model %s not in LiteLLM registry, skipping JSON mode", model_name)
return False


FALLBACK_MAX_TOKENS = 4096

def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS) -> int:
def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS, fall_back: int = FALLBACK_MAX_TOKENS) -> int:
"""Return a token count safe for the given model, clamped to its output limit.

Queries LiteLLM's model registry for ``max_output_tokens`` and returns
Expand Down Expand Up @@ -768,7 +769,7 @@ def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKEN
except Exception:
pass # Model not in registry, drop down to fallback logic

safe = min(safe_requested, FALLBACK_MAX_TOKENS)
safe = min(safe_requested, fall_back)
logging.debug(
"Model %s not in LiteLLM registry, clamping requested max_tokens %d → %d constraint",
model_name,
Expand All @@ -778,6 +779,7 @@ def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKEN
return safe



def _appears_truncated(data: dict, schema_type: str = "resume") -> bool:
"""LLM-001: Check if JSON data appears to be truncated.

Expand Down Expand Up @@ -898,15 +900,27 @@ def _calculate_timeout(
operation: str,
max_tokens: int = 4096,
provider: str = "openai",
base_timeout_override: int | None = None,
) -> int:
"""LLM-005: Calculate adaptive timeout based on operation and parameters."""
"""LLM-005: Calculate adaptive timeout based on operation and parameters.

When ``base_timeout_override`` is provided, it replaces the default base
timeout for ``completion`` and ``json`` operations. The ``token_factor``
and ``provider_factor`` multipliers are still applied so that the override
scales correctly with token count and provider latency.

``health_check`` always uses its hard-coded 30s default regardless of
overrides — health checks should remain fast.
"""
base_timeouts = {
"health_check": LLM_TIMEOUT_HEALTH_CHECK,
"completion": LLM_TIMEOUT_COMPLETION,
"json": LLM_TIMEOUT_JSON,
}

base = base_timeouts.get(operation, LLM_TIMEOUT_COMPLETION)
if base_timeout_override is not None and operation != "health_check":
base = base_timeout_override

# Scale by token count (relative to 4096 baseline)
token_factor = max(1.0, max_tokens / 4096)
Expand Down Expand Up @@ -1064,7 +1078,7 @@ async def complete_json(
"model": "primary",
"messages": messages,
"max_tokens": max_tokens,
"timeout": _calculate_timeout("json", max_tokens, config.provider),
"timeout": _calculate_timeout("json", max_tokens, config.provider, config.timeout_seconds),
}
# LLM-002: Increase temperature on retry for variation
retry_temp = _get_retry_temperature(model_name, attempt)
Expand Down
70 changes: 23 additions & 47 deletions apps/backend/app/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@
save_api_keys_to_config,
delete_api_key_from_config,
clear_all_api_keys,
load_config_file,
save_config_file,
)
from app.config_cache import invalidate_config_cache
from app.database import db
Expand All @@ -52,13 +50,18 @@ def _get_config_path() -> Path:


def _load_config() -> dict:
"""Load config with decrypted API keys injected (so resolve_api_key works)."""
return load_config_file()
"""Load config from file."""
path = _get_config_path()
if path.exists():
return json.loads(path.read_text())
return {}


def _save_config(config: dict) -> None:
"""Save non-secret config (keys stripped) and invalidate the shared cache."""
save_config_file(config)
"""Save config to file and invalidate the resume router's cache."""
path = _get_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(config, indent=2))
invalidate_config_cache()


Expand Down Expand Up @@ -105,6 +108,7 @@ async def get_llm_config_endpoint() -> LLMConfigResponse:
api_key=_mask_api_key(resolve_api_key(stored, provider)),
api_base=stored.get("api_base", settings.llm_api_base),
reasoning_effort=reasoning_effort or None,
timeout_seconds=stored.get("timeout_seconds"),
)


Expand All @@ -129,23 +133,19 @@ async def update_llm_config(
stored["provider"] = request.provider
if request.model is not None:
stored["model"] = request.model
# NOTE: API keys are NOT written here anymore. They live in the encrypted
# per-provider store (PUT /config/api-keys). Writing the legacy single
# ``api_key`` slot here is what caused providers to overwrite each other and
# shadow the per-provider map in resolve_api_key. request.api_key is ignored
# for persistence (kept in the schema only for response masking/back-compat).
# api_base: distinguish "omitted" (leave unchanged) from "present but
# blank/null" (explicit clear). The frontend sends api_base: null/"" when
# the Base URL field is cleared; treating that as "don't change" left a
# stale override in config.json (issue #760). Normalize blank → None so an
# empty string also never reaches LiteLLM as a bogus endpoint.
if "api_base" in request.model_fields_set:
cleaned = (request.api_base or "").strip()
stored["api_base"] = cleaned or None
if request.api_key is not None:
stored["api_key"] = request.api_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: API key stored in plaintext config.json — security regression

The replaced load_config_file() injected decrypted keys and save_config_file() stripped them before writing (per CLAUDE.md: "keys live ONLY in the encrypted api_keys SQLite table"). The new _load_config() does raw json.loads() and _save_config() does raw json.dumps(), bypassing the encryption layer entirely.

Combined with this line (stored["api_key"] = request.api_key), the API key will be written to config.json in plaintext. This contradicts the documented security architecture and exposes secrets on disk.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if request.api_base is not None:
stored["api_base"] = request.api_base
if request.reasoning_effort is not None:
# Persist empty string on clear so the gpt-5 auto-migration doesn't
# re-fire on next get_llm_config() call.
stored["reasoning_effort"] = request.reasoning_effort
# Allow the frontend to clear the persisted timeout by sending null.
# model_dump(exclude_unset=True) lets us distinguish "not provided"
# from "explicitly set to None" while still validating type/range.
if "timeout_seconds" in request.model_dump(exclude_unset=True):
stored["timeout_seconds"] = request.timeout_seconds

# Build normalized config for response and background health check
resolved_provider = stored.get("provider", settings.llm_provider)
Expand All @@ -171,6 +171,7 @@ async def update_llm_config(
api_key=_mask_api_key(test_config.api_key),
api_base=test_config.api_base,
reasoning_effort=test_config.reasoning_effort,
timeout_seconds=stored.get("timeout_seconds"),
)


Expand Down Expand Up @@ -425,19 +426,8 @@ async def update_feature_prompts(
)


# Supported API key providers (key-store names). ``openai_compatible`` and
# ``ollama`` are included so secured local servers can store a key; they keep
# their env-fallback skip in resolve_api_key.
SUPPORTED_PROVIDERS = [
"openai",
"anthropic",
"google",
"openrouter",
"deepseek",
"groq",
"openai_compatible",
"ollama",
]
# Supported API key providers
SUPPORTED_PROVIDERS = ["openai", "anthropic", "google", "openrouter", "deepseek", "groq"]


def _mask_key_short(key: str | None) -> str | None:
Expand Down Expand Up @@ -525,20 +515,6 @@ async def update_api_keys(request: ApiKeysUpdateRequest) -> ApiKeysUpdateRespons
del stored_keys["groq"]
updated.append("groq")

if request.openai_compatible is not None:
if request.openai_compatible:
stored_keys["openai_compatible"] = request.openai_compatible
elif "openai_compatible" in stored_keys:
del stored_keys["openai_compatible"]
updated.append("openai_compatible")

if request.ollama is not None:
if request.ollama:
stored_keys["ollama"] = request.ollama
elif "ollama" in stored_keys:
del stored_keys["ollama"]
updated.append("ollama")

save_api_keys_to_config(stored_keys)
invalidate_config_cache()

Expand Down Expand Up @@ -621,5 +597,5 @@ async def reset_database_endpoint(request: ResetDatabaseRequest) -> dict:
status_code=400,
detail="Confirmation required. Pass confirm=RESET_ALL_DATA in request body.",
)
await db.reset_database()
db.reset_database()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: db.reset_database() is async def but not awaited — same pattern as the resumes.py db calls.

The method is declared async def reset_database(self) at database.py:749. Without await, the database will never actually be reset — the returned coroutine is silently discarded, and the endpoint returns a success message while no data is deleted.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return {"message": "Database and all data have been reset successfully"}
Loading