Skip to content

refactor(llm): decompose litellm_client.py into mixins + leaves behind a facade (Tier 2.5) - #273

Merged
guangyu-reflexio merged 5 commits into
mainfrom
refactor/decompose-litellm-client
Jul 2, 2026
Merged

refactor(llm): decompose litellm_client.py into mixins + leaves behind a facade (Tier 2.5)#273
guangyu-reflexio merged 5 commits into
mainfrom
refactor/decompose-litellm-client

Conversation

@guangyu-reflexio

@guangyu-reflexio guangyu-reflexio commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Decompose litellm_client.py (reflexio/server/llm/, 2058 lines, core LLM adapter, ~102 importers) into a 312-line facade + 3 concern mixins + 3 leaf modules — behavior-preserving, zero public-surface change. Final Tier-2.5 decomposition; litellm_client stays the stable import point.

Architecture (hybrid, state-driven)

LiteLLMClient is stateful, so self-bound concerns become mixins composed into the client; stateless parts become leaves:

  • Mixins: _litellm_text_generation.py (generation + hard-timeout/subprocess orchestration + _compute_cost_usd), _litellm_embedding.py (embedding + token-budget), _litellm_structured_output.py.
  • Leaves: _litellm_types.py (config/response/exceptions), _litellm_json_extraction.py (6 pure fns), _litellm_subprocess.py (worker + the 6 _Completion*Snapshot dataclasses).
  • Per-mixin TYPE_CHECKING stubs type the cross-mixin edges (_provider_response_format/_maybe_parse_structured_output); no shared Protocol, no cycle.

Behavior-preservation safeguards (this is core LLM infra)

  • Identity-preserving re-exports: the facade re-exports every symbol by import binding (never redefines). A surface-completeness guard asserts all 5 public + 8 test-imported internals resolve, __all__ equality, per-module cold-import, object-identity (_TRUNCATION_WARNED_MODELS + each snapshot class is its defining module's), and hasattr(litellm_client, "litellm").
  • LLM-mock interception preserved: moved code keeps import litellm + litellm.completion(...) module-attr calls; the facade retains import litellm + the suppress_debug_info/callbacks init (the ~40 patch("...litellm_client.litellm.*") sites depend on the shared module object). Multiprocessing start-method unchanged (fork); snapshot pickling survives via identity.
  • Patch-where-used repoints: test patches of names that moved (embedding providers, resolve_model_name, assert_provider_safe_schema) were repointed to their new module — a re-export would have made them silent no-ops running the real embedder/network. resolve_model_name is repointed in both embedding + text-gen. Embedding tests assert mock call-counts.

Test plan

  • tests/server/llm/ 488 passed / 60 skipped (incl. the identity surface guard + mock-count embedding tests); generation-service sample 375 passed; import reflexio boot OK; ruff + pyright clean; whole-branch /review-loop APPROVE (AST byte-identity confirmed on all moved bodies; all 112 importer sites resolve).

Summary by CodeRabbit

  • New Features

    • Improved text generation with better support for structured responses, tool calls, fallback handling, and image inputs.
    • Added embedding support across more model types, including local and specialized providers.
    • Added stronger handling for long-running requests with a hard timeout path.
  • Bug Fixes

    • Made structured output parsing more reliable for common JSON formatting issues.
    • Improved embedding request consistency, including safer truncation and preserved result order.
    • Better error reporting when requests fail or time out.

…Tier-2.5 Task 0)

Add test_litellm_client_surface.py: a single contract test asserting the full
importer surface of the litellm_client facade stays importable through the
decomposition — the 5 public names (LiteLLMClient, LiteLLMConfig,
LiteLLMClientError, ToolCallingChatResponse, create_litellm_client) plus the 8
test-imported internals (AST-scanned from both repos). Also asserts __all__ ==
public set, hasattr(litellm_client, 'litellm') (SINK-3), the server/llm package
re-export, per-new-module cold-import, and boot smoke on both import paths.

Includes SINK-2 identity asserts (snapshot classes + _TRUNCATION_WARNED_MODELS)
that skip until the leaf/mixin modules exist and enforce object identity once
they do. Adds __all__ to the facade to pin the public surface.
…2.5 Task 1)

Move to three dependency-free sibling leaf modules, re-exported by import
binding (SINK-2, identity-preserving) so the facade surface is unchanged:

- _litellm_types.py: LiteLLMConfig, ToolCallingChatResponse, LiteLLMClientError,
  StructuredOutputParseError, LLMHardTimeoutError.
- _litellm_subprocess.py: the 6 _Completion*Snapshot dataclasses + snapshot
  builders + _litellm_completion_worker (calls litellm.completion via the module
  attr so the fork-inherited global LLM mock still intercepts).
- _litellm_json_extraction.py: the 6 pure JSON helpers (were self-free methods;
  now module-level free functions) + _PYTHON_TO_JSON_REPLACEMENTS.

The facade re-imports every moved name (the 6 snapshots by redundant-alias
re-export) and _maybe_parse_structured_output now calls the JSON free functions.
Surface guard extended: snapshot-class identity asserts now enforce each facade
_Completion*Snapshot IS the _litellm_subprocess class. Zero behavior change,
zero SINK-1 (no patch-namespace move). test_litellm_client_unit updated to call
the two directly-tested JSON helpers as free functions (imported from the facade,
same objects).
…5 Task 2)

Move the three self-bound embedding methods (_resolve_default_embedding_model,
get_embedding, get_embeddings) into EmbeddingMixin and the four stateless
token-budget helpers (+ _TRUNCATION_WARNED_MODELS + budget constants) to
_litellm_embedding.py; compose EmbeddingMixin into LiteLLMClient. The mixin
self-types foreign members (config, _default_embedding_model, _resolve_api_key)
via per-mixin TYPE_CHECKING stubs (Tier-1b idiom), never class-level defaults.

SINK-1 (patch-where-used): repoint every embedding patch namespace from the old
litellm_client namespace to _litellm_embedding — resolve_model_name (the :717
embedding-path site), get_service_embeddings, should_use_embedding_service,
_is_chromadb_importable, NomicEmbedder, LocalEmbedder(.get), and
litellm.get_model_info — across test_litellm_client_unit,
test_embedding_service_provider, and test_local_embedding_provider (both the
string-target and setattr(module,...) forms). The suites' hard mock assertions
(assert_not_called / assert_called_once / cache-count == 1) confirm each
repointed mock fires — a no-op patch hitting the real embedder/137M model would
fail loud.

SINK-2 (identity): the facade re-exports _TRUNCATION_WARNED_MODELS + the three
test-imported budget functions by import binding; litellm.embedding stays a
shared-module attr so the global mock still intercepts. Surface guard's
_TRUNCATION_WARNED_MODELS identity assert now enforces object identity.
Move the response-format selection helpers (_supports_response_schema,
_provider_for_model, _accepts_json_schema_response_format,
_provider_response_format), the post-hoc parse orchestrator
(_maybe_parse_structured_output), and the _JSON_SCHEMA_PROVIDER_ALLOWLIST class
constant into StructuredOutputMixin in _litellm_structured_output.py; compose it
into LiteLLMClient. The mixin self-types config via a per-mixin TYPE_CHECKING
stub. Moves BEFORE text-gen since _provider_response_format and
_maybe_parse_structured_output are the cross-mixin edges text-gen depends on.

SINK-1 (patch-where-used): repoint assert_provider_safe_schema (:1184/:1209) from
the old litellm_client namespace to _litellm_structured_output — the real guard
raises under pytest, so a no-op patch would fail loud. litellm.supports_response_
schema/get_llm_provider stay shared-module attrs.

Bodies verbatim. Facade drops now-unused imports (assert_provider_safe_schema,
strict_response_format_for_model, json, lru_cache) and re-exports the two
test-imported JSON helpers by alias. _make_request / _build_completion_params
reach the moved methods via mixin MRO (self dispatch), unchanged.
…-2.5 Task 4)

Move the ~17 text-generation methods (generate_response, generate_chat_response,
_build_completion_params, the hard-timeout/subprocess-isolation cluster,
observability, _make_request, prompt caching, multimodal image handling,
_is_temperature_restricted_model) plus _compute_cost_usd (retained method,
verbatim billing exception->None semantics — no separate cost module),
_MODEL_TIMEOUT_FLOOR_SECONDS, and the SUPPORTED_IMAGE_FORMATS /
TEMPERATURE_RESTRICTED_MODELS class constants into TextGenerationMixin
(_litellm_text_generation.py). Compose it first in the MRO:
LiteLLMClient(TextGenerationMixin, EmbeddingMixin, StructuredOutputMixin).

Per-mixin TYPE_CHECKING stubs (Tier-1b idiom) self-type the foreign members:
client-core config/logger/_api_key/_api_base/_api_version/_resolve_api_key (on
the facade) and the two cross-mixin edges resolved via MRO
(_provider_response_format + _maybe_parse_structured_output on
StructuredOutputMixin).

LLM-mock: every litellm call stays a shared-module attr (litellm.completion);
the subprocess worker is imported from _litellm_subprocess. SINK-1:
resolve_model_name is imported into this module for the model-role path (no test
patches the text-gen binding; the :717 embedding site was repointed in Task 2).

The facade is now the client-core shell: __init__ (keeps import litellm +
suppress_debug_info + the Braintrust callbacks init — SINK-3), _resolve_api_key,
_resolve_by_prefix (+ _SIMPLE_PROVIDER_PREFIXES), update_config, get_model,
get_config, create_litellm_client, __all__, and the identity re-exports of every
moved public + test-internal name. Bodies verbatim; all LLM suites +
billing/cost + mock-compliance snapshots green.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The monolithic litellm_client.py module is split into separate _litellm_embedding, _litellm_json_extraction, _litellm_structured_output, _litellm_subprocess, _litellm_text_generation, and _litellm_types modules composed as mixins. litellm_client.py becomes a re-export facade preserving identity. Tests are updated to patch the new module paths, and a new facade-surface test is added.

Changes

LiteLLM client module split

Layer / File(s) Summary
Shared types and exceptions
reflexio/server/llm/_litellm_types.py
Adds LiteLLMConfig, ToolCallingChatResponse dataclasses and LiteLLMClientError, StructuredOutputParseError, LLMHardTimeoutError exceptions.
JSON extraction and sanitization helpers
reflexio/server/llm/_litellm_json_extraction.py
Adds pure functions to extract balanced JSON from strings/markdown fences, detect truncation, and sanitize Python-style literals into valid JSON.
Structured output mixin
reflexio/server/llm/_litellm_structured_output.py
Adds StructuredOutputMixin for schema capability detection, provider allowlisting, response-format construction, and parsing content into Pydantic models using the JSON helpers.
Embedding mixin and dispatch
reflexio/server/llm/_litellm_embedding.py
Adds EmbeddingMixin with token-budget resolution, truncation, and routing across embedding service, nomic, local, and litellm providers for get_embedding/get_embeddings.
Subprocess hard-timeout snapshots
reflexio/server/llm/_litellm_subprocess.py
Adds picklable snapshot dataclasses and a worker function that runs litellm.completion in a child process, returning ok/error results via a queue.
Text generation mixin
reflexio/server/llm/_litellm_text_generation.py
Adds TextGenerationMixin with chat entry points, completion-parameter construction, cost/timeout helpers, observability logging, hard-timeout request orchestration, prompt caching, and multimodal content building.
Facade re-export and wiring
reflexio/server/llm/litellm_client.py
Refactors into a facade composing mixins into LiteLLMClient, adds explicit __all__, provider-prefix credential mapping, and invalidates cached embedding model on config update.
Test updates for new module paths
tests/server/llm/test_embedding_service_provider.py, tests/server/llm/test_litellm_client_unit.py, tests/server/llm/test_local_embedding_provider.py, tests/server/llm/test_litellm_client_surface.py
Updates patch targets to new _litellm_* modules and adds a facade-surface test verifying re-exports and identity preservation.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant TextGenerationMixin
  participant CompletionSubprocess
  participant litellm
  participant StructuredOutputMixin

  Caller->>TextGenerationMixin: generate_chat_response(messages)
  TextGenerationMixin->>TextGenerationMixin: _build_completion_params()
  TextGenerationMixin->>TextGenerationMixin: _make_request(params)
  TextGenerationMixin->>CompletionSubprocess: _completion_with_hard_timeout(params)
  CompletionSubprocess->>litellm: completion(**params) in child process
  litellm-->>CompletionSubprocess: response or error
  CompletionSubprocess-->>TextGenerationMixin: result or LLMHardTimeoutError
  TextGenerationMixin->>StructuredOutputMixin: _maybe_parse_structured_output(content)
  StructuredOutputMixin-->>TextGenerationMixin: parsed BaseModel or raw content
  TextGenerationMixin-->>Caller: ToolCallingChatResponse
Loading
sequenceDiagram
  participant Caller
  participant EmbeddingMixin
  participant EmbeddingService
  participant LocalEmbedder
  participant litellm

  Caller->>EmbeddingMixin: get_embedding(text, model)
  EmbeddingMixin->>EmbeddingMixin: embedding_provider_mode()/should_use_embedding_service()
  alt service mode
    EmbeddingMixin->>EmbeddingService: get_service_embeddings(text)
    EmbeddingService-->>EmbeddingMixin: embedding vector
  else local model
    EmbeddingMixin->>LocalEmbedder: get(text)
    LocalEmbedder-->>EmbeddingMixin: embedding vector
  else litellm model
    EmbeddingMixin->>EmbeddingMixin: _truncate_for_embedding(text)
    EmbeddingMixin->>litellm: embedding(model, text)
    litellm-->>EmbeddingMixin: response.data
  end
  EmbeddingMixin-->>Caller: embedding vector
Loading

Possibly related PRs

  • ReflexioAI/reflexio#79: Both PRs implement the same LiteLLM embedding-provider routing path (service vs in-process vs cloud/off) that the new EmbeddingMixin dispatches through.
  • ReflexioAI/reflexio#204: Both PRs implement the same "allowlist providers to force strict json_schema instead of raw Pydantic schema" logic in the structured-output response-format path.
  • ReflexioAI/reflexio#159: Both PRs change _extract_json_from_string's handling/priority of outermost JSON containers versus Markdown fenced code blocks.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: refactoring litellm_client.py into mixins and leaf modules while leaving a facade.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/decompose-litellm-client

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
reflexio/server/llm/_litellm_text_generation.py (1)

87-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make these constants immutable class variables.

Ruff is flagging these as mutable class attributes. Since they are process-wide policy constants, prefer ClassVar[frozenset[str]].

♻️ Proposed refactor
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, ClassVar
@@
-    SUPPORTED_IMAGE_FORMATS: set[str] = set(SUPPORTED_IMAGE_MIME_TYPES.keys())
+    SUPPORTED_IMAGE_FORMATS: ClassVar[frozenset[str]] = frozenset(
+        SUPPORTED_IMAGE_MIME_TYPES
+    )
@@
-    TEMPERATURE_RESTRICTED_MODELS = {
+    TEMPERATURE_RESTRICTED_MODELS: ClassVar[frozenset[str]] = frozenset({
         "gpt-5",
         "gpt-5.4-mini",
         "gpt-5-nano",
         "gpt-5-codex",
         "gemini-3-flash-preview",
         "gemini-3-pro-preview",
-    }
+    })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/_litellm_text_generation.py` around lines 87 - 97, The
constants in the text generation module are being treated as mutable class
attributes, so update the definitions for SUPPORTED_IMAGE_FORMATS and
TEMPERATURE_RESTRICTED_MODELS to be immutable class variables using
ClassVar[frozenset[str]]. Keep their values process-wide policy constants, and
adjust the type annotations in the class where these symbols are defined so Ruff
no longer flags them.

Source: Linters/SAST tools

reflexio/server/llm/litellm_client.py (1)

105-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sort __all__ to satisfy RUF022.

Ruff flags this list as unsorted. Trivial fix, keeps the "clean ruff" claim in the PR description accurate.

🔧 Proposed fix
 __all__ = [
     "LiteLLMClient",
+    "LiteLLMClientError",
     "LiteLLMConfig",
-    "LiteLLMClientError",
     "ToolCallingChatResponse",
     "create_litellm_client",
 ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/litellm_client.py` around lines 105 - 111, The __all__
export list in LiteLLMClient module is unsorted and triggers RUF022. Reorder the
names in the LiteLLMClient/__all__ declaration alphabetically while keeping the
same exports and structure, so Ruff passes without changing behavior.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@reflexio/server/llm/_litellm_embedding.py`:
- Line 96: The remaining Ruff findings in the LiteLLM embedding module come from
a broad fallback catch and several exception messages that are not compliant. In
the fallback around the LiteLLM lookup, either narrow the bare Exception
handling to the specific error type(s) or keep it and add a clear suppression
rationale for BLE001 if the catch is intentional. Also update the
exception-raising paths in the module so the messages satisfy RUF010 by using
proper exception message formatting consistently in the affected lookup and
validation branches.

In `@reflexio/server/llm/_litellm_text_generation.py`:
- Around line 229-236: Avoid mutating caller-owned message dictionaries in the
message assembly logic. In _litellm_text_generation.py, final_messages =
list(messages) only copies the list, so updating final_messages[0]["content"]
still changes the original first message when it is already a system message.
Update the code in the system-message merge path to copy that first message dict
before modifying it, so reused histories in final_messages and the caller’s
messages stay untouched.
- Around line 865-870: Reject plaintext image URLs in the image handling branch
of _litellm_text_generation before adding them to content_blocks. Update the
image.startswith(...) check so only secure https:// URLs are forwarded as
{"type": "image_url"} entries, and treat http:// as invalid or unsupported in
this path. Keep the change localized to the URL handling logic near
content_blocks.append and preserve the existing file-path handling for non-URL
images.
- Around line 471-484: The hard-timeout handling in the LLM worker flow can
falsely time out because the child process is joined before draining
result_queue. Update the logic around the process.join and result_queue.get
sequence in the text generation path so the queue is read/drained before waiting
on the child process, and keep the timeout/kill cleanup in the same flow. Use
the existing hard timeout block and the result_queue handling in
_litellm_text_generation to locate the fix.

---

Nitpick comments:
In `@reflexio/server/llm/_litellm_text_generation.py`:
- Around line 87-97: The constants in the text generation module are being
treated as mutable class attributes, so update the definitions for
SUPPORTED_IMAGE_FORMATS and TEMPERATURE_RESTRICTED_MODELS to be immutable class
variables using ClassVar[frozenset[str]]. Keep their values process-wide policy
constants, and adjust the type annotations in the class where these symbols are
defined so Ruff no longer flags them.

In `@reflexio/server/llm/litellm_client.py`:
- Around line 105-111: The __all__ export list in LiteLLMClient module is
unsorted and triggers RUF022. Reorder the names in the LiteLLMClient/__all__
declaration alphabetically while keeping the same exports and structure, so Ruff
passes without changing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e7ae84c-487a-4521-89d4-79995b75407b

📥 Commits

Reviewing files that changed from the base of the PR and between daeb861 and 0c6ef61.

📒 Files selected for processing (11)
  • reflexio/server/llm/_litellm_embedding.py
  • reflexio/server/llm/_litellm_json_extraction.py
  • reflexio/server/llm/_litellm_structured_output.py
  • reflexio/server/llm/_litellm_subprocess.py
  • reflexio/server/llm/_litellm_text_generation.py
  • reflexio/server/llm/_litellm_types.py
  • reflexio/server/llm/litellm_client.py
  • tests/server/llm/test_embedding_service_provider.py
  • tests/server/llm/test_litellm_client_surface.py
  • tests/server/llm/test_litellm_client_unit.py
  • tests/server/llm/test_local_embedding_provider.py

"""
try:
info = litellm.get_model_info(model)
except Exception:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the remaining Ruff findings in this module.

Line 96 still trips BLE001, and the exception messages here trip RUF010. If the broad LiteLLM lookup fallback is intentional, add an explicit suppression rationale; otherwise narrow the catch.

Proposed fix
-    except Exception:
+    except Exception:  # noqa: BLE001 - litellm may raise provider-specific lookup errors here.
         info = None
@@
-                    f"Nomic embedding generation failed: {str(e)}"
+                    f"Nomic embedding generation failed: {e!s}"
@@
-                    f"Local embedding generation failed: {str(e)}"
+                    f"Local embedding generation failed: {e!s}"
@@
-            raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
+            raise LiteLLMClientError(f"Embedding generation failed: {e!s}") from e
@@
-                    f"Nomic batch embedding generation failed: {str(e)}"
+                    f"Nomic batch embedding generation failed: {e!s}"
@@
-                    f"Local batch embedding generation failed: {str(e)}"
+                    f"Local batch embedding generation failed: {e!s}"
@@
-                f"Batch embedding generation failed: {str(e)}"
+                f"Batch embedding generation failed: {e!s}"

Also applies to: 285-287, 305-307, 333-334, 377-379, 391-393, 421-424

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 96-96: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/_litellm_embedding.py` at line 96, The remaining Ruff
findings in the LiteLLM embedding module come from a broad fallback catch and
several exception messages that are not compliant. In the fallback around the
LiteLLM lookup, either narrow the bare Exception handling to the specific error
type(s) or keep it and add a clear suppression rationale for BLE001 if the catch
is intentional. Also update the exception-raising paths in the module so the
messages satisfy RUF010 by using proper exception message formatting
consistently in the affected lookup and validation branches.

Source: Linters/SAST tools

Comment on lines +229 to +236
final_messages = list(messages)
if system_message:
# Check if first message is already a system message
if final_messages and final_messages[0].get("role") == "system":
# Merge with existing system message
final_messages[0]["content"] = (
f"{system_message}\n\n{final_messages[0]['content']}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid mutating caller-owned message dictionaries.

list(messages) only copies the list. Line 234 mutates the original first message dict when it is already a system message, so reused histories can accumulate duplicated system prompts.

🐛 Proposed fix
-        final_messages = list(messages)
+        final_messages = [dict(message) for message in messages]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final_messages = list(messages)
if system_message:
# Check if first message is already a system message
if final_messages and final_messages[0].get("role") == "system":
# Merge with existing system message
final_messages[0]["content"] = (
f"{system_message}\n\n{final_messages[0]['content']}"
)
final_messages = [dict(message) for message in messages]
if system_message:
# Check if first message is already a system message
if final_messages and final_messages[0].get("role") == "system":
# Merge with existing system message
final_messages[0]["content"] = (
f"{system_message}\n\n{final_messages[0]['content']}"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/_litellm_text_generation.py` around lines 229 - 236,
Avoid mutating caller-owned message dictionaries in the message assembly logic.
In _litellm_text_generation.py, final_messages = list(messages) only copies the
list, so updating final_messages[0]["content"] still changes the original first
message when it is already a system message. Update the code in the
system-message merge path to copy that first message dict before modifying it,
so reused histories in final_messages and the caller’s messages stay untouched.

Comment on lines +471 to +484
process.join(timeout=hard_timeout)
if process.is_alive():
process.terminate()
process.join(timeout=1.0)
if process.is_alive():
process.kill()
process.join(timeout=1.0)
raise LLMHardTimeoutError(
f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
f"(provider timeout={provider_timeout!r})"
)

try:
status, payload = result_queue.get(timeout=1.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Drain the worker queue before joining the child process.

The worker writes the completion to multiprocessing.Queue; joining before reading can block on the queue feeder for large responses and turn a completed request into a false hard timeout.

🐛 Proposed direction
-            process.join(timeout=hard_timeout)
-            if process.is_alive():
+            try:
+                status, payload = result_queue.get(timeout=hard_timeout)
+            except queue.Empty as exc:
+                process.join(timeout=0)
+                if not process.is_alive():
+                    raise LiteLLMClientError(
+                        "LLM request process exited without returning a result "
+                        f"(exitcode={process.exitcode})"
+                    ) from exc
                 process.terminate()
                 process.join(timeout=1.0)
                 if process.is_alive():
                     process.kill()
                     process.join(timeout=1.0)
@@
-            try:
-                status, payload = result_queue.get(timeout=1.0)
-            except queue.Empty as exc:
-                raise LiteLLMClientError(
-                    "LLM request process exited without returning a result "
-                    f"(exitcode={process.exitcode})"
-                ) from exc
+            process.join(timeout=1.0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
process.join(timeout=hard_timeout)
if process.is_alive():
process.terminate()
process.join(timeout=1.0)
if process.is_alive():
process.kill()
process.join(timeout=1.0)
raise LLMHardTimeoutError(
f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
f"(provider timeout={provider_timeout!r})"
)
try:
status, payload = result_queue.get(timeout=1.0)
try:
status, payload = result_queue.get(timeout=hard_timeout)
except queue.Empty as exc:
process.join(timeout=0)
if not process.is_alive():
raise LiteLLMClientError(
"LLM request process exited without returning a result "
f"(exitcode={process.exitcode})"
) from exc
process.terminate()
process.join(timeout=1.0)
if process.is_alive():
process.kill()
process.join(timeout=1.0)
raise LLMHardTimeoutError(
f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
f"(provider timeout={provider_timeout!r})"
)
process.join(timeout=1.0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/_litellm_text_generation.py` around lines 471 - 484, The
hard-timeout handling in the LLM worker flow can falsely time out because the
child process is joined before draining result_queue. Update the logic around
the process.join and result_queue.get sequence in the text generation path so
the queue is read/drained before waiting on the child process, and keep the
timeout/kill cleanup in the same flow. Use the existing hard timeout block and
the result_queue handling in _litellm_text_generation to locate the fix.

Comment on lines +865 to +870
# File path or URL
if image.startswith(("http://", "https://")):
# URL - use directly
content_blocks.append(
{"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject cleartext image URLs before forwarding them.

Line 866 allows http:// image URLs. The server is not fetching them, but the LLM provider will retrieve the image over plaintext, which can expose or tamper with image content.

🛡️ Proposed fix
-                if image.startswith(("http://", "https://")):
+                if image.startswith("http://"):
+                    raise LiteLLMClientError(
+                        "Image URLs must use HTTPS; pass bytes or a local file path instead"
+                    )
+                if image.startswith("https://"):
                     # URL - use directly
                     content_blocks.append(
                         {"type": "image_url", "image_url": {"url": image}}  # type: ignore[reportArgumentType]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# File path or URL
if image.startswith(("http://", "https://")):
# URL - use directly
content_blocks.append(
{"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
)
# File path or URL
if image.startswith("http://"):
raise LiteLLMClientError(
"Image URLs must use HTTPS; pass bytes or a local file path instead"
)
if image.startswith("https://"):
# URL - use directly
content_blocks.append(
{"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
)
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 865-865: Do not make http calls without encryption
Context: "http://"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/llm/_litellm_text_generation.py` around lines 865 - 870,
Reject plaintext image URLs in the image handling branch of
_litellm_text_generation before adding them to content_blocks. Update the
image.startswith(...) check so only secure https:// URLs are forwarded as
{"type": "image_url"} entries, and treat http:// as invalid or unsupported in
this path. Keep the change localized to the URL handling logic near
content_blocks.append and preserve the existing file-path handling for non-URL
images.

Source: Linters/SAST tools

@guangyu-reflexio
guangyu-reflexio merged commit 2f288a5 into main Jul 2, 2026
1 check passed
@guangyu-reflexio
guangyu-reflexio deleted the refactor/decompose-litellm-client branch July 2, 2026 06:48
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.

1 participant