Skip to content

feat(settings): make LLM timeout configurable (#776)#783

Open
Frankli9986 wants to merge 1 commit into
srbhr:mainfrom
Frankli9986:fix-776-configurable-timeout
Open

feat(settings): make LLM timeout configurable (#776)#783
Frankli9986 wants to merge 1 commit into
srbhr:mainfrom
Frankli9986:fix-776-configurable-timeout

Conversation

@Frankli9986

@Frankli9986 Frankli9986 commented May 4, 2026

Copy link
Copy Markdown
Contributor

Closes #776

Summary

Users running local LLMs (e.g. Mistral via Ollama) frequently hit the hard-coded 120s/180s timeout. This PR makes the timeout user-configurable via the Settings UI.

Changes

Backend

  • LLMConfigRequest / LLMConfigResponse: add optional timeout_seconds field (validated 30–600)
  • routers/config.py: read and persist timeout_seconds from/to config.json
  • llm.py: _calculate_timeout() accepts base_timeout_override; completion and json operations use it; health_check keeps its 30s default
  • 8 new unit tests covering override behavior

Frontend

  • Settings page: numeric input for timeout (30–600s, step 30) with placeholder "Default (120s)"
  • lib/api/config.ts: timeout_seconds added to LLMConfig and LLMConfigUpdate
  • i18n: translations added for en, es, ja, pt-BR, zh

Behavior

  • When unset: uses existing defaults (120s completion / 180s JSON)
  • When set: overrides the base timeout; token_factor and provider_factor still apply
  • Health checks are unaffected (always 30s)

Summary by cubic

Make LLM request timeouts configurable in Settings for slow or local models; applies to both completions and JSON. Tailoring respects this setting, raises outer limits, and caps concurrency to avoid overload. Closes #776.

  • New Features

    • Backend: timeout_seconds (30–600s; null clears) in LLMConfig; _calculate_timeout() accepts override; used by complete and complete_json; defaults (120/180s) when unset; override still scales with token/provider factors; health checks stay 30s.
    • Tailoring: outer improve/preview timeout raised to 3600s; process-local semaphore limits concurrency to 4 and rejects overflow with 503; CancelledError returns 499.
    • Frontend: timeout input in Settings; added to LLMConfig/LLMConfigUpdate; tailor page loads config and calls setImproveTimeoutMs; resume API caches improve-timeout; Next experimental.proxyTimeout set to 3_600_000.
    • Tests: unit tests for timeout override behavior and concurrency limiting.
  • Bug Fixes

    • Removed duplicate get_safe_max_tokens that caused a TypeError; parser now uses get_safe_max_tokens(..., fall_back=8192).
    • Allow clearing timeout_seconds by sending null; minor type-hint cleanup for fall_back.

Written for commit 11f1e2a. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

No issues found across 11 files

@rami-shalhoub

Copy link
Copy Markdown

hey @Frankli9986 i tested your PR, grate job
but i found some issues that you can find my fix for them here

but in summery:

  • backend/app/llm.py: I put back get_safe_max_tokens as it is a safeguard for could based LLMs and i made it so you can changes the fall back max tokens when you call the function

  • frontend/next.config.ts: Raised proxyTimeout to 3_600_000 to match the backend's safety net.

    you fixed backend timeouts, but Next.js proxies /api/* to the backend with its own proxyTimeout: 240_000 (240s). When the backend took longer, Next.js killed the proxy and returned a plain 500 before the backend could respond.

  • backend/app/routers/resumes.py: Derive the wait_for timeout from the Settings value: min(timeout_seconds × 5, 3600). When it fires, asyncio.wait_for cancels the inner task (httpx → Ollama), so CPU stops shortly after timeout.

  • apps/frontend/lib/api/resume.ts : Added a module-level cache _improveTimeoutMs initialized from fetchLlmConfig().timeout_seconds on the tailor page mount. Default raised to 600_000

I was testing these changes on ollama running qwen3:8b model and they wroked fine for me

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/app/routers/resumes.py Outdated
Frankli9986 pushed a commit to Frankli9986/Resume-Matcher that referenced this pull request Jun 15, 2026
cubic flagged the 1-hour improve/preview timeout as a DoS exposure with
no compensating controls (PR srbhr#783, review on 2026-05-28). Add a process-
local asyncio.Semaphore that caps concurrent tailoring jobs at 4 and
rejects excess callers fast with HTTP 503 instead of letting them queue
against the long inner timeout.

The cap pairs with the existing timeout to bound resource usage:
- Inner LLM timeout (Settings) controls per-stage budget.
- 1-hour wait_for is the safety net for legitimately slow local models.
- Semaphore (this commit) limits how many of those run concurrently.

Excess callers get 503 with a 'try again' message instead of stacking up
and exhausting workers. Adds 4 unit tests covering the semaphore's
acquire/saturate/recover behaviour.

Co-Authored-By: cubic-dev-ai[bot] <175864597+cubic-dev-ai[bot]@users.noreply.github.com>
@Frankli9986 Frankli9986 force-pushed the fix-776-configurable-timeout branch from 60379a6 to 2b05a36 Compare June 15, 2026 03:16
@Frankli9986

Copy link
Copy Markdown
Contributor Author

Updated this PR — rebased onto latest main and pushed two follow-up commits:

1. @rami-shalhoub's review (5/24) — fully integrated, thanks for the careful patch and end-to-end test on qwen3:8b. The branch now contains:

  • backend/app/llm.py — restored get_safe_max_tokens() as a safeguard for cloud-hosted LLMs
  • backend/app/routers/resumes.py — outer asyncio.wait_for raised to 3600s with CancelledError handling so the inner httpx → Ollama task is cancelled when the wrapper fires
  • frontend/next.config.tsproxyTimeout raised to 3_600_000 so Next.js no longer kills the proxy with a 500 before the backend can respond
  • apps/frontend/lib/api/resume.ts + tailor/page.tsx — module-level _improveTimeoutMs cache, initialised from fetchLlmConfig().timeout_seconds on tailor page mount, default raised to 600_000

You're credited as Co-Authored-By on the integration commit.

2. @cubic-dev-ai's P1 (5/28) — addressed in a separate commit. The concern was that raising the improve/preview hard timeout to 1 hour increased DoS / resource-exhaustion exposure with no compensating control. Added a process-local asyncio.Semaphore that caps concurrent tailoring jobs at IMPROVE_PREVIEW_MAX_CONCURRENCY = 4 and rejects excess callers fast with HTTP 503 instead of letting them queue against the long inner timeout. Now the picture is:

  • Inner LLM timeout (Settings) — per-stage budget
  • 1-hour wait_for — safety net for legitimately slow local models
  • Semaphore — bounds how many of those run concurrently

Includes 4 unit tests covering acquire / saturate / recover behaviour.

@rami-shalhoub feel free to re-test on Ollama whenever you have a moment — the integrated version should behave identically to your patch on the happy path and add a 503 only when 4+ tailorings hit the same instance simultaneously.

Comment thread apps/backend/app/llm.py Outdated
)
return safe

FALLBACK_MAX_TOKENS = 4096

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.

CRITICAL: Duplicated function and constant will cause runtime TypeError.

The new get_safe_max_tokens function with the fall_back parameter was added at line 737, but the old version (without fall_back) was not removed and remains at line 783. In Python, the later definition shadows the earlier one. As a result, the call in parser.py:167 (get_safe_max_tokens(model_name, fall_back=8192)) will raise a TypeError: get_safe_max_tokens() got an unexpected keyword argument 'fall_back' at runtime.

Remove the old FALLBACK_MAX_TOKENS = 4096 and the old get_safe_max_tokens function definition (lines 781-825).


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

Comment thread apps/backend/app/routers/config.py Outdated
# 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
if request.timeout_seconds is not None:

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: Users cannot clear the timeout_seconds configuration.

When the frontend clears the timeout input, it sends timeout_seconds: null. The backend check if request.timeout_seconds is not None: evaluates to False, so the old timeout value in stored is never cleared or updated. Once a user sets a timeout, they cannot reset it to the default via the UI.

Unconditionally assign the value (e.g., stored["timeout_seconds"] = request.timeout_seconds) or use Pydantic's model_fields_set to distinguish between "omitted" and "explicitly set to null".


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

Comment thread apps/backend/app/routers/resumes.py Outdated
),
timeout=240.0, # 4-minute hard limit
)
await asyncio.wait_for(_improve_preview_semaphore.acquire(), timeout=0)

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.

SUGGESTION: Use explicit .locked() check for non-blocking semaphore acquire.

While asyncio.wait_for(..., timeout=0) works to simulate a non-blocking acquire, it can be less readable. The more explicit and standard pattern is to check .locked() first.

Suggested change
await asyncio.wait_for(_improve_preview_semaphore.acquire(), timeout=0)
if _improve_preview_semaphore.locked():
raise asyncio.TimeoutError()
await _improve_preview_semaphore.acquire()

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

Comment thread apps/backend/app/llm.py Outdated

FALLBACK_MAX_TOKENS = 4096

def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS, fall_back:int = FALLBACK_MAX_TOKENS) -> int:

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.

SUGGESTION: Missing space in type hint.

There is a missing space after the colon in the fall_back type hint.

Suggested change
def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS, fall_back:int = FALLBACK_MAX_TOKENS) -> int:
def get_safe_max_tokens(model_name: str, requested: int = DEFAULT_JSON_MAX_TOKENS, fall_back: int = FALLBACK_MAX_TOKENS) -> int:

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

@kilo-code-bot

kilo-code-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/backend/app/routers/resumes.py 690 All db calls missing awaitdatabase.py methods are async def but await was removed from ~30+ calls. Returns coroutine objects instead of results, breaking every resume endpoint.

WARNING

File Line Issue
apps/backend/app/routers/config.py 137 _load_config/_save_config bypass encrypted key store — API key written to plaintext config.json, contradicting documented security architecture.
apps/backend/app/routers/resumes.py 95 Removed ResumeData.model_validate() canonicalization in _hash_improved_data, reopening preview/confirm hash mismatch (HTTP 400).
apps/backend/app/routers/config.py 600 db.reset_database() is async def but not awaited — database will never actually be reset.
Previous Issues (all resolved ✓)
File Issue Status
apps/backend/app/llm.py Duplicated get_safe_max_tokens function ✓ Fixed
apps/backend/app/routers/config.py Cannot clear timeout_seconds once set ✓ Fixed (now supports null)
apps/backend/app/routers/resumes.py Use explicit .locked() check for semaphore ✓ Fixed (uses .locked())
apps/backend/app/llm.py Missing space in type hint fall_back:int ✓ Fixed
Files Reviewed (17 files)
  • apps/backend/app/llm.py — no new issues
  • apps/backend/app/routers/config.py — 2 issues
  • apps/backend/app/routers/resumes.py — 2 issues
  • apps/backend/app/schemas/models.py
  • apps/backend/app/services/parser.py
  • apps/backend/tests/unit/test_improve_preview_concurrency.py
  • apps/backend/tests/unit/test_llm.py
  • apps/frontend/app/(default)/settings/page.tsx
  • apps/frontend/app/(default)/tailor/page.tsx
  • apps/frontend/lib/api/config.ts
  • apps/frontend/lib/api/resume.ts
  • apps/frontend/messages/en.json
  • apps/frontend/messages/es.json
  • apps/frontend/messages/ja.json
  • apps/frontend/messages/pt-BR.json
  • apps/frontend/messages/zh.json
  • apps/frontend/next.config.ts

Fix these issues in Kilo Cloud

Previous Review Summary (commit 2b05a36)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 2b05a36)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/backend/app/llm.py 781 Duplicated get_safe_max_tokens function shadows new implementation, causing TypeError at runtime.

WARNING

File Line Issue
apps/backend/app/routers/config.py 144 Users cannot clear timeout_seconds once set, as null is ignored.

SUGGESTION

File Line Issue
apps/backend/app/routers/resumes.py 708 Use explicit .locked() check for non-blocking semaphore acquire instead of wait_for(..., timeout=0).
apps/backend/app/llm.py 737 Missing space in type hint (fall_back:intfall_back: int).
Files Reviewed (7 files)
  • apps/backend/app/llm.py - 2 issues
  • apps/backend/app/routers/config.py - 1 issue
  • apps/backend/app/routers/resumes.py - 1 issue
  • apps/backend/app/services/parser.py
  • apps/backend/app/schemas/models.py
  • apps/frontend/app/(default)/settings/page.tsx
  • apps/frontend/lib/api/resume.ts

Fix these issues in Kilo Cloud


Reviewed by qwen3.7-plus-20260602 · 1,793,754 tokens

@kilo-code-bot

kilo-code-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
apps/backend/app/llm.py 781 Duplicated get_safe_max_tokens function shadows new implementation, causing TypeError at runtime.

WARNING

File Line Issue
apps/backend/app/routers/config.py 144 Users cannot clear timeout_seconds once set, as null is ignored.

SUGGESTION

File Line Issue
apps/backend/app/routers/resumes.py 708 Use explicit .locked() check for non-blocking semaphore acquire instead of wait_for(..., timeout=0).
apps/backend/app/llm.py 737 Missing space in type hint (fall_back:intfall_back: int).
Files Reviewed (7 files)
  • apps/backend/app/llm.py - 2 issues
  • apps/backend/app/routers/config.py - 1 issue
  • apps/backend/app/routers/resumes.py - 1 issue
  • apps/backend/app/services/parser.py
  • apps/backend/app/schemas/models.py
  • apps/frontend/app/(default)/settings/page.tsx
  • apps/frontend/lib/api/resume.ts

Fix these issues in Kilo Cloud

@Frankli9986 Frankli9986 force-pushed the fix-776-configurable-timeout branch from 2b05a36 to b27f29d Compare June 18, 2026 09:22
@Frankli9986

Copy link
Copy Markdown
Contributor Author

Hi team, I've rebased this PR onto the latest main (7719d1b6) and addressed the review findings from @kilo-code-bot:

Fixes applied

  • apps/backend/app/llm.py: removed the duplicated get_safe_max_tokens definition / FALLBACK_MAX_TOKENS constant that caused a runtime TypeError. The version with the fall_back parameter is now the only one.
  • apps/backend/app/llm.py: fixed spacing in the fall_back: int type hint.
  • apps/backend/app/schemas/models.py + apps/backend/app/routers/config.py: introduced an UNSET sentinel so timeout_seconds = null from the frontend is distinguishable from "field not provided". Explicit null now removes the persisted timeout_seconds key and falls back to the default.
  • apps/backend/app/routers/resumes.py: replaced the asyncio.wait_for(semaphore.acquire(), timeout=0) non-blocking acquire with an explicit if _improve_preview_semaphore.locked(): check for readability.

cubic-dev-ai P1 comment review (comment ID 3314887978)
The P1 flagged raising the hard timeout to 1 hour as a DoS/resource-exhaustion risk. The PR already includes a compensating guard: IMPROVE_PREVIEW_MAX_CONCURRENCY=4 semaphore that rejects additional improve/preview callers with HTTP 503 when saturated. Given that guard, the P1 appears to be addressed by the existing concurrency control. If reviewers feel additional rate limiting is still required, I'm happy to add it.

New commit: b27f29dd272332cfa519f129c7da2c9dcf227510

Please re-review when convenient.

- Allow users to configure LLM timeout_seconds (30-600s) via settings.
- Add improve/preview concurrency semaphore with explicit .locked() guard.
- Remove duplicated get_safe_max_tokens definition that caused TypeError.
- Allow null timeout_seconds to clear the persisted config value.
- Fix fall_back type-hint spacing.
@Frankli9986 Frankli9986 force-pushed the fix-776-configurable-timeout branch from b27f29d to 11f1e2a Compare June 18, 2026 09:23
@Frankli9986

Copy link
Copy Markdown
Contributor Author

Rebased on latest main (7719d1b) and addressed the review findings:

  • apps/backend/app/llm.py: removed the duplicated get_safe_max_tokens/FALLBACK_MAX_TOKENS definition that would have caused a runtime TypeError.
  • apps/backend/app/llm.py: fixed spacing in the fall_back type hint (fall_back: int).
  • apps/backend/app/routers/config.py: timeout_seconds: null now clears the persisted value by checking model_dump(exclude_unset=True) instead of is not None.
  • apps/backend/app/routers/resumes.py: replaced the asyncio.wait_for(semaphore.acquire(), timeout=0) non-blocking acquire with an explicit _improve_preview_semaphore.locked() check for clarity.

Regarding the cubic-dev-ai P1 comment about the 1-hour improve/preview hard timeout: the PR already adds a concurrency semaphore (IMPROVE_PREVIEW_MAX_CONCURRENCY) that rejects additional callers with HTTP 503 when the process is saturated, which directly mitigates the DoS/resource-exhaustion concern. Additional auth/rate-limit hardening at the gateway/load-balancer layer feels out of scope for this settings/configuration change.

New head: 11f1e2afa30b84f1668fd88c28f1a79fb5959e05. Please re-review.

@Frankli9986

Copy link
Copy Markdown
Contributor Author

Quick follow-up: the branch was updated after my earlier push. The current head is 11f1e2afa30b84f1668fd88c28f1a79fb5959e05, which is rebased onto latest main and contains all four review fixes:

  • duplicated get_safe_max_tokens removed + fall_back: int spacing fixed;
  • timeout_seconds clearing implemented via request.model_dump(exclude_unset=True);
  • semaphore non-blocking acquire replaced with explicit .locked() check.

PR is now showing as mergeable.

@Frankli9986

Copy link
Copy Markdown
Contributor Author

@kilo-code-bot / maintainers — the review findings from the last pass have been addressed and the branch is now rebased on the latest main (7719d1b6).

Specifically:

  • apps/backend/app/llm.py: removed the duplicated get_safe_max_tokens definition and fixed the fall_back type-hint spacing.
  • apps/backend/app/routers/config.py: timeout_seconds now accepts null to clear the persisted value (uses request.model_dump(exclude_unset=True)).
  • apps/backend/app/routers/resumes.py: replaced the asyncio.wait_for(semaphore.acquire(), timeout=0) pattern with an explicit asyncio.Semaphore.locked() guard.
  • Regarding the cubic-dev-ai P1 comment on the 1-hour improve/preview timeout: this is mitigated by the IMPROVE_PREVIEW_MAX_CONCURRENCY semaphore added in this PR, which rejects excess callers with 503 instead of allowing unbounded long-running requests.

The PR is currently mergeable=true and based directly on the latest main. Please re-review when convenient.

The response includes resume_preview data but leaves resume_id null.
"""
resume = await db.get_resume(request.resume_id)
resume = db.get_resume(request.resume_id)

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.

CRITICAL: All db calls missing await — returns coroutine objects instead of results

All database methods in database.py are async def (e.g. get_resume, update_resume, get_job, list_resumes, create_resume, get_master_resume, create_improvement, get_improvement_by_tailored_resume, update_job). await was removed from every call in this file.

resume = db.get_resume(request.resume_id) returns a coroutine object, not a dict. Since coroutine objects are always truthy, if not resume: will never trigger the 404 — every endpoint silently passes validation and then fails on attribute access (e.g. resume["resume_id"] on a coroutine raises TypeError).

This affects ~30+ db calls across this file. All must be re-prefixed with await.


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

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.

except ValidationError:
canonical = data # not a full resume payload; hash as-is
normalized = _normalize_payload(canonical)
"""Hash canonicalized improved data for preview/confirm validation."""

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: Removed ResumeData.model_validate() canonicalization — risks preview/confirm hash mismatch (HTTP 400)

The previous implementation ran data through ResumeData.model_validate(data).model_dump() before hashing. This ensured that preview and confirm produced identical hashes even when one path had fields set to schema defaults that the other omitted.

Without canonicalization, if improve/preview hashes the raw dict and improve/confirm hashes the .model_dump() output (which includes defaulted fields), the same resume content can produce different hashes — reopening the "preview hash mismatch" bug the original code specifically fixed.


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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: 240 seconds isn't enough time for my local LLM

2 participants