Skip to content

feat: local model management for Ollama and LM Studio#1037

Merged
Aureliolo merged 10 commits intomainfrom
feat/local-model-management
Apr 3, 2026
Merged

feat: local model management for Ollama and LM Studio#1037
Aureliolo merged 10 commits intomainfrom
feat/local-model-management

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

Local providers (Ollama, LM Studio) have management APIs that SynthOrg didn't use. The setup wizard showed wrong model counts (LiteLLM static DB vs live discovery), and there was no way to download, delete, or configure local models from the dashboard.

Bug fix

  • Model count mismatch: For presets with auth_type=NONE (Ollama, LM Studio, vLLM), skip models_from_litellm() and always use live discovery. The static DB returned stale/wrong models (Ollama got old entries; LM Studio/vLLM got OpenAI cloud models via litellm_provider="openai").

Backend

  • Preset capability flags: supports_model_pull, supports_model_delete, supports_model_config on ProviderPreset (Ollama: all true; LM Studio/vLLM: deferred)
  • LocalModelParams: Per-model launch parameters (num_ctx, num_gpu_layers, num_threads, num_batch, repeat_penalty) stored on ProviderModelConfig
  • preset_name on ProviderConfig for capability flag resolution in API responses
  • OllamaModelManager: Pull via streaming /api/pull (newline-delimited JSON), delete via /api/delete, with LocalModelManager protocol
  • Service layer: pull_model (async generator), delete_model (with auto-refresh), update_model_config (immutable update under lock)
  • API endpoints: POST /{name}/models/pull (SSE streaming), DELETE /{name}/models/{model_id} (204), PUT /{name}/models/{model_id}/config (returns updated model)
  • Error handling: httpx transport errors caught in pull stream, SSE generator catch-all, terminal events on stream disconnect, malformed JSON logging

Dashboard

  • Model pull dialog: Input + ProgressGauge + cancel via AbortController, proper overlay with a11y (role=dialog, aria-modal, Escape handler)
  • Model delete: ConfirmDialog (destructive variant) per model row
  • Model config drawer: Per-model parameter editor (num_ctx, num_gpu_layers, num_threads, num_batch, repeat_penalty) with NaN-safe parsing
  • Refresh button: RefreshCw with spin animation, calls discover-models
  • All controls gated by preset capability flags (supports_model_pull/delete/config)
  • Storybook stories for ModelPullDialog and ModelConfigDrawer

Documentation

  • Updated CLAUDE.md package structure with local model management
  • Updated docs/design/operations.md: API Surface table, Provider Management section, preset capability flags, Web UI features

Test plan

  • 13,070 Python tests pass (30 new tests for local model management)
  • 2,342 web tests pass (195 test files)
  • Zero lint warnings (ruff, mypy, ESLint)
  • Clean TypeScript type check

Visual testing

  • After bug fix: re-run setup wizard, verify auto-detect and configured provider show same model count
  • After pull endpoint: curl -X POST localhost:3001/api/v1/providers/ollama/models/pull -H "Content-Type: application/json" -d '{"model_name":"llama3.2:1b"}' -N
  • After dashboard: navigate to Providers > Ollama detail, verify real models, test pull/delete/config controls

Review coverage

Pre-reviewed by 8 agents (code-reviewer, conventions-enforcer, frontend-reviewer, silent-failure-hunter, type-design-analyzer, api-contract-drift, docs-consistency, issue-resolution-verifier). 22 findings identified and fixed.

Closes #1030

Copilot AI review requested due to automatic review settings April 3, 2026 14:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 67a5d090-9719-4139-b7e8-206b259f4861

📥 Commits

Reviewing files that changed from the base of the PR and between 6f7c30c and 835f0ea.

📒 Files selected for processing (1)
  • tests/unit/providers/management/test_local_models.py
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Deploy Preview
  • GitHub Check: Build Sandbox
  • GitHub Check: Build Web
  • GitHub Check: Build Backend
  • GitHub Check: Dashboard Test
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations -- Python 3.14 has PEP 649 native lazy annotations
Use except A, B: syntax (no parentheses) per PEP 758 -- ruff enforces this on Python 3.14
Type hints required on all public functions; mypy strict mode enforced
Docstrings required on public classes and functions using Google style; enforced by ruff D rules
Create new objects, never mutate existing ones; for non-Pydantic internal collections use copy.deepcopy() at construction + MappingProxyType wrapping for read-only enforcement
Use frozen Pydantic models for config/identity; separate mutable-via-copy models for runtime state; never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict) with allow_inf_nan=False in all ConfigDict declarations; use @computed_field for derived values; use NotBlankStr for all identifier/name fields
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls); prefer structured concurrency over bare create_task
Line length 88 characters enforced by ruff
Functions must be less than 50 lines; files must be less than 800 lines
Handle errors explicitly, never silently swallow exceptions
Validate at system boundaries (user input, external APIs, config files)
Maintain 80% minimum code coverage enforced in CI
Python 3.14+ required; PEP 649 native lazy annotations; minimum 80% test coverage; mypy strict mode

Files:

  • tests/unit/providers/management/test_local_models.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow markers for test categorization
Use asyncio_mode = "auto" in pytest; no manual @pytest.mark.asyncio needed
30 seconds per test timeout (global in pyproject.toml); do not add per-file pytest.mark.timeout(30) markers; non-default overrides like timeout(60) are allowed
Prefer @pytest.mark.parametrize for testing similar cases
Use test-provider, test-small-001, etc. in tests instead of real vendor names
Use Hypothesis for property-based testing with @given + @settings; profiles: ci (10 examples, derandomize=True), dev (1000 examples), fuzz (10,000 examples, no deadline)
Never skip, dismiss, or ignore flaky tests -- always fix them fully and fundamentally; mock time.monotonic() and asyncio.sleep() for determinism; use asyncio.Event().wait() for indefinite blocking

Files:

  • tests/unit/providers/management/test_local_models.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/unit/providers/management/test_local_models.py
{src,tests}/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

{src,tests}/**/*.py: Linting with uv run ruff check src/ tests/ + formatting with uv run ruff format src/ tests/; enforce 88-char line length
Type-checking with uv run mypy src/ tests/ in strict mode

Files:

  • tests/unit/providers/management/test_local_models.py
🧠 Learnings (4)
📓 Common learnings
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.
📚 Learning: 2026-04-01T15:36:39.993Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T15:36:39.993Z
Learning: Applies to src/**/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`). Use `allow_inf_nan=False` in all `ConfigDict` declarations to reject `NaN`/`Inf` in numeric fields at validation time

Applied to files:

  • tests/unit/providers/management/test_local_models.py
📚 Learning: 2026-04-02T11:49:21.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T11:49:21.202Z
Learning: Applies to **/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`) with `allow_inf_nan=False` in all `ConfigDict` declarations

Applied to files:

  • tests/unit/providers/management/test_local_models.py
📚 Learning: 2026-04-03T17:10:42.444Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T17:10:42.444Z
Learning: Applies to **/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`) with `allow_inf_nan=False` in all `ConfigDict` declarations; use `computed_field` for derived values; use `NotBlankStr` for all identifier/name fields

Applied to files:

  • tests/unit/providers/management/test_local_models.py
🔇 Additional comments (7)
tests/unit/providers/management/test_local_models.py (7)

1-17: LGTM!

Imports are clean and appropriate. Module-level pytest.mark.unit marker correctly categorizes all tests in this file.


20-64: LGTM!

Comprehensive coverage of PullProgressEvent validation: frozen immutability, default values, the cross-field invariant requiring error events to be terminal (done=True), and the completed_bytes <= total_bytes constraint. All validators from the model are exercised.


66-85: LGTM!

Factory function get_local_model_manager is well tested across all documented presets (ollama, lm-studio, vllm), unknown presets, and None input. Uses "example-provider" per testing guidelines.


170-215: LGTM!

Transport error handling test correctly verifies that httpx.ConnectError yields a single terminal error event with the error message preserved. The helper methods _make_aiter and _make_stream_cm provide clean abstractions for mocking the streaming response pattern.


218-254: LGTM!

Delete tests verify the correct request shape and the 404 → ValueError mapping. The timeout assertion (30.0) matches _DELETE_TIMEOUT_SECONDS from the implementation.

Consider adding a test for non-404 HTTP errors (e.g., 500) which should raise RuntimeError per the implementation docstring, but the current coverage of the happy path and 404 case is sufficient for this PR.


257-298: LGTM!

Comprehensive validation coverage for LocalModelParams: frozen immutability, default values (including repeat_penalty), gt=0 vs ge=0 constraints, and allow_inf_nan=False rejection across multiple fields. The parametrized test efficiently covers NaN/Inf rejection.

Minor observation: num_batch isn't explicitly tested in test_non_finite_rejected, but since allow_inf_nan=False applies to the entire model's ConfigDict, the existing tests provide adequate confidence.


119-120: The test assertion is correct. The implementation at line 173 explicitly sets is_done = status == "success", ensuring that success status events are properly marked as terminal (done=True), matching the test expectation.


Walkthrough

Adds local model management end-to-end: a streaming SSE pull endpoint (POST /api/v1/providers/{name}/models/pull), model delete (DELETE /api/v1/providers/{name}/models/{model_id}), and model config update (PUT /api/v1/providers/{name}/models/{model_id}/config). Introduces LocalModelParams, PullProgressEvent, a LocalModelManager protocol with an OllamaModelManager implementation, provider preset capability flags (supports_model_pull, supports_model_delete, supports_model_config) and preset_name, service methods to pull/delete/update models, UI/store/storybook updates, observability events, and unit tests.

Suggested labels

autorelease: tagged

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: local model management for Ollama and LM Studio' accurately describes the primary change—adding local model management capabilities (pull, delete, config) for local providers.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, covering bug fixes (model count mismatch), backend implementation (APIs, managers, service layer), frontend features (dialogs, drawers, controls), documentation updates, and test coverage.
Linked Issues check ✅ Passed The PR meets all coding requirements from issue #1030: fixes model-count mismatch for auth_type=NONE presets, adds preset capability flags, implements streaming pull/delete/config endpoints and LocalModelManager protocol, updates service layer and DTOs, includes UI components and Storybook stories, and demonstrates 13,070+ passing tests.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the objectives in issue #1030: local model management APIs, capability flags, config parameters, streaming pull/delete/config, UI controls, and documentation. Minor CSS class updates (p-card, gap-grid-gap) are incidental styling normalizations, not out-of-scope.
Docstring Coverage ✅ Passed Docstring coverage is 66.67% which is sufficient. The required threshold is 40.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 3, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 835f0ea.
Ensure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue. See the documentation for more information and troubleshooting advice.

Scanned Files

None

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces local model management capabilities, primarily targeting Ollama. It adds backend support for pulling models with SSE-based progress streaming, deleting models, and configuring per-model launch parameters (such as context window and GPU layers). The frontend is updated with new UI components including a model pull dialog and a configuration drawer. Additionally, the PR includes a bug fix to ensure local presets skip static LiteLLM model discovery in favor of live discovery. A critical syntax error was identified in the exception handling logic of the provider management service.

await manager.delete_model(model_id)
try:
await self.discover_models_for_provider(name)
except MemoryError, RecursionError:
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

The use of a comma in the except clause is invalid syntax in Python. It should be written as except (MemoryError, RecursionError):.

Suggested change
except MemoryError, RecursionError:
except (MemoryError, RecursionError):

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end local model management for local providers (starting with Ollama), fixes model count mismatches by preferring live discovery for no-auth presets, and exposes new capability-gated UI controls in the dashboard.

Changes:

  • Backend: introduce preset capability flags, per-model LocalModelParams, local model manager protocol + Ollama implementation, and provider management service methods for pull/delete/config.
  • API: add SSE-based model pull endpoint plus delete/config endpoints; include preset_name and capability flags in provider responses.
  • Web: add model pull dialog, delete confirmation, config drawer, refresh UI, and update store/API client plumbing and tests/stories.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
web/src/stores/providers.ts Zustand store actions/state for pull/delete/config + UI refresh integration
web/src/api/endpoints/providers.ts Adds POST-based SSE pull via fetch + delete/config endpoints
web/src/api/types.ts Adds LocalModelParams, PullProgressEvent, provider capability flags, preset_name
web/src/pages/ProviderDetailPage.tsx Wires refresh/pull/config/delete dialogs into provider detail page
web/src/pages/providers/ProviderDetailHeader.tsx Adds Refresh + Pull Model buttons (capability-gated)
web/src/pages/providers/ProviderModelList.tsx Adds per-model action buttons (configure/delete) when supported
web/src/pages/providers/ModelPullDialog.tsx New pull dialog with progress gauge + cancel handling
web/src/pages/providers/ModelConfigDrawer.tsx New drawer to edit per-model LocalModelParams
web/src/pages/**.stories.tsx Updates/adds Storybook coverage for new UI components/fields
web/src/tests/** Updates tests for new provider fields + local_params
src/synthorg/providers/presets.py Adds capability flags to presets (Ollama enabled)
src/synthorg/config/schema.py Adds LocalModelParams and local_params on ProviderModelConfig; adds preset_name on ProviderConfig
src/synthorg/api/dto_providers.py Adds DTO fields + request payloads for pull/config; resolves capability flags in responses
src/synthorg/api/controllers/providers.py Adds SSE pull + delete + config endpoints
src/synthorg/providers/management/local_models.py Implements LocalModelManager and OllamaModelManager (pull/delete)
src/synthorg/providers/management/service.py Bug fix for local presets to skip LiteLLM DB; adds service methods for pull/delete/config
src/synthorg/providers/management/_helpers.py Persists preset_name into ProviderConfig on creation
src/synthorg/observability/events/provider.py Adds structured event names for local model management
tests/unit/providers/** Adds unit coverage for capabilities, local model manager, and service behavior
docs/design/operations.md Documents capability flags + new local model management endpoints/features
CLAUDE.md Updates package structure note to include local model management

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +397 to +416
pullModel: async (name, modelName) => {
_pullAbortController = new AbortController()
set({ pullingModel: true, pullProgress: null })
try {
await apiPullModel(
name,
modelName,
(event) => set({ pullProgress: event }),
_pullAbortController.signal,
)
useToastStore.getState().add({
variant: 'success',
title: `Model "${modelName}" pulled successfully`,
})
const detail = get().selectedProvider
if (detail?.name === name) {
await get().fetchProviderDetail(name)
}
return true
} catch (err) {
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

pullModel() shows a success toast and returns true whenever apiPullModel() completes, even if the SSE stream reported an error (e.g. backend emits an error event or a progress payload with an error field). Because apiPullModel() never rejects on streamed errors, users can see “pulled successfully” for failed pulls. Track the last streamed event and treat event.error / event.done && error (or SSE event: error) as a failure (reject or return false) before emitting the success toast.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +180

while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })

const lines = buffer.split('\n')
buffer = lines.pop() ?? ''

for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6)) as PullProgressEvent
onProgress(event)
} catch {
// Skip malformed JSON
}
}
}
}

// Process any remaining data in the buffer after the stream ends
if (buffer.startsWith('data: ')) {
try {
const event = JSON.parse(buffer.slice(6)) as PullProgressEvent
onProgress(event)
} catch {
// Skip malformed JSON
}
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The SSE parser only processes lines starting with "data: " and ignores event: lines, so it can’t distinguish progress vs error events and can miss the last message if the final chunk ends with event: then data: without a trailing newline. This also means an error event payload (currently {error: ...}) will be treated as a PullProgressEvent without required fields. Consider implementing a minimal SSE parser (track current event + accumulate data: lines until blank line), and reject/throw when event === "error" or when parsed JSON contains an error flag.

Suggested change
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6)) as PullProgressEvent
onProgress(event)
} catch {
// Skip malformed JSON
}
}
}
}
// Process any remaining data in the buffer after the stream ends
if (buffer.startsWith('data: ')) {
try {
const event = JSON.parse(buffer.slice(6)) as PullProgressEvent
onProgress(event)
} catch {
// Skip malformed JSON
}
}
let currentEvent = 'message'
let currentDataLines: string[] = []
const dispatchEvent = () => {
if (currentDataLines.length === 0) {
currentEvent = 'message'
return
}
const data = currentDataLines.join('\n')
currentDataLines = []
try {
const parsed = JSON.parse(data) as PullProgressEvent | { error?: string }
if (
currentEvent === 'error' ||
(parsed !== null &&
typeof parsed === 'object' &&
'error' in parsed &&
typeof parsed.error === 'string' &&
parsed.error.length > 0)
) {
const message =
parsed !== null &&
typeof parsed === 'object' &&
'error' in parsed &&
typeof parsed.error === 'string' &&
parsed.error.length > 0
? parsed.error
: 'Pull failed'
currentEvent = 'message'
throw new Error(message)
}
onProgress(parsed as PullProgressEvent)
} catch (error) {
currentEvent = 'message'
if (error instanceof Error) {
throw error
}
// Skip malformed JSON
return
}
currentEvent = 'message'
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line === '') {
dispatchEvent()
continue
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
const value = line.startsWith('data: ') ? line.slice(6) : line.slice(5)
currentDataLines.push(value)
}
}
}
buffer += decoder.decode()
const trailingLines = buffer.split(/\r?\n/)
for (const line of trailingLines) {
if (line === '') {
dispatchEvent()
continue
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
const value = line.startsWith('data: ') ? line.slice(6) : line.slice(5)
currentDataLines.push(value)
}
}
if (currentDataLines.length > 0) {
dispatchEvent()
}

Copilot uses AI. Check for mistakes.
Comment on lines +639 to +659
async def _event_stream() -> AsyncIterator[dict[str, str]]:
try:
async for event in svc.pull_model(name, data.model_name):
yield {
"event": "complete" if event.done else "progress",
"data": _json.dumps(
event.model_dump(exclude_none=True),
),
}
except ProviderNotFoundError:
yield {
"event": "error",
"data": _json.dumps(
{"error": f"Provider {name!r} not found"},
),
}
except ProviderValidationError as exc:
yield {
"event": "error",
"data": _json.dumps({"error": str(exc)}),
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

The SSE error path emits data shaped as { "error": "..." }, while the success/progress path emits serialized PullProgressEvent. This makes the stream payload a union type and breaks clients that expect consistent PullProgressEvent fields (e.g. status, done). Consider always emitting a PullProgressEvent-shaped payload on errors too (e.g. status="error", error=..., done=true, and include nulls) so the frontend can handle errors uniformly.

Copilot uses AI. Check for mistakes.
Comment on lines +686 to +706
async def update_model_config(
self,
name: str,
model_id: str,
local_params: LocalModelParams,
) -> ProviderConfig:
"""Update per-model launch parameters for a local provider.

Args:
name: Provider name.
model_id: Model identifier.
local_params: New launch parameters.

Returns:
Updated provider configuration.

Raises:
ProviderNotFoundError: If the provider does not exist.
ProviderValidationError: If the model is not found.
"""
async with self._lock:
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

update_model_config() updates local_params for any provider/model without checking the preset capability flag (supports_model_config). This bypasses the capability gating used for pull/delete (and the UI gating), allowing config updates on providers where it should be unsupported. Consider reusing _resolve_local_manager(..., capability="config") or at least validating the preset capability before persisting the update.

Copilot uses AI. Check for mistakes.
Comment on lines +397 to +405
pullModel: async (name, modelName) => {
_pullAbortController = new AbortController()
set({ pullingModel: true, pullProgress: null })
try {
await apiPullModel(
name,
modelName,
(event) => set({ pullProgress: event }),
_pullAbortController.signal,
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

pullModel() overwrites the module-level _pullAbortController without aborting any in-flight pull first. If the user triggers multiple pulls (double-click, rapid reopen), the earlier fetch will keep running but becomes un-cancellable and can continue updating state. Consider aborting any existing controller before creating a new one, or guarding against concurrent pulls.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +52
const handleSave = async () => {
setSaving(true)
const parseIntSafe = (val: string): number | null => {
if (!val) return null
const parsed = parseInt(val, 10)
return Number.isNaN(parsed) ? null : parsed
}
const parseFloatSafe = (val: string): number | null => {
if (!val) return null
const parsed = parseFloat(val)
return Number.isNaN(parsed) ? null : parsed
}
const newParams: LocalModelParams = {
num_ctx: parseIntSafe(numCtx),
num_gpu_layers: parseIntSafe(numGpuLayers),
num_threads: parseIntSafe(numThreads),
num_batch: parseIntSafe(numBatch),
repeat_penalty: parseFloatSafe(repeatPenalty),
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

parseInt() / parseFloat() will accept partially-numeric inputs (e.g. "12abc" becomes 12), which can silently persist unintended values. If the intent is “numeric or empty”, consider validating the whole string (e.g. regex or Number(val) + Number.isFinite) and treating any non-strict numeric input as null/invalid before calling the API.

Copilot uses AI. Check for mistakes.
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 3, 2026

Codecov Report

❌ Patch coverage is 63.35616% with 107 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.28%. Comparing base (901ae92) to head (835f0ea).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/synthorg/api/controllers/providers.py 16.21% 62 Missing ⚠️
src/synthorg/providers/management/local_models.py 79.16% 18 Missing and 7 partials ⚠️
src/synthorg/providers/management/service.py 70.90% 13 Missing and 3 partials ⚠️
src/synthorg/api/dto_providers.py 81.81% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1037      +/-   ##
==========================================
- Coverage   91.48%   91.28%   -0.21%     
==========================================
  Files         687      688       +1     
  Lines       38392    38680     +288     
  Branches     3821     3851      +30     
==========================================
+ Hits        35124    35310     +186     
- Misses       2608     2701      +93     
- Partials      660      669       +9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/synthorg/api/dto_providers.py (1)

133-166: ⚠️ Potential issue | 🔴 Critical

Remove preset_name from CreateProviderRequest or filter it out during conversion.

The generic create_provider() endpoint accepts preset_name from the client via CreateProviderRequest, and the conversion helper (_request_to_config() in _helpers.py:57) copies it directly into ProviderConfig without validation. This allows a client to manually set preset_name to an arbitrary preset, and to_provider_response() will then derive supports_model_pull, supports_model_delete, and supports_model_config from that preset. Combined with a client-supplied base_url, this grants local model management capabilities to any provider that was not legitimately created via create_from_preset(), violating the boundary enforcement required by the coding guidelines ("Validate at system boundaries").

Only create_from_preset() should set preset_name on the resulting config (line 375). Remove the field from CreateProviderRequest or, if backward compatibility is required, filter it out to None during conversion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/dto_providers.py` around lines 133 - 166, Remove
client-controlled preset injection by eliminating preset_name from the
CreateProviderRequest model or by ensuring _request_to_config() ignores it:
update CreateProviderRequest to no longer declare preset_name (or accept it but
never map it), and modify _request_to_config() to always set
ProviderConfig.preset_name = None so only create_from_preset() can assign a
preset; also verify to_provider_response() continues deriving capabilities from
ProviderConfig.preset_name only when set by create_from_preset().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/design/operations.md`:
- Line 130: Update the local model management bullet to use the full provider
API namespace: replace the relative endpoints `POST /{name}/models/pull`,
`DELETE /{name}/models/{model_id}`, and `PUT /{name}/models/{model_id}/config`
with fully qualified paths under the provider API (e.g. `POST
/api/v1/providers/{name}/models/pull`, `DELETE
/api/v1/providers/{name}/models/{model_id}`, `PUT
/api/v1/providers/{name}/models/{model_id}/config`) and keep the referenced
`LocalModelParams` fields (`num_ctx`, `num_gpu_layers`, `num_threads`,
`num_batch`, `repeat_penalty`) unchanged; update the Ollama/LM Studio note
accordingly.

In `@src/synthorg/api/controllers/providers.py`:
- Around line 663-669: The log call in providers.py uses the inline string
"api.sse.pull_model_failed"; replace this with the appropriate event constant
from synthorg.observability.events (import the domain constant, e.g. from
synthorg.observability.events.api import SSE_PULL_MODEL_FAILED) and pass that
constant to logger.exception instead of the inline string while keeping the same
context (provider=name, model=data.model_name, error=str(exc),
error_type=type(exc).__qualname__). Ensure the import is added at top of the
module and update the logger.exception invocation to use the imported constant.

In `@src/synthorg/providers/management/local_models.py`:
- Around line 275-298: The delete logic currently raises ValueError for any
response.status_code >= _HTTP_CLIENT_ERROR which gets mapped to a 404 upstream;
change it so ValueError is only raised when response.status_code ==
_HTTP_NOT_FOUND (keep the PROVIDER_MODEL_DELETE_FAILED log as-is), and for other
HTTP errors (response.status_code >= _HTTP_CLIENT_ERROR) log
PROVIDER_MODEL_DELETE_FAILED and raise a different exception type (e.g.,
RuntimeError or a custom UpstreamError) with the HTTP status and message; also
surface transport/timeout exceptions from client.request (wrap the await
client.request call to catch transport errors, log PROVIDER_MODEL_DELETE_FAILED
with the error, and re-raise as the same non-ValueError exception).

In `@src/synthorg/providers/management/service.py`:
- Around line 686-748: The update_model_config method currently persists
local_params without verifying the provider preset's capability; inside the
async with self._lock block (in update_model_config) check the provider config's
preset supports_model_config flag (e.g., config.preset.supports_model_config)
after loading config and before modifying models, and if it's False log using
PROVIDER_VALIDATION_FAILED (include provider/name and a message) and raise
ProviderValidationError to prevent persisting launch parameters for presets that
don't allow model config changes.

In `@tests/unit/providers/management/test_local_models.py`:
- Around line 28-34: The test test_defaults assumes
PullProgressEvent.progress_percent is None but the actual default is 0; update
the test_defaults assertions to match the implementation by expecting
progress_percent == 0 (keep other assertions unchanged), i.e., change the
assertion in test_defaults for PullProgressEvent.progress_percent to assert it
equals 0 so the test aligns with the PullProgressEvent class default.

In `@web/src/api/endpoints/providers.ts`:
- Around line 160-179: The JSON parse failures in the SSE handling loop and
final buffer (when parsing into PullProgressEvent) are being swallowed; update
the catch blocks in the pullModel() stream handling to signal terminal failure
by either (a) calling onProgress with a synthetic PullProgressEvent representing
an error (include an error message and a terminal status) or (b) rethrowing the
parse error so the caller can fail; ensure you reference the same
PullProgressEvent shape and use onProgress(event) so the store receives a
terminal error instead of silently treating the stream as successful.

In `@web/src/pages/providers/ModelConfigDrawer.tsx`:
- Around line 34-52: The parsing helpers inside handleSave (parseIntSafe and
parseFloatSafe) currently allow partial parses like "12abc" → 12; change them to
validate that the entire string is a valid number and otherwise return null or
mark the field invalid: use full-string checks (e.g., regex or re-parsing and
comparing string representations) for numCtx, numGpuLayers, numThreads, numBatch
and repeatPenalty before constructing LocalModelParams, and when a value is
malformed set a corresponding error state used by the InputField error prop so
the UI shows validation feedback and prevent saving until corrected.

In `@web/src/pages/providers/ModelPullDialog.stories.tsx`:
- Around line 18-21: Replace the hardcoded wrapper height class "min-h-[400px]"
around the Story render with a design-system friendly utility: use a standard
Tailwind spacing like "min-h-96" or the tokenized density variable (e.g.,
"min-h-[var(--p-card)]" or whatever project token mapping provides) so the story
uses approved spacing rules; update the div wrapping Story in
ModelPullDialog.stories (the div containing <Story />) to use the chosen class
instead of "min-h-[400px]".

In `@web/src/pages/providers/ModelPullDialog.tsx`:
- Around line 49-116: Replace the hand-rolled overlay in ModelPullDialog with
the app's accessible modal primitive (the same Dialog/Modal component used by
other UI modals) instead of a raw div so focus is trapped and restored
automatically; wire the modal's onClose to the existing handleCancel/onClose
handlers, set the modal's initialFocus to the model name InputField (add a ref
to InputField) so keyboard users start on the input, and ensure the primitive's
aria props are used rather than manual role/aria-modal/aria-label on the
wrapper; keep existing handlers (handlePull, handleCancel, onClose) and state
(pullingModel, pullProgress) but remove the outer div overlay and rely on the
imported Dialog/Modal's built-in focus management and backdrop behavior.

In `@web/src/pages/providers/ProviderModelList.tsx`:
- Around line 72-92: The two raw icon <button> elements (the Configure and
Delete actions rendered when supportsConfig and supportsDelete are true) must be
replaced with the shared Button primitive (use the project Button component) so
row actions inherit focus, disabled and variant styling; update the JSX in
ProviderModelList to render <Button> with the same onClick handlers
(onConfigure?.(model) and onDelete?.(model.id)), preserve title and aria-label
attributes, render the Settings2 and Trash2 icons as the Button children, remove
the ad-hoc className styling used on the raw buttons, and add the appropriate
Button props for an icon/ghost variant and size (so keyboard focus and visual
variants match other UI buttons); also add the Button import at the top and
remove the unused raw button markup.

In `@web/src/stores/providers.ts`:
- Around line 398-405: The module-level _pullAbortController is being
overwritten on each apiPullModel call so the finally block can clear the active
controller for a newer request; fix by creating a local controller instance
(e.g., const controller = new AbortController()), assign it to the module-level
_pullAbortController, pass controller.signal to apiPullModel, and in the finally
only clear/reset _pullAbortController and set pullingModel:false if
_pullAbortController === controller (so cleanup is tied to the specific
controller that started the request).
- Around line 401-415: The success toast is shown unconditionally after
apiPullModel resolves, but apiPullModel streams terminal payloads that may
include an in-band error; modify the caller around apiPullModel (the block that
updates pullProgress and then calls useToastStore.getState().add) to track the
last SSE event received (the value fed into set({ pullProgress: event })) and
only show the success toast and call get().fetchProviderDetail when that final
event has done === true and no error property; if the final event contains an
error, show an error toast instead and return false; use the existing symbols
apiPullModel, set({ pullProgress: ... }), _pullAbortController.signal,
get().selectedProvider and get().fetchProviderDetail to locate and implement
this conditional behavior.

---

Outside diff comments:
In `@src/synthorg/api/dto_providers.py`:
- Around line 133-166: Remove client-controlled preset injection by eliminating
preset_name from the CreateProviderRequest model or by ensuring
_request_to_config() ignores it: update CreateProviderRequest to no longer
declare preset_name (or accept it but never map it), and modify
_request_to_config() to always set ProviderConfig.preset_name = None so only
create_from_preset() can assign a preset; also verify to_provider_response()
continues deriving capabilities from ProviderConfig.preset_name only when set by
create_from_preset().
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6254bc19-45c9-49b5-bff5-98a057a6c8f7

📥 Commits

Reviewing files that changed from the base of the PR and between 760bfbd and 39a27b3.

📒 Files selected for processing (32)
  • CLAUDE.md
  • docs/design/operations.md
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/api/dto_providers.py
  • src/synthorg/config/schema.py
  • src/synthorg/observability/events/provider.py
  • src/synthorg/providers/management/_helpers.py
  • src/synthorg/providers/management/local_models.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/presets.py
  • tests/unit/providers/management/test_local_models.py
  • tests/unit/providers/management/test_service_local_models.py
  • tests/unit/providers/test_presets.py
  • web/src/__tests__/pages/ProviderDetailPage.test.tsx
  • web/src/__tests__/pages/ProvidersPage.test.tsx
  • web/src/__tests__/stores/setup-wizard.test.ts
  • web/src/__tests__/utils/providers.test.ts
  • web/src/__tests__/utils/setup-validation.property.test.ts
  • web/src/__tests__/utils/setup-validation.test.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/types.ts
  • web/src/pages/ProviderDetailPage.tsx
  • web/src/pages/providers/ModelConfigDrawer.stories.tsx
  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/pages/providers/ModelPullDialog.stories.tsx
  • web/src/pages/providers/ModelPullDialog.tsx
  • web/src/pages/providers/PresetPicker.stories.tsx
  • web/src/pages/providers/ProviderCard.stories.tsx
  • web/src/pages/providers/ProviderDetailHeader.tsx
  • web/src/pages/providers/ProviderModelList.stories.tsx
  • web/src/pages/providers/ProviderModelList.tsx
  • web/src/stores/providers.ts

Comment on lines +49 to +116
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Pull model"
>
<div className="w-full max-w-md rounded-lg bg-card p-card shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Pull Model</h2>
<button
type="button"
onClick={handleCancel}
aria-label="Close"
className="rounded p-1 text-text-muted hover:bg-bg-surface hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>

{!pullingModel ? (
<div className="flex flex-col gap-4">
<InputField
label="Model name"
value={modelName}
onValueChange={setModelName}
placeholder="e.g. llama3.2:1b"
hint="Enter the model name and optional tag"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
onClick={handlePull}
disabled={!modelName.trim()}
>
<Download className="size-3.5 mr-1.5" />
Pull
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-center py-4">
<ProgressGauge
value={progressPercent}
variant="linear"
/>
</div>
<p className="text-center text-sm text-text-secondary truncate">
{statusText}
</p>
{pullProgress?.error && (
<p className="text-center text-sm text-danger">
{pullProgress.error}
</p>
)}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
</div>
</div>
)}
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This modal needs real focus management.

The hand-rolled overlay never traps focus, sets initial focus, or restores focus on close, so keyboard users can tab behind the dialog and lose context during a pull. Please build this on the same accessible modal primitive used elsewhere instead of a raw div overlay. As per coding guidelines, "ALWAYS reuse existing components from web/src/components/ui/ before creating new ones."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ModelPullDialog.tsx` around lines 49 - 116, Replace
the hand-rolled overlay in ModelPullDialog with the app's accessible modal
primitive (the same Dialog/Modal component used by other UI modals) instead of a
raw div so focus is trapped and restored automatically; wire the modal's onClose
to the existing handleCancel/onClose handlers, set the modal's initialFocus to
the model name InputField (add a ref to InputField) so keyboard users start on
the input, and ensure the primitive's aria props are used rather than manual
role/aria-modal/aria-label on the wrapper; keep existing handlers (handlePull,
handleCancel, onClose) and state (pullingModel, pullProgress) but remove the
outer div overlay and rely on the imported Dialog/Modal's built-in focus
management and backdrop behavior.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
web/src/pages/providers/ModelConfigDrawer.tsx (1)

34-52: ⚠️ Potential issue | 🟠 Major

Don't persist malformed numeric input as cleared config.

These helpers still treat bad input as something savable: typos like 4096x collapse to null and clear the existing setting, while whitespace-only input is parsed as a value instead of “blank”. Please validate trimmed input and block save with field errors instead of serializing malformed values into LocalModelParams.

Run this read-only check to confirm the current helper behavior. Expected result: whitespace becomes a numeric value, and malformed strings collapse to null rather than being rejected.

#!/bin/bash
set -euo pipefail

sed -n '34,52p' web/src/pages/providers/ModelConfigDrawer.tsx

node - <<'NODE'
const parseIntStrict = (val) => {
  if (!val) return null
  const n = Number(val)
  return Number.isFinite(n) && Number.isInteger(n) ? n : null
}
const parseFloatStrict = (val) => {
  if (!val) return null
  const n = Number(val)
  return Number.isFinite(n) ? n : null
}

for (const sample of ['   ', '4096x', '1.25x']) {
  console.log(JSON.stringify({
    sample,
    parseIntStrict: parseIntStrict(sample),
    parseFloatStrict: parseFloatStrict(sample),
  }))
}
NODE

Based on learnings, "Validate at system boundaries (user input, external APIs, config files)".

Also applies to: 60-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ModelConfigDrawer.tsx` around lines 34 - 52, The
parseIntStrict and parseFloatStrict helpers accept whitespace-only input as
valid and silently convert malformed strings to null which clears existing
settings; fix by trimming input first and treating empty-after-trim as "blank"
(do not change stored value) and treat any non-numeric or partially-numeric
values as validation errors that prevent save. Specifically, update
parseIntStrict and parseFloatStrict in ModelConfigDrawer.tsx to trim(val) and
return a distinct sentinel or throw/return error state for invalid input (rather
than null), then in handleSave validate all fields before constructing
LocalModelParams (use the trimmed values, only include numeric properties when
valid), set per-field error state for invalid inputs, and block the save when
any field error exists so malformed input is not persisted.
web/src/stores/providers.ts (1)

397-429: ⚠️ Potential issue | 🟠 Major

Tie pull cleanup to the controller that started the request.

If a second pull begins before the first one finishes unwinding, the first finally block still nulls _pullAbortController and flips pullingModel to false. That leaves the newer request without a cancel handle and drops the UI back to idle mid-download.

Suggested fix
   pullModel: async (name, modelName) => {
-    _pullAbortController?.abort()
-    _pullAbortController = new AbortController()
+    const controller = new AbortController()
+    _pullAbortController?.abort()
+    _pullAbortController = controller
     set({ pullingModel: true, pullProgress: null })
     try {
       await apiPullModel(
         name,
         modelName,
         (event) => set({ pullProgress: event }),
-        _pullAbortController.signal,
+        controller.signal,
       )
       useToastStore.getState().add({
         variant: 'success',
         title: `Model "${modelName}" pulled successfully`,
       })
@@
     } finally {
-      _pullAbortController = null
-      set({ pullingModel: false })
+      if (_pullAbortController === controller) {
+        _pullAbortController = null
+        set({ pullingModel: false })
+      }
     }
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/providers.ts` around lines 397 - 429, The finally block is
clearing the shared _pullAbortController and flipping pullingModel even when a
newer pull started; fix pullModel by capturing the controller created for this
invocation (e.g., const controller = _pullAbortController after new
AbortController()) and use that local reference in the finally to only null
_pullAbortController and set pullingModel to false if _pullAbortController ===
controller, so subsequent pulls retain their abort handle and UI state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 771-787: The current except block for ProviderValidationError in
controllers/providers.py treats any validation failure as HTTP 400; change it so
that when the validation error specifically indicates the target model is
missing you raise NotFoundError instead. In practice: inside the except
ProviderValidationError as exc handler, inspect exc (e.g., error
code/message/attribute that denotes "model not found" for the service) and if it
matches the missing-model condition, log API_RESOURCE_NOT_FOUND with
resource="model" and name=model_id and raise NotFoundError(f"Model {model_id!r}
not found") from exc; otherwise keep the existing API_VALIDATION_FAILED log and
raise ApiValidationError(str(exc)). Reference symbols: ProviderValidationError,
NotFoundError, ApiValidationError, API_RESOURCE_NOT_FOUND,
API_VALIDATION_FAILED, and the model_id variable.

In `@src/synthorg/providers/management/local_models.py`:
- Around line 193-225: The local_models.py loop defines an unused got_done
variable; remove got_done and its final conditional branch by relying on the
existing early return when event.done is true. In the async for over
response.aiter_lines(), keep JSON parsing and the call to
self._parse_pull_line(model_name) and yielding the event, then when the stream
ends log the same PROVIDER_MODEL_PULL_FAILED message and yield the
PullProgressEvent as currently done but without referencing or updating
got_done; remove the got_done declaration and any checks related to it to
eliminate the dead variable.

---

Duplicate comments:
In `@web/src/pages/providers/ModelConfigDrawer.tsx`:
- Around line 34-52: The parseIntStrict and parseFloatStrict helpers accept
whitespace-only input as valid and silently convert malformed strings to null
which clears existing settings; fix by trimming input first and treating
empty-after-trim as "blank" (do not change stored value) and treat any
non-numeric or partially-numeric values as validation errors that prevent save.
Specifically, update parseIntStrict and parseFloatStrict in
ModelConfigDrawer.tsx to trim(val) and return a distinct sentinel or
throw/return error state for invalid input (rather than null), then in
handleSave validate all fields before constructing LocalModelParams (use the
trimmed values, only include numeric properties when valid), set per-field error
state for invalid inputs, and block the save when any field error exists so
malformed input is not persisted.

In `@web/src/stores/providers.ts`:
- Around line 397-429: The finally block is clearing the shared
_pullAbortController and flipping pullingModel even when a newer pull started;
fix pullModel by capturing the controller created for this invocation (e.g.,
const controller = _pullAbortController after new AbortController()) and use
that local reference in the finally to only null _pullAbortController and set
pullingModel to false if _pullAbortController === controller, so subsequent
pulls retain their abort handle and UI state.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 39a740b4-fd68-40f0-8c79-9d50660ae39b

📥 Commits

Reviewing files that changed from the base of the PR and between 39a27b3 and e651b88.

📒 Files selected for processing (18)
  • docs/design/operations.md
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/api/dto_providers.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/providers/management/local_models.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/presets.py
  • tests/unit/providers/management/test_local_models.py
  • tests/unit/providers/management/test_service_local_models.py
  • web/src/api/endpoints/providers.ts
  • web/src/pages/ProviderDetailPage.tsx
  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/pages/providers/ModelPullDialog.tsx
  • web/src/pages/providers/PresetPicker.tsx
  • web/src/pages/providers/ProviderFormModal.tsx
  • web/src/pages/providers/ProviderModelList.stories.tsx
  • web/src/pages/providers/TestConnectionResult.tsx
  • web/src/stores/providers.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (4)
web/src/stores/providers.ts (2)

439-443: ⚠️ Potential issue | 🟠 Major

Keep pullingModel cleanup tied to the active controller.

Line 443 still runs even when this request has already been superseded by a newer pull. The older finally can therefore hide a still-running stream, and it makes it impossible to keep a terminal error state latched if the dialog wants to continue showing it.

♻️ Scope the reset to the request that still owns `_pullAbortController`
     } finally {
       if (_pullAbortController === controller) {
         _pullAbortController = null
-      }
-      set({ pullingModel: false })
+        const finalEvent = get().pullProgress
+        if (!(finalEvent?.done && finalEvent.error)) {
+          set({ pullingModel: false })
+        }
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/providers.ts` around lines 439 - 443, The finally block
unconditionally clears pullingModel which can hide a newer pull; change it so
pullingModel is only reset when this finally belongs to the active request by
checking that _pullAbortController === controller before calling set({
pullingModel: false }) (i.e., scope both _pullAbortController = null and set({
pullingModel: false }) behind the existing controller equality check for the
_pullAbortController variable).

397-424: ⚠️ Potential issue | 🔴 Critical

Require a terminal event before reporting pull success.

apiPullModel() resolves when the SSE stream closes, so lastEvent === null or done === false still falls through to the success toast. This block is also what CI is failing on at Line 413 (TS2339), because TypeScript does not track the callback assignment into lastEvent across the await.

♻️ One way to fix the terminal-event check and the TS narrowing issue
-    let lastEvent: PullProgressEvent | null = null
+    const streamState: { lastEvent: PullProgressEvent | null } = { lastEvent: null }
     try {
       await apiPullModel(
         name,
         modelName,
         (event) => {
-          lastEvent = event
+          streamState.lastEvent = event
           set({ pullProgress: event })
         },
         controller.signal,
       )
-      if (lastEvent?.error) {
+      const finalEvent = streamState.lastEvent
+      if (!finalEvent || !finalEvent.done) {
+        useToastStore.getState().add({
+          variant: 'error',
+          title: 'Model pull incomplete',
+          description: 'Pull stream ended before a terminal event was received.',
+        })
+        return false
+      }
+      if (finalEvent.error) {
         useToastStore.getState().add({
           variant: 'error',
           title: 'Model pull failed',
-          description: lastEvent.error,
+          description: finalEvent.error,
         })
         return false
       }
#!/bin/bash
# Inspect the terminal-event handling around the failing line and the event shape it depends on.
sed -n '397,424p' web/src/stores/providers.ts
printf '\n--- PullProgressEvent ---\n'
sed -n '700,707p' web/src/api/types.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/providers.ts` around lines 397 - 424, The pullModel flow
reports success whenever apiPullModel resolves even if the SSE never emitted a
terminal event; fix pullModel (and the post-await logic) to require a terminal
event: after the await apiPullModel(...) check that lastEvent is non-null and
that lastEvent.done === true and lastEvent.error is falsy before showing the
success toast, otherwise show an error toast using lastEvent?.error or a generic
message and return false; reference the pullModel function, the lastEvent
variable (type PullProgressEvent | null), the PullProgressEvent.done and .error
fields, and the apiPullModel callback to locate and implement this check so
TypeScript narrowing is satisfied and CI TS2339 goes away.
src/synthorg/providers/management/local_models.py (1)

97-106: ⚠️ Potential issue | 🟡 Minor

Fix the delete_model() exception contract in the protocol/docstrings.

The implementation now uses ValueError only for “model not found” and RuntimeError for upstream/transport failures, but both docstrings still say ValueError covers request failures too. That stale contract makes the next LocalModelManager implementation likely to reintroduce the old 404 mis-mapping.

Also applies to: 300-302

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/management/local_models.py` around lines 97 - 106, The
docstring for delete_model currently misstates the exception contract; update
the Raises section for the delete_model(self, model_name: str) method to specify
that ValueError is raised only when the model does not exist and that
RuntimeError is raised for upstream/transport/delete request failures, and
ensure any related wording in the method (and similar docstrings around lines
300-302) matches this contract so implementations (e.g., LocalModelManager) will
not map 404s to the wrong exception type.
src/synthorg/api/controllers/providers.py (1)

806-815: ⚠️ Potential issue | 🟠 Major

Return 404 when the updated model is missing.

This branch is a missing resource, not a validation failure. Returning ApiValidationError makes PUT /models/{model_id}/config inconsistent with the delete endpoint and forces clients to treat stale model IDs as HTTP 400.

🩹 Suggested fix
         if model is None:
-            msg = f"Model {model_id!r} missing from updated config"
-            logger.error(
-                API_VALIDATION_FAILED,
-                resource="model",
-                name=model_id,
-                provider=name,
-                error=msg,
-            )
-            raise ApiValidationError(msg)
+            msg = f"Model {model_id!r} not found"
+            logger.warning(
+                API_RESOURCE_NOT_FOUND,
+                resource="model",
+                name=model_id,
+                provider=name,
+            )
+            raise NotFoundError(msg)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 806 - 815, This
branch represents a missing resource, not a validation error: replace the
ApiValidationError raise and the API_VALIDATION_FAILED log with a 404-style
error; update the logger call to use the not-found error code constant (e.g.,
API_NOT_FOUND) and raise the appropriate not-found exception (e.g.,
ApiNotFoundError or ResourceNotFoundError) instead of ApiValidationError when
model is None, keeping the same context fields (resource="model", name=model_id,
provider=name) so PUT /models/{model_id}/config returns HTTP 404 for
stale/missing model IDs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 746-754: The except block in providers.py currently maps
RuntimeError (from local_models.py upstream/transport failures) to
ApiValidationError, causing 5xx/provider errors to be treated as client 400s;
change the handler so RuntimeError is not converted to ApiValidationError—either
re-raise the original RuntimeError or raise the controller-level provider/server
error (e.g., ApiProviderError or the existing 5xx error type used elsewhere)
instead of ApiValidationError; keep the logger.warning call
(API_VALIDATION_FAILED, resource="model", name=model_id, provider=name,
error=str(exc)) or change it to a provider-error log level if appropriate, but
ensure ApiValidationError is not used for RuntimeError so upstream failures
surface as 5xx.
- Around line 645-697: The SSE handler _event_stream currently yields error
payloads that omit nullable fields (progress_percent, total_bytes,
completed_bytes, error) because error dicts are hand-built with exclude_none
behaviour, breaking the PullProgressEvent contract; update the error branches
(ProviderNotFoundError, ProviderValidationError, generic Exception) to include
the same keys as the success/progress payloads — always emit progress_percent,
total_bytes, completed_bytes and error (set them to null when not applicable)
and keep done/status/error strings, or alternatively call event.model_dump
without exclude_none or construct a full dict matching the success shape; ensure
JSON serialization (_json.dumps) returns the same field set for progress and
error events so the frontend receives nulls instead of missing fields.

---

Duplicate comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 806-815: This branch represents a missing resource, not a
validation error: replace the ApiValidationError raise and the
API_VALIDATION_FAILED log with a 404-style error; update the logger call to use
the not-found error code constant (e.g., API_NOT_FOUND) and raise the
appropriate not-found exception (e.g., ApiNotFoundError or
ResourceNotFoundError) instead of ApiValidationError when model is None, keeping
the same context fields (resource="model", name=model_id, provider=name) so PUT
/models/{model_id}/config returns HTTP 404 for stale/missing model IDs.

In `@src/synthorg/providers/management/local_models.py`:
- Around line 97-106: The docstring for delete_model currently misstates the
exception contract; update the Raises section for the delete_model(self,
model_name: str) method to specify that ValueError is raised only when the model
does not exist and that RuntimeError is raised for upstream/transport/delete
request failures, and ensure any related wording in the method (and similar
docstrings around lines 300-302) matches this contract so implementations (e.g.,
LocalModelManager) will not map 404s to the wrong exception type.

In `@web/src/stores/providers.ts`:
- Around line 439-443: The finally block unconditionally clears pullingModel
which can hide a newer pull; change it so pullingModel is only reset when this
finally belongs to the active request by checking that _pullAbortController ===
controller before calling set({ pullingModel: false }) (i.e., scope both
_pullAbortController = null and set({ pullingModel: false }) behind the existing
controller equality check for the _pullAbortController variable).
- Around line 397-424: The pullModel flow reports success whenever apiPullModel
resolves even if the SSE never emitted a terminal event; fix pullModel (and the
post-await logic) to require a terminal event: after the await apiPullModel(...)
check that lastEvent is non-null and that lastEvent.done === true and
lastEvent.error is falsy before showing the success toast, otherwise show an
error toast using lastEvent?.error or a generic message and return false;
reference the pullModel function, the lastEvent variable (type PullProgressEvent
| null), the PullProgressEvent.done and .error fields, and the apiPullModel
callback to locate and implement this check so TypeScript narrowing is satisfied
and CI TS2339 goes away.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee6a2719-03d8-4dbc-b35d-5ecc41b689fb

📥 Commits

Reviewing files that changed from the base of the PR and between e651b88 and 4e0396f.

📒 Files selected for processing (6)
  • docs/design/operations.md
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
  • web/src/pages/providers/ModelPullDialog.stories.tsx
  • web/src/pages/providers/ProviderModelList.tsx
  • web/src/stores/providers.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (3)
web/src/stores/providers.ts (1)

439-443: ⚠️ Potential issue | 🟠 Major

Guard pullingModel cleanup with the active controller check.

Line 443 still runs when an older pull settles. If a second pull starts before the first request finishes aborting, the first finally flips pullingModel to false while the newer stream is still active.

🔒 Scope cleanup to the request that started it
     } finally {
       if (_pullAbortController === controller) {
         _pullAbortController = null
+        set({ pullingModel: false })
       }
-      set({ pullingModel: false })
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/providers.ts` around lines 439 - 443, The finally block
currently always calls set({ pullingModel: false }) even if a newer pull
replaced the original; change it to only clear pullingModel when the abort
controller that started this request is still the active one. Concretely, in the
finally of the pull function check _pullAbortController === controller and only
then set({ pullingModel: false }) (i.e., move the set call inside the existing
if that nulls _pullAbortController) so older settled requests don't flip
pullingModel for a newer active pull.
src/synthorg/api/controllers/providers.py (2)

650-652: ⚠️ Potential issue | 🟠 Major

Emit a stable PullProgressEvent shape on every SSE frame.

PullProgressEvent defines nullable progress_percent, total_bytes, completed_bytes, and error, but Line 651 drops None values and the synthetic error payloads omit those keys entirely. That changes the contract from “nullable” to “sometimes missing,” so the web client gets undefined instead of null.

♻️ Contract-preserving serialization
                 async for event in svc.pull_model(name, data.model_name):
                     yield {
                         "event": "complete" if event.done else "progress",
-                        "data": _json.dumps(
-                            event.model_dump(exclude_none=True),
-                        ),
+                        "data": _json.dumps(event.model_dump()),
                     }
             except ProviderNotFoundError:
                 yield {
                     "event": "error",
                     "data": _json.dumps(
                         {
                             "status": f"Provider {name!r} not found",
+                            "progress_percent": None,
+                            "total_bytes": None,
+                            "completed_bytes": None,
                             "error": f"Provider {name!r} not found",
                             "done": True,
                         }
                     ),
                 }
             except ProviderValidationError as exc:
                 yield {
                     "event": "error",
                     "data": _json.dumps(
                         {
                             "status": str(exc),
+                            "progress_percent": None,
+                            "total_bytes": None,
+                            "completed_bytes": None,
                             "error": str(exc),
                             "done": True,
                         }
                     ),
                 }
@@
                 yield {
                     "event": "error",
                     "data": _json.dumps(
                         {
                             "status": f"Internal error: {type(exc).__name__}",
+                            "progress_percent": None,
+                            "total_bytes": None,
+                            "completed_bytes": None,
                             "error": f"Internal error: {type(exc).__name__}",
                             "done": True,
                         }
                     ),
                 }

Also applies to: 657-662, 669-673, 691-695

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 650 - 652, The SSE
payloads currently call event.model_dump(exclude_none=True) which strips keys
with None and breaks the PullProgressEvent contract; change the serialization to
preserve nullable keys (e.g., use event.model_dump() or build a dict that
explicitly includes progress_percent, total_bytes, completed_bytes, and error
with their None values) wherever you see "data":
_json.dumps(event.model_dump(exclude_none=True)) (including the occurrences
around the PullProgressEvent emission at the shown snippet and the similar
blocks at the other noted ranges) so the emitted JSON contains the keys with
null values instead of omitting them.

746-754: ⚠️ Potential issue | 🟠 Major

Surface delete transport/upstream failures as 5xxs.

RuntimeError here is not a client validation problem. Mapping it to ApiValidationError turns provider-side delete failures into HTTP 4xx responses, which breaks retry/error handling for callers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 746 - 754, The
RuntimeError in the provider delete path is a server-side failure and should not
be mapped to ApiValidationError (a 4xx); change the except block that currently
catches RuntimeError in controllers/providers.py to either re-raise the original
RuntimeError or raise an ApiServerError (or equivalent 5xx exception) instead of
ApiValidationError; keep the existing logger call (adjust level to error if
appropriate) and include str(exc) in the log, and ensure the raised exception is
ApiServerError(...) from exc (or simply raise) so callers get a 5xx response
rather than a 4xx.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 811-824: The missing-model branch is an internal invariant failure
(service should have raised earlier) so don't treat it as a client validation
error; update the branch that currently logs API_VALIDATION_FAILED and raises
ApiValidationError to instead log as a provider/internal error (use the
controller's provider-error/5xx log level/constant instead of
API_VALIDATION_FAILED) and raise an internal server/provider error so the
request takes the controller's 5xx/provider-error path (replace raising
ApiValidationError with the appropriate internal/provider error type used by the
controllers); locate the code around the model lookup (variables model,
updated.models, model_id) in controllers/providers.py to apply this change.

In `@src/synthorg/providers/management/local_models.py`:
- Around line 97-107: The docstring for delete_model in local_models.py is
inaccurate: OllamaModelManager.delete_model actually raises ValueError for 404
(model not found) and RuntimeError for other HTTP errors; update the
delete_model docstring to state these exact exception semantics (ValueError when
model does not exist, RuntimeError on other delete failures) or alternatively
change OllamaModelManager.delete_model to raise the same exception type for all
failures—refer to the delete_model method and OllamaModelManager.delete_model to
locate and apply the change.
- Around line 293-347: Update the delete_model method docstring to accurately
reflect its error behavior: state that ValueError is raised when the model is
not found (HTTP 404), and RuntimeError is raised for other HTTP client/server
errors (status >= 400) and transport/httpx errors; mention these conditions by
name (delete_model, httpx.HTTPError) so reviewers can verify the docstring
matches the implementation.

In `@web/src/stores/providers.ts`:
- Around line 421-429: The providers list cache isn't refreshed after a model
pull/delete (the code around useToastStore.getState().add and the
selectedProvider check), so list-level UI (provider.models counts/badges) stays
stale; after a successful mutation return, call the store method that reloads
the providers array (e.g., get().fetchProviders()) to update the cached
providers list, and do the same fix for the similar block around lines 457-465
so both pull and delete refresh the top-level providers cache as well as the
detail view (keep the existing selectedProvider/fetchProviderDetail logic).

---

Duplicate comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 650-652: The SSE payloads currently call
event.model_dump(exclude_none=True) which strips keys with None and breaks the
PullProgressEvent contract; change the serialization to preserve nullable keys
(e.g., use event.model_dump() or build a dict that explicitly includes
progress_percent, total_bytes, completed_bytes, and error with their None
values) wherever you see "data":
_json.dumps(event.model_dump(exclude_none=True)) (including the occurrences
around the PullProgressEvent emission at the shown snippet and the similar
blocks at the other noted ranges) so the emitted JSON contains the keys with
null values instead of omitting them.
- Around line 746-754: The RuntimeError in the provider delete path is a
server-side failure and should not be mapped to ApiValidationError (a 4xx);
change the except block that currently catches RuntimeError in
controllers/providers.py to either re-raise the original RuntimeError or raise
an ApiServerError (or equivalent 5xx exception) instead of ApiValidationError;
keep the existing logger call (adjust level to error if appropriate) and include
str(exc) in the log, and ensure the raised exception is ApiServerError(...) from
exc (or simply raise) so callers get a 5xx response rather than a 4xx.

In `@web/src/stores/providers.ts`:
- Around line 439-443: The finally block currently always calls set({
pullingModel: false }) even if a newer pull replaced the original; change it to
only clear pullingModel when the abort controller that started this request is
still the active one. Concretely, in the finally of the pull function check
_pullAbortController === controller and only then set({ pullingModel: false })
(i.e., move the set call inside the existing if that nulls _pullAbortController)
so older settled requests don't flip pullingModel for a newer active pull.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b79eb2ed-c0c8-47b4-a704-e87a4c8e2307

📥 Commits

Reviewing files that changed from the base of the PR and between 4e0396f and a6a7682.

📒 Files selected for processing (3)
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
  • web/src/stores/providers.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (8)
web/src/**/*.{tsx,ts}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{tsx,ts}: ALWAYS reuse existing components from web/src/components/ui/ before creating new ones (StatusBadge, MetricCard, Sparkline, SectionCard, AgentCard, DeptHealthBar, ProgressGauge, StatPill, Avatar, Button, Toast, Skeleton, EmptyState, ErrorBoundary, ConfirmDialog, CommandPalette, InlineEdit, AnimatedPresence, StaggerGroup, Drawer, InputField, SelectField, SliderField, ToggleField, TaskStatusIndicator, PriorityBadge, ProviderHealthBadge, TokenUsageBar, CodeMirrorEditor, SegmentedControl, ThemeToggle, LiveRegion, MobileUnsupportedOverlay, LazyCodeMirrorEditor, TagInput, MetadataGrid, ProjectStatusBadge, ContentTypeBadge)
Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-accent)) for colors. NEVER hardcode hex values in .tsx/.ts files.
Use font-sans or font-mono for typography (maps to Geist tokens). NEVER set fontFamily directly.
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing. NEVER hardcode pixel values for layout spacing.
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders. NEVER hardcode values.
Import cn from @/lib/utils for conditional class merging in new components.
Do NOT recreate status dots inline -- use <StatusBadge> component.
Do NOT build card-with-header layouts from scratch -- use <SectionCard> component.
Do NOT create metric displays with text-metric font-bold -- use <MetricCard> component.
Do NOT render initials circles manually -- use <Avatar> component.
Do NOT create complex (>8 line) JSX inside .map() -- extract to a shared component.
TypeScript strict mode must pass type checking (run npm --prefix web run type-check); all type errors must be resolved before proceeding.
Do NOT hardcode Framer Motion transition durations -- use @/lib/motion presets.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{tsx,ts,css}

📄 CodeRabbit inference engine (web/CLAUDE.md)

Do NOT use rgba() with hardcoded values -- use design token variables.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{tsx,ts,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

ESLint must enforce zero warnings (run npm --prefix web run lint); all warnings must be fixed before proceeding.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

web/src/**/*.{ts,tsx}: Reuse existing components from web/src/components/ui/ before creating new ones. Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions—use design tokens and @/lib/motion presets.
Web dashboard component updates must follow design token rules and reuse existing components. A PostToolUse hook enforces these rules on every Edit/Write to web/src/.
Web dashboard is built with React 19, TypeScript 6.0+, and design system components from shadcn/ui and Radix UI. Use Tailwind CSS 4 for styling with design tokens.

Files:

  • web/src/stores/providers.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations—Python 3.14 has PEP 649 native lazy annotations.
Use PEP 758 except syntax: use except A, B: (no parentheses)—ruff enforces this on Python 3.14.
All public functions must have type hints; mypy strict mode is required.
Docstrings must use Google style and are required on all public classes and functions—enforced by ruff D rules.
Create new objects instead of mutating existing ones—use immutability as the default pattern.
For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction and MappingProxyType wrapping for read-only enforcement.
Use model_copy(update=...) for runtime state that evolves; separate mutable-via-copy models from frozen Pydantic models for config/identity. Never mix static config fields with mutable runtime fields in one model.
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time.
Use @computed_field for derived values instead of storing and validating redundant fields (e.g. TokenUsage.total_tokens).
Use NotBlankStr from core.types for all identifier/name fields—including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants—instead of manual whitespace validators.
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls). Prefer structured concurrency over bare create_task. Existing code is being migrated incrementally.
Function line length limit: 88 characters; enforced by ruff.
Functions must be less than 50 lines; files must be less than 800 lines.
Handle errors explicitly; never silently swallow exceptions.
Validate at system boundaries (user input, external APIs, config files).
Logger variable name must always be logger (not _logger, not log).
Always use structured logging with kwargs: `l...

Files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
src/synthorg/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/**/*.py: Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(__name__).
Never use import logging / logging.getLogger() / print() in application code. Exception: observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py may use stdlib logging and print(..., file=sys.stderr) for handler construction and bootstrap.
Always use event name constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.
Never use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: example-provider, example-large-001, example-medium-001, example-small-001. Vendor names may only appear in: (1) Operations design page, (2) .claude/ files, (3) third-party import paths, (4) provider presets. Tests must use test-provider, test-small-001, etc.

Files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
src/synthorg/providers/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/providers/**/*.py: All provider calls go through BaseCompletionProvider which applies retry and rate limiting automatically. Never implement retry logic in driver subclasses or calling code.
RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig. Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry.
Rate limiter respects RateLimitError.retry_after from providers—automatically pauses future requests.

Files:

  • src/synthorg/providers/management/local_models.py
src/**/*.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
🧠 Learnings (35)
📓 Common learnings
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.{ts,tsx} : Use Zustand stores for state management in the web dashboard; each domain has its own store module (auth, WebSocket, toast, analytics, setup, company, agents, budget, tasks, settings, providers, theme, per-domain stores)

Applied to files:

  • web/src/stores/providers.ts
📚 Learning: 2026-03-27T12:44:29.466Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T12:44:29.466Z
Learning: Applies to web/src/stores/**/*.{ts,tsx} : Use Zustand stores in web dashboard for state management (auth, WebSocket, toast, analytics, domain shells)

Applied to files:

  • web/src/stores/providers.ts
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Handle errors explicitly, never silently swallow. Validate at system boundaries (user input, external APIs, config files).

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-01T17:49:14.133Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T17:49:14.133Z
Learning: Applies to src/synthorg/{providers,engine}/**/*.py : Retryable errors are `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately; `RetryExhaustedError` signals all retries failed

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, and key function entry/exit

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions.

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-17T06:43:14.114Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:43:14.114Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions. Pure data models, enums, and re-exports do NOT need logging.

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-31T20:07:03.035Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T20:07:03.035Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising; all state transitions must log at INFO; DEBUG for object creation, internal flow, entry/exit of key functions

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising.

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (using model_copy(update=...)) for runtime state that evolves. Never mix static config fields with mutable runtime fields in one model.

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors: `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately.

Applied to files:

  • src/synthorg/providers/management/local_models.py
  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/api/**/*.py : API package (api/): Litestar REST + WebSocket with controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors, AppState hot-reload slots, service auto-wiring (Phase 1 at construction, Phase 2 on startup), lifecycle helpers

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-31T16:09:24.320Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T16:09:24.320Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from `synthorg.observability.events.<domain>` modules (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly and use in structured logging

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`); import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T06:24:56.341Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T06:24:56.341Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from the domain-specific module under `synthorg.observability.events` in logging calls

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly rather than using string literals

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from synthorg.observability.events domain-specific modules (e.g., PROVIDER_CALL_START from events.provider). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-19T11:33:01.580Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T11:33:01.580Z
Learning: Applies to src/synthorg/**/*.py : Use event constants from `synthorg.observability.events.<domain>` (e.g., `API_REQUEST_STARTED` from `events.api`); import directly and log with structured kwargs: `logger.info(EVENT, key=value)`, never interpolated strings

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-18T21:23:23.586Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-18T21:23:23.586Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from the domain-specific module under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly from synthorg.observability.events.<domain>.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget, etc.). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-03T13:34:37.835Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T13:34:37.835Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : Use event name constants from domain-specific modules under `ai_company.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (via `model_copy(update=...)`) for runtime state that evolves

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; separate mutable-via-copy models (using `model_copy(update=...)`) for runtime state

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-31T21:07:37.469Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.469Z
Learning: Applies to src/synthorg/providers/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T19:13:36.562Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:36.562Z
Learning: Applies to src/synthorg/providers/**/*.py : RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-03T13:34:37.835Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T13:34:37.835Z
Learning: Applies to src/synthorg/providers/**/*.py : RetryConfig and RateLimiterConfig are set per-provider in `ProviderConfig`. Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T19:13:34.746Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:34.746Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry. RetryExhaustedError signals that all retries failed — the engine layer catches this to trigger fallback chains.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/api/**/*.py : REST API: Litestar framework, controllers with guards, channels for WebSocket, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint. RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation).

Applied to files:

  • src/synthorg/api/controllers/providers.py
🔇 Additional comments (7)
src/synthorg/providers/management/local_models.py (7)

1-33: LGTM!

Module setup follows conventions: proper observability imports, correct logger instantiation, and well-named constants for timeouts and HTTP status codes. The TYPE_CHECKING guard for AsyncIterator is appropriate with PEP 649's lazy annotation evaluation in Python 3.14.


35-74: LGTM!

Well-designed immutable Pydantic model with proper constraints. The cross-field validator correctly enforces that error events are terminal and completed_bytes never exceeds total_bytes. Good use of NotBlankStr for string fields and allow_inf_nan=False in ConfigDict.


110-128: LGTM!

Constructor properly validates base_url is non-empty and normalizes the trailing slash. The optional client injection enables testability.


130-185: LGTM!

The _parse_pull_line method correctly handles error events, computes progress percentage with proper bounds (min(..., 100.0)), and logs at appropriate levels. The distinction between using float values for progress calculation while only storing integer byte counts is intentional and correct.


187-224: LGTM!

Robust stream consumption: malformed JSON lines are logged and skipped without breaking the stream, early return on terminal events, and a fallback error event if the stream ends unexpectedly. Good defensive programming.


226-291: LGTM!

The pull_model method properly handles client lifecycle, logs state transitions, and converts all failure modes (non-200, transport errors) into terminal PullProgressEvent instances with appropriate logging. The finally block ensures cleanup of owned clients.


350-366: LGTM!

Clean factory function with proper type hints and docstring. The None return for unsupported presets is correctly handled by _resolve_local_manager in the service layer, which raises ProviderValidationError in that case.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
web/src/stores/providers.ts (1)

404-410: ⚠️ Potential issue | 🟠 Major

Ignore progress callbacks from superseded pull requests.

The finally guard fixed stale cleanup, but this callback can still flush a buffered SSE chunk after abort() has been issued. Because Lines 407-409 always write pullProgress, an aborted/superseded request can overwrite the newer pull's state or repopulate state after cancelPull() cleared it.

🔧 Tie progress updates to the controller that started this pull
       await apiPullModel(
         name,
         modelName,
         (event) => {
+          if (_pullAbortController !== controller) return
           if (event.error) lastError = event.error
           set({ pullProgress: event })
         },
         controller.signal,
       )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/providers.ts` around lines 404 - 410, Tie progress updates to
the controller that started the pull: when calling apiPullModel, capture the
controller used for this request (e.g., const myController = controller or
similar) and inside the progress callback first verify this is still the active
controller (compare myController to the store's currentPullController or check
myController.signal.aborted); if it’s not the active controller or is aborted,
ignore the event and do not call set({ pullProgress: event }); this prevents
superseded/aborted pulls from overwriting state after cancelPull()/abort() was
called.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 664-668: The SSE generator currently maps every terminal
PullProgressEvent to "complete"; change the logic around svc.pull_model
iteration so that when an event is terminal (event.done) and carries an error
(event.error is truthy) you emit "event": "error" instead of "complete"
(otherwise emit "complete" when done and no error, and "progress" while not
done). Update the yield in the async for over svc.pull_model to inspect
event.done and event.error and set the event type accordingly while preserving
the existing payload construction via _json.dumps(event.model_dump()).
- Around line 321-327: The controller currently strips preset_name via safe_data
= data.model_copy(update={"preset_name": None}) before calling
provider_management.create_provider, which silently creates providers missing
preset metadata used by _resolve_local_manager; instead validate the incoming
request at this boundary: if data.preset_name is not None, return a 4xx error
(HTTPException) telling callers to use POST /providers/from-preset, or
alternatively preserve preset_name (remove the model_copy update) so that
create_provider receives it; update the branch around safe_data/data.model_copy
and create_provider to implement one of these two behaviors and ensure
_resolve_local_manager will have the preset metadata it expects.

In `@src/synthorg/providers/management/local_models.py`:
- Around line 197-208: The decoded JSON chunk from json.loads may be a scalar or
list; before calling _parse_pull_line, validate that `data` is a JSON object
(e.g. isinstance(data, dict)) and if not, call `logger.warning` with the same
`PROVIDER_MODEL_PULL_FAILED` message including `provider="ollama"`,
`model=model_name`, and `error` describing a non-object payload (include a
snippet of `line[:200]!r`), then continue the loop; this ensures non-object
payloads are treated as provider pull failures rather than causing an
AttributeError inside `_parse_pull_line`.

---

Duplicate comments:
In `@web/src/stores/providers.ts`:
- Around line 404-410: Tie progress updates to the controller that started the
pull: when calling apiPullModel, capture the controller used for this request
(e.g., const myController = controller or similar) and inside the progress
callback first verify this is still the active controller (compare myController
to the store's currentPullController or check myController.signal.aborted); if
it’s not the active controller or is aborted, ignore the event and do not call
set({ pullProgress: event }); this prevents superseded/aborted pulls from
overwriting state after cancelPull()/abort() was called.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 99c6ba6d-e00a-40a3-b5bb-d4d25f75a203

📥 Commits

Reviewing files that changed from the base of the PR and between a6a7682 and 2300ba8.

📒 Files selected for processing (3)
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
  • web/src/stores/providers.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Build Sandbox
  • GitHub Check: Build Backend
  • GitHub Check: Build Web
  • GitHub Check: Dashboard Test
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Dependency Review
  • GitHub Check: Analyze (python)
🧰 Additional context used
📓 Path-based instructions (8)
web/src/**/*.{tsx,ts}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{tsx,ts}: ALWAYS reuse existing components from web/src/components/ui/ before creating new ones (StatusBadge, MetricCard, Sparkline, SectionCard, AgentCard, DeptHealthBar, ProgressGauge, StatPill, Avatar, Button, Toast, Skeleton, EmptyState, ErrorBoundary, ConfirmDialog, CommandPalette, InlineEdit, AnimatedPresence, StaggerGroup, Drawer, InputField, SelectField, SliderField, ToggleField, TaskStatusIndicator, PriorityBadge, ProviderHealthBadge, TokenUsageBar, CodeMirrorEditor, SegmentedControl, ThemeToggle, LiveRegion, MobileUnsupportedOverlay, LazyCodeMirrorEditor, TagInput, MetadataGrid, ProjectStatusBadge, ContentTypeBadge)
Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-accent)) for colors. NEVER hardcode hex values in .tsx/.ts files.
Use font-sans or font-mono for typography (maps to Geist tokens). NEVER set fontFamily directly.
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing. NEVER hardcode pixel values for layout spacing.
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders. NEVER hardcode values.
Import cn from @/lib/utils for conditional class merging in new components.
Do NOT recreate status dots inline -- use <StatusBadge> component.
Do NOT build card-with-header layouts from scratch -- use <SectionCard> component.
Do NOT create metric displays with text-metric font-bold -- use <MetricCard> component.
Do NOT render initials circles manually -- use <Avatar> component.
Do NOT create complex (>8 line) JSX inside .map() -- extract to a shared component.
TypeScript strict mode must pass type checking (run npm --prefix web run type-check); all type errors must be resolved before proceeding.
Do NOT hardcode Framer Motion transition durations -- use @/lib/motion presets.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{tsx,ts,css}

📄 CodeRabbit inference engine (web/CLAUDE.md)

Do NOT use rgba() with hardcoded values -- use design token variables.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{tsx,ts,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

ESLint must enforce zero warnings (run npm --prefix web run lint); all warnings must be fixed before proceeding.

Files:

  • web/src/stores/providers.ts
web/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

web/src/**/*.{ts,tsx}: Reuse existing components from web/src/components/ui/ before creating new ones. Never hardcode hex colors, font-family, pixel spacing, or Framer Motion transitions—use design tokens and @/lib/motion presets.
Web dashboard component updates must follow design token rules and reuse existing components. A PostToolUse hook enforces these rules on every Edit/Write to web/src/.
Web dashboard is built with React 19, TypeScript 6.0+, and design system components from shadcn/ui and Radix UI. Use Tailwind CSS 4 for styling with design tokens.

Files:

  • web/src/stores/providers.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations—Python 3.14 has PEP 649 native lazy annotations.
Use PEP 758 except syntax: use except A, B: (no parentheses)—ruff enforces this on Python 3.14.
All public functions must have type hints; mypy strict mode is required.
Docstrings must use Google style and are required on all public classes and functions—enforced by ruff D rules.
Create new objects instead of mutating existing ones—use immutability as the default pattern.
For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction and MappingProxyType wrapping for read-only enforcement.
Use model_copy(update=...) for runtime state that evolves; separate mutable-via-copy models from frozen Pydantic models for config/identity. Never mix static config fields with mutable runtime fields in one model.
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). Use allow_inf_nan=False in all ConfigDict declarations to reject NaN/Inf in numeric fields at validation time.
Use @computed_field for derived values instead of storing and validating redundant fields (e.g. TokenUsage.total_tokens).
Use NotBlankStr from core.types for all identifier/name fields—including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants—instead of manual whitespace validators.
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls). Prefer structured concurrency over bare create_task. Existing code is being migrated incrementally.
Function line length limit: 88 characters; enforced by ruff.
Functions must be less than 50 lines; files must be less than 800 lines.
Handle errors explicitly; never silently swallow exceptions.
Validate at system boundaries (user input, external APIs, config files).
Logger variable name must always be logger (not _logger, not log).
Always use structured logging with kwargs: `l...

Files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
src/synthorg/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/**/*.py: Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(__name__).
Never use import logging / logging.getLogger() / print() in application code. Exception: observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py may use stdlib logging and print(..., file=sys.stderr) for handler construction and bootstrap.
Always use event name constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.
Never use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: example-provider, example-large-001, example-medium-001, example-small-001. Vendor names may only appear in: (1) Operations design page, (2) .claude/ files, (3) third-party import paths, (4) provider presets. Tests must use test-provider, test-small-001, etc.

Files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
src/**/*.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
src/synthorg/providers/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/providers/**/*.py: All provider calls go through BaseCompletionProvider which applies retry and rate limiting automatically. Never implement retry logic in driver subclasses or calling code.
RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig. Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry.
Rate limiter respects RateLimitError.retry_after from providers—automatically pauses future requests.

Files:

  • src/synthorg/providers/management/local_models.py
🧠 Learnings (35)
📓 Common learnings
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.
📚 Learning: 2026-03-30T10:41:40.176Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-30T10:41:40.176Z
Learning: Applies to web/src/**/*.{ts,tsx} : Use Zustand stores for state management in the web dashboard; each domain has its own store module (auth, WebSocket, toast, analytics, setup, company, agents, budget, tasks, settings, providers, theme, per-domain stores)

Applied to files:

  • web/src/stores/providers.ts
📚 Learning: 2026-03-27T12:44:29.466Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T12:44:29.466Z
Learning: Applies to web/src/stores/**/*.{ts,tsx} : Use Zustand stores in web dashboard for state management (auth, WebSocket, toast, analytics, domain shells)

Applied to files:

  • web/src/stores/providers.ts
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-19T07:12:14.508Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T07:12:14.508Z
Learning: Applies to src/synthorg/api/**/*.py : API package (api/): Litestar REST + WebSocket with controllers, guards, channels, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint, provider management endpoint (CRUD + test + presets), backup endpoint, RFC 9457 structured errors, AppState hot-reload slots, service auto-wiring (Phase 1 at construction, Phase 2 on startup), lifecycle helpers

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-31T16:09:24.320Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T16:09:24.320Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from `synthorg.observability.events.<domain>` modules (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly and use in structured logging

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`); import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T06:24:56.341Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T06:24:56.341Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from the domain-specific module under `synthorg.observability.events` in logging calls

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`); import directly rather than using string literals

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use event name constants from synthorg.observability.events domain-specific modules (e.g., PROVIDER_CALL_START from events.provider). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-19T11:33:01.580Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-19T11:33:01.580Z
Learning: Applies to src/synthorg/**/*.py : Use event constants from `synthorg.observability.events.<domain>` (e.g., `API_REQUEST_STARTED` from `events.api`); import directly and log with structured kwargs: `logger.info(EVENT, key=value)`, never interpolated strings

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-18T21:23:23.586Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-18T21:23:23.586Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from the domain-specific module under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly from synthorg.observability.events.<domain>.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to src/synthorg/**/*.py : Event names: always use constants from domain-specific modules under synthorg.observability.events (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget, etc.). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-03T13:34:37.835Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T13:34:37.835Z
Learning: Applies to src/synthorg/**/*.py : Always use event name constants from domain-specific modules under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`). Import directly: `from synthorg.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-14T16:18:57.267Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-14T16:18:57.267Z
Learning: Applies to src/ai_company/!(observability)/**/*.py : Use event name constants from domain-specific modules under `ai_company.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (using model_copy(update=...)) for runtime state that evolves. Never mix static config fields with mutable runtime fields in one model.

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-20T21:44:04.528Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T21:44:04.528Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (via `model_copy(update=...)`) for runtime state that evolves

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T18:38:44.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:38:44.202Z
Learning: Applies to src/synthorg/**/*.py : Use frozen Pydantic models for config/identity; separate mutable-via-copy models (using `model_copy(update=...)`) for runtime state

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Handle errors explicitly, never silently swallow. Validate at system boundaries (user input, external APIs, config files).

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-31T21:07:37.469Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.469Z
Learning: Applies to src/synthorg/providers/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`. Retryable errors: `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately.

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-16T19:13:36.562Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:36.562Z
Learning: Applies to src/synthorg/providers/**/*.py : RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-03T13:34:37.835Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T13:34:37.835Z
Learning: Applies to src/synthorg/providers/**/*.py : RetryConfig and RateLimiterConfig are set per-provider in `ProviderConfig`. Retryable errors (`is_retryable=True`): `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`. Non-retryable errors raise immediately without retry.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T19:13:34.746Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:34.746Z
Learning: Applies to src/synthorg/providers/**/*.py : Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry. RetryExhaustedError signals that all retries failed — the engine layer catches this to trigger fallback chains.

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-04-01T17:49:14.133Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T17:49:14.133Z
Learning: Applies to src/synthorg/{providers,engine}/**/*.py : Retryable errors are `RateLimitError`, `ProviderTimeoutError`, `ProviderConnectionError`, `ProviderInternalError`; non-retryable errors raise immediately; `RetryExhaustedError` signals all retries failed

Applied to files:

  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/api/**/*.py : REST API: Litestar framework, controllers with guards, channels for WebSocket, JWT + API key + WS ticket auth, approval gate integration, coordination endpoint, collaboration endpoint, settings endpoint. RFC 9457 structured errors (ErrorCategory, ErrorCode, ErrorDetail, ProblemDetail, CATEGORY_TITLES, category_title, category_type_uri, content negotiation).

Applied to files:

  • src/synthorg/api/controllers/providers.py
📚 Learning: 2026-03-16T07:22:28.134Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T07:22:28.134Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, and key function entry/exit

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions.

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-17T06:43:14.114Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T06:43:14.114Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising. All state transitions must log at INFO. DEBUG for object creation, internal flow, entry/exit of key functions. Pure data models, enums, and re-exports do NOT need logging.

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-31T20:07:03.035Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T20:07:03.035Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising; all state transitions must log at INFO; DEBUG for object creation, internal flow, entry/exit of key functions

Applied to files:

  • src/synthorg/providers/management/local_models.py
📚 Learning: 2026-03-15T16:55:07.730Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T16:55:07.730Z
Learning: Applies to src/synthorg/**/*.py : All error paths must log at WARNING or ERROR with context before raising.

Applied to files:

  • src/synthorg/providers/management/local_models.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/pages/providers/ProviderCard.stories.tsx (1)

55-60: 🧹 Nitpick | 🔵 Trivial

Consider setting capability flags for the LocalProvider story.

The LocalProvider story represents an Ollama provider (litellm_provider: 'ollama') but inherits supports_model_pull: false, supports_model_delete: false, and supports_model_config: false from baseProvider. Per the PR objectives, Ollama should support these local model management features. Consider overriding these flags to true to accurately demonstrate Ollama's capabilities in the story.

📖 Suggested story update
 export const LocalProvider: Story = {
   args: {
-    provider: { ...baseProvider, name: 'ollama-local', litellm_provider: 'ollama', auth_type: 'none', base_url: 'http://localhost:11434', models: [], has_api_key: false },
+    provider: { ...baseProvider, name: 'ollama-local', litellm_provider: 'ollama', auth_type: 'none', base_url: 'http://localhost:11434', models: [], has_api_key: false, supports_model_pull: true, supports_model_delete: true, supports_model_config: true },
     health: null,
   },
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ProviderCard.stories.tsx` around lines 55 - 60, The
LocalProvider story currently uses baseProvider values that leave
supports_model_pull, supports_model_delete, and supports_model_config false, but
since it models an Ollama provider (litellm_provider: 'ollama') these capability
flags should be true; update the LocalProvider.args.provider to explicitly set
supports_model_pull: true, supports_model_delete: true, and
supports_model_config: true so the story accurately reflects Ollama's local
model management capabilities (locate the LocalProvider Story and the provider
object in ProviderCard.stories.tsx to apply the overrides).
tests/unit/providers/test_presets.py (1)

76-85: ⚠️ Potential issue | 🔴 Critical

Replace real vendor names with vendor-agnostic test names.

The test file uses real provider names ("anthropic", "openai", "gemini", etc.) in the _CLOUD_PRESETS and _SELF_HOSTED_PRESETS tuples. Per coding guidelines, tests must use vendor-agnostic names only (e.g., "test-provider", "test-large-001", etc.). This applies to all test code, even when testing actual provider preset integration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/providers/test_presets.py` around lines 76 - 85, Update the test
fixtures _CLOUD_PRESETS and _SELF_HOSTED_PRESETS in test_presets.py to use
vendor-agnostic names instead of real vendor names: replace entries in the
_CLOUD_PRESETS tuple
("anthropic","openai","gemini","mistral","groq","deepseek","openrouter") and the
_SELF_HOSTED_PRESETS tuple ("ollama","lm-studio","vllm","azure") with generic
test values (e.g., "test-provider-1", "test-provider-2", "test-model-1", etc.)
so the tuples remain the same shape but contain no real vendor identifiers.
♻️ Duplicate comments (2)
web/src/pages/providers/ModelConfigDrawer.tsx (1)

34-52: ⚠️ Potential issue | 🟠 Major

Don't collapse invalid numeric input to null.

The parsers still turn any non-empty invalid value into null, so a typo clears the saved param instead of failing validation. That's especially confusing for num_threads, because the "auto" placeholder implies a supported value even though typing "auto" currently clears the field. Keep blank input as null, but surface per-field errors and block save when a value is malformed or outside the backend's allowed range. Based on learnings, "Validate at system boundaries (user input, external APIs, config files)."

Also applies to: 60-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ModelConfigDrawer.tsx` around lines 34 - 52, The
current handleSave implementation uses parseIntStrict and parseFloatStrict which
convert any non-empty invalid string into null, unintentionally clearing values
(e.g., num_threads with "auto"); change this so empty strings still map to null
but malformed or out-of-range inputs do not become null—instead set per-field
validation errors and prevent saving. Update parseIntStrict/parseFloatStrict (or
add validateInt/validateFloat) to return a discriminated result (e.g.,
{value:number|null, error?:string}) or throw a validation error, then in
handleSave build newParams only from valid parsed values and if any field
(num_ctx, num_gpu_layers, num_threads, num_batch, repeat_penalty) has a
parse/range error set corresponding UI error state and abort the save; ensure
LocalModelParams remains correctly typed and that input placeholder values like
"auto" are treated as invalid text (not silently nulled) so the UI can surface
the error.
src/synthorg/api/controllers/providers.py (1)

321-327: ⚠️ Potential issue | 🟠 Major

Don't silently drop preset_name on POST /providers.

This makes an invalid request look successful while creating a provider that no longer carries the preset metadata _resolve_local_manager() uses in src/synthorg/providers/management/service.py:597-639 for pull/delete/config. If /providers is not allowed to accept preset_name, reject it here and point callers at /providers/from-preset; otherwise preserve the field.

🛡️ Fail fast instead of creating a half-configured provider
-        # Strip preset_name from external requests -- only
-        # create_from_preset may set it (capability flag injection).
-        safe_data = data.model_copy(update={"preset_name": None})
+        if data.preset_name is not None:
+            logger.warning(
+                API_VALIDATION_FAILED,
+                resource="provider",
+                name=data.name,
+                error="preset_name is only accepted by /providers/from-preset",
+            )
+            raise ApiValidationError(
+                "Use /providers/from-preset when supplying preset_name",
+            )
         try:
-            config = await app_state.provider_management.create_provider(
-                safe_data,
-            )
+            config = await app_state.provider_management.create_provider(data)

As per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 321 - 327, The
handler currently strips preset_name from incoming POST /providers requests
(using data.model_copy(...) before calling
app_state.provider_management.create_provider), which lets invalid requests
succeed and breaks downstream logic in _resolve_local_manager
(src/synthorg/providers/management/service.py). Change the endpoint to validate
at the boundary: if data.preset_name is present, return a 400/validation error
that instructs callers to use /providers/from-preset; otherwise pass the
original data through (do not drop preset_name) to
app_state.provider_management.create_provider. Ensure the error message
references the correct caller action and keep handling centered in the same
request handler that currently calls create_provider.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 755-763: The logger in delete_model currently uses the
pull-specific event constant API_SSE_PULL_MODEL_FAILED; update the
logger.exception call in delete_model to use a delete-specific constant (e.g.,
API_SSE_DELETE_MODEL_FAILED) or a generic model failure constant (e.g.,
API_SSE_MODEL_OPERATION_FAILED), and if that constant does not exist add it to
the event constants module; ensure the same replacement is made for any related
error-raising paths in delete_model so the event name semantically matches the
delete operation.
- Around line 802-819: The current handler relies on string-matching "not found"
on ProviderValidationError (variable exc_msg) which is brittle; update the
ProviderValidationError hierarchy to include an explicit subclass (e.g.,
ProviderNotFoundError) or an error code attribute (e.g., .code == "NOT_FOUND"),
then change the except block in the controller to test that explicit signal (use
isinstance(exc, ProviderNotFoundError) or check exc.code) instead of `"not
found" in exc_msg`, and keep the existing logging calls (API_RESOURCE_NOT_FOUND
with resource="model", name=model_id, provider=name and raising NotFoundError)
for the not-found case, otherwise log API_VALIDATION_FAILED and raise
ApiValidationError as before.

In `@src/synthorg/config/schema.py`:
- Around line 45-58: Docstrings for the public config classes are out of date:
update the Google-style class docstrings for LocalModelParams and ProviderConfig
to list the new fields (for LocalModelParams mention num_ctx, num_gpu_layers,
num_threads, num_batch, repeat_penalty and their brief meanings/constraints; for
ProviderConfig mention the newly exposed preset_name and its purpose) so
generated docs/IDE hovers reflect the current public API; edit the docstring
blocks at the top of the LocalModelParams class and the ProviderConfig class to
include one-line descriptions for each added field following the existing
Google-style format.

In `@src/synthorg/providers/management/service.py`:
- Around line 715-726: The capability check done by _resolve_local_manager
before acquiring self._lock can race with concurrent provider updates; move the
capability validation into the locked section (the block that calls
self._config_resolver.get_provider_configs()) or, after re-reading config under
the lock in update_model_config, re-validate that the provider still has the
required fields (preset_name/base_url) and capability before proceeding.
Specifically, adjust the flow around _resolve_local_manager and the locked read
of get_provider_configs so that either the capability check is performed while
holding self._lock or add explicit re-validation of preset_name/base_url and the
requested capability after fetching config and before raising
ProviderNotFoundError or continuing.

In `@tests/unit/providers/management/test_local_models.py`:
- Around line 28-34: Add a test that verifies the terminal-error invariant for
PullProgressEvent: when constructing PullProgressEvent with an error (e.g.,
PullProgressEvent(status="pulling", error="some error")) but without done=True,
the model should reject it; update tests in test_defaults (or add a new test) to
assert that instantiating PullProgressEvent(status="pulling", error="msg")
raises the expected validation exception (e.g., ValueError/ValidationError)
rather than silently allowing a non-terminal error state.

In `@web/src/api/endpoints/providers.ts`:
- Around line 133-140: The code in processSseLines currently throws an Error
when state.currentEvent === 'error' or parsed.error is present before calling
onProgress, which prevents the caller from receiving the terminal error frame;
change the control flow in processSseLines so that when a terminal error is
detected (state.currentEvent === 'error' or parsed.error), you first call
onProgress(parsed) to deliver the final event (so pullProgress is persisted),
then clear/reset state.currentEvent and only after notifying the caller throw
the Error (using parsed.error || parsed.status || 'Pull failed') to preserve
behavior while ensuring the callback sees the terminal payload.
- Around line 178-184: This fetch error branch currently only clears
localStorage keys ('auth_token', 'auth_token_expires_at',
'auth_must_change_password') which leaves the app auth store and redirect state
inconsistent; replace that local-only cleanup with the shared 401 logout flow
used by apiClient interceptors (i.e., call the central logout/unauthorized
handler used elsewhere so the auth store is cleared and the redirect path/router
is updated) instead of directly removing localStorage keys in this response
handling block.

In `@web/src/pages/ProviderDetailPage.tsx`:
- Around line 91-93: The onRefresh handler calls
useProvidersStore.getState().discoverModels(decodedName) but doesn't thread the
provider's preset_name; update the call in the onRefresh callback to pass the
preset hint, e.g. call useProvidersStore.getState().discoverModels(decodedName,
provider.preset_name ?? undefined), so discoverModels(presetHint) receives
provider.preset_name for backward-compatible refresh behavior; locate the
onRefresh block in ProviderDetailPage.tsx and modify the discoverModels
invocation accordingly.

In `@web/src/pages/providers/ModelConfigDrawer.stories.tsx`:
- Around line 52-58: The EmptyParams story duplicates Default because baseModel
already has local_params: null; either remove the redundant EmptyParams export
or change its args to demonstrate a different state (e.g., provide a model with
non-null local_params or other differing fields) so it meaningfully differs from
Default; update the EmptyParams export or delete it accordingly, referencing the
EmptyParams and Default Story objects and the baseModel/local_params values.

In `@web/src/pages/providers/ModelPullDialog.stories.tsx`:
- Around line 62-70: The story currently sets useProvidersStore.pullingModel
true which simulates an in-flight/cancellable pull; change the fixture to
reflect a terminal failed pull by setting pullingModel: false and leaving
pullProgress as an error (e.g. pullProgress.status = 'Error', progress_percent =
0, done = true, error message present) so the UI renders the post-failure
retry/error state the real pullModel() finally produces; update the object in
the ModelPullDialog.stories.tsx snippet that sets
useProvidersStore.setState(...) accordingly (referencing useProvidersStore,
pullingModel, and pullProgress).

In `@web/src/pages/providers/ModelPullDialog.tsx`:
- Around line 83-99: The status and error text updates in ModelPullDialog
(statusText and pullProgress.error) are not announced to assistive tech; wrap
the status/error block (the paragraph showing statusText and the conditional
error paragraph) with the existing LiveRegion component from
web/src/components/ui/LiveRegion (or add aria-live="polite" aria-atomic="true"
to that container) so streamed SSE updates are announced; ensure you use the
LiveRegion wrapper rather than creating a new component to comply with
guidelines and keep the ProgressGauge and other layout intact.

---

Outside diff comments:
In `@tests/unit/providers/test_presets.py`:
- Around line 76-85: Update the test fixtures _CLOUD_PRESETS and
_SELF_HOSTED_PRESETS in test_presets.py to use vendor-agnostic names instead of
real vendor names: replace entries in the _CLOUD_PRESETS tuple
("anthropic","openai","gemini","mistral","groq","deepseek","openrouter") and the
_SELF_HOSTED_PRESETS tuple ("ollama","lm-studio","vllm","azure") with generic
test values (e.g., "test-provider-1", "test-provider-2", "test-model-1", etc.)
so the tuples remain the same shape but contain no real vendor identifiers.

In `@web/src/pages/providers/ProviderCard.stories.tsx`:
- Around line 55-60: The LocalProvider story currently uses baseProvider values
that leave supports_model_pull, supports_model_delete, and supports_model_config
false, but since it models an Ollama provider (litellm_provider: 'ollama') these
capability flags should be true; update the LocalProvider.args.provider to
explicitly set supports_model_pull: true, supports_model_delete: true, and
supports_model_config: true so the story accurately reflects Ollama's local
model management capabilities (locate the LocalProvider Story and the provider
object in ProviderCard.stories.tsx to apply the overrides).

---

Duplicate comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 321-327: The handler currently strips preset_name from incoming
POST /providers requests (using data.model_copy(...) before calling
app_state.provider_management.create_provider), which lets invalid requests
succeed and breaks downstream logic in _resolve_local_manager
(src/synthorg/providers/management/service.py). Change the endpoint to validate
at the boundary: if data.preset_name is present, return a 400/validation error
that instructs callers to use /providers/from-preset; otherwise pass the
original data through (do not drop preset_name) to
app_state.provider_management.create_provider. Ensure the error message
references the correct caller action and keep handling centered in the same
request handler that currently calls create_provider.

In `@web/src/pages/providers/ModelConfigDrawer.tsx`:
- Around line 34-52: The current handleSave implementation uses parseIntStrict
and parseFloatStrict which convert any non-empty invalid string into null,
unintentionally clearing values (e.g., num_threads with "auto"); change this so
empty strings still map to null but malformed or out-of-range inputs do not
become null—instead set per-field validation errors and prevent saving. Update
parseIntStrict/parseFloatStrict (or add validateInt/validateFloat) to return a
discriminated result (e.g., {value:number|null, error?:string}) or throw a
validation error, then in handleSave build newParams only from valid parsed
values and if any field (num_ctx, num_gpu_layers, num_threads, num_batch,
repeat_penalty) has a parse/range error set corresponding UI error state and
abort the save; ensure LocalModelParams remains correctly typed and that input
placeholder values like "auto" are treated as invalid text (not silently nulled)
so the UI can surface the error.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8fe3f4d0-4a41-448f-bc7a-ccd06cb7e6f5

📥 Commits

Reviewing files that changed from the base of the PR and between 2300ba8 and 301c062.

📒 Files selected for processing (36)
  • CLAUDE.md
  • docs/design/operations.md
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/api/dto_providers.py
  • src/synthorg/config/schema.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/observability/events/provider.py
  • src/synthorg/providers/management/_helpers.py
  • src/synthorg/providers/management/local_models.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/presets.py
  • tests/unit/providers/management/test_local_models.py
  • tests/unit/providers/management/test_service_local_models.py
  • tests/unit/providers/test_presets.py
  • web/src/__tests__/pages/ProviderDetailPage.test.tsx
  • web/src/__tests__/pages/ProvidersPage.test.tsx
  • web/src/__tests__/stores/setup-wizard.test.ts
  • web/src/__tests__/utils/providers.test.ts
  • web/src/__tests__/utils/setup-validation.property.test.ts
  • web/src/__tests__/utils/setup-validation.test.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/types.ts
  • web/src/pages/ProviderDetailPage.tsx
  • web/src/pages/providers/ModelConfigDrawer.stories.tsx
  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/pages/providers/ModelPullDialog.stories.tsx
  • web/src/pages/providers/ModelPullDialog.tsx
  • web/src/pages/providers/PresetPicker.stories.tsx
  • web/src/pages/providers/PresetPicker.tsx
  • web/src/pages/providers/ProviderCard.stories.tsx
  • web/src/pages/providers/ProviderDetailHeader.tsx
  • web/src/pages/providers/ProviderFormModal.tsx
  • web/src/pages/providers/ProviderModelList.stories.tsx
  • web/src/pages/providers/ProviderModelList.tsx
  • web/src/pages/providers/TestConnectionResult.tsx
  • web/src/stores/providers.ts

Comment on lines +755 to +763
except RuntimeError as exc:
logger.exception(
API_SSE_PULL_MODEL_FAILED,
resource="model",
name=model_id,
provider=name,
error=str(exc),
)
raise ApiError(str(exc)) from exc
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Mismatched event constant for delete errors.

API_SSE_PULL_MODEL_FAILED is used for RuntimeError in delete_model, but this endpoint handles model deletion, not pulling. Consider using a delete-specific event constant (or a generic model operation failure constant) for semantic accuracy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 755 - 763, The logger
in delete_model currently uses the pull-specific event constant
API_SSE_PULL_MODEL_FAILED; update the logger.exception call in delete_model to
use a delete-specific constant (e.g., API_SSE_DELETE_MODEL_FAILED) or a generic
model failure constant (e.g., API_SSE_MODEL_OPERATION_FAILED), and if that
constant does not exist add it to the event constants module; ensure the same
replacement is made for any related error-raising paths in delete_model so the
event name semantically matches the delete operation.

Comment on lines +802 to +819
except ProviderValidationError as exc:
exc_msg = str(exc)
if "not found" in exc_msg:
logger.warning(
API_RESOURCE_NOT_FOUND,
resource="model",
name=model_id,
provider=name,
)
raise NotFoundError(exc_msg) from exc
logger.warning(
API_VALIDATION_FAILED,
resource="provider",
name=name,
model=model_id,
error=exc_msg,
)
raise ApiValidationError(exc_msg) from exc
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Fragile string-based error classification.

Detecting "not found" via "not found" in exc_msg (line 804) is brittle—if the service's error message changes, this will silently break HTTP status code selection. Consider adding an error code or subclass to ProviderValidationError for explicit not-found cases.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 802 - 819, The
current handler relies on string-matching "not found" on ProviderValidationError
(variable exc_msg) which is brittle; update the ProviderValidationError
hierarchy to include an explicit subclass (e.g., ProviderNotFoundError) or an
error code attribute (e.g., .code == "NOT_FOUND"), then change the except block
in the controller to test that explicit signal (use isinstance(exc,
ProviderNotFoundError) or check exc.code) instead of `"not found" in exc_msg`,
and keep the existing logging calls (API_RESOURCE_NOT_FOUND with
resource="model", name=model_id, provider=name and raising NotFoundError) for
the not-found case, otherwise log API_VALIDATION_FAILED and raise
ApiValidationError as before.

Comment on lines +715 to +726
await self._resolve_local_manager(name, capability="config")
async with self._lock:
providers = await self._config_resolver.get_provider_configs()
config = providers.get(name)
if config is None:
msg = f"Provider {name!r} not found"
logger.warning(
PROVIDER_NOT_FOUND,
provider=name,
error=msg,
)
raise ProviderNotFoundError(msg)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential TOCTOU: capability check before lock, config read under lock.

_resolve_local_manager reads the provider config (via get_provider) outside the lock, then update_model_config re-reads it under the lock at line 717. If the provider is deleted or its preset_name/base_url changes between these two reads, the capability check passes but the locked read finds a different (or missing) config. The code handles the deletion case (lines 719-726), but a concurrent update that removes preset_name or base_url would bypass the capability guard.

Consider moving the capability check inside the lock, or re-validating the preset capability after re-reading the config.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/management/service.py` around lines 715 - 726, The
capability check done by _resolve_local_manager before acquiring self._lock can
race with concurrent provider updates; move the capability validation into the
locked section (the block that calls
self._config_resolver.get_provider_configs()) or, after re-reading config under
the lock in update_model_config, re-validate that the provider still has the
required fields (preset_name/base_url) and capability before proceeding.
Specifically, adjust the flow around _resolve_local_manager and the locked read
of get_provider_configs so that either the capability check is performed while
holding self._lock or add explicit re-validation of preset_name/base_url and the
requested capability after fetching config and before raising
ProviderNotFoundError or continuing.

Comment on lines +178 to +184
if (!response.ok || !response.body) {
if (response.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_token_expires_at')
localStorage.removeItem('auth_must_change_password')
}
throw new Error(`Pull failed: HTTP ${response.status}`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mirror the shared 401 logout flow on this fetch path.

This request bypasses apiClient interceptors. Clearing only localStorage here leaves the auth store and redirect path untouched, so an expired token during a pull can leave the UI looking logged in until some later axios request happens to fail.

♻️ Suggested change
   if (!response.ok || !response.body) {
     if (response.status === 401) {
       localStorage.removeItem('auth_token')
       localStorage.removeItem('auth_token_expires_at')
       localStorage.removeItem('auth_must_change_password')
+      import('@/stores/auth').then(({ useAuthStore }) => {
+        useAuthStore.getState().logout()
+      }).catch(() => {
+        if (window.location.pathname !== '/login' && window.location.pathname !== '/setup') {
+          window.location.href = '/login'
+        }
+      })
     }
📝 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
if (!response.ok || !response.body) {
if (response.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_token_expires_at')
localStorage.removeItem('auth_must_change_password')
}
throw new Error(`Pull failed: HTTP ${response.status}`)
import { useAuthStore } from '@/stores/auth'
// ... elsewhere in the file ...
if (!response.ok || !response.body) {
if (response.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_token_expires_at')
localStorage.removeItem('auth_must_change_password')
useAuthStore.getState().logout()
}
throw new Error(`Pull failed: HTTP ${response.status}`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/api/endpoints/providers.ts` around lines 178 - 184, This fetch error
branch currently only clears localStorage keys ('auth_token',
'auth_token_expires_at', 'auth_must_change_password') which leaves the app auth
store and redirect state inconsistent; replace that local-only cleanup with the
shared 401 logout flow used by apiClient interceptors (i.e., call the central
logout/unauthorized handler used elsewhere so the auth store is cleared and the
redirect path/router is updated) instead of directly removing localStorage keys
in this response handling block.

- Fix model count mismatch: skip models_from_litellm for local presets
  (auth_type=NONE), always use live discovery instead of stale static DB
- Add preset capability flags: supports_model_pull/delete/config on
  ProviderPreset (Ollama: all true, LM Studio/vLLM: deferred)
- Add LocalModelParams model for per-model launch parameters (num_ctx,
  num_gpu_layers, num_threads, num_batch)
- Add preset_name to ProviderConfig for capability flag resolution
- New OllamaModelManager: pull (streaming via /api/pull), delete
  (/api/delete) with LocalModelManager protocol
- Service layer: pull_model, delete_model, update_model_config methods
- API: SSE pull endpoint, DELETE model, PUT model config
- Dashboard: model pull dialog with progress, delete with confirmation,
  per-model config drawer, refresh button, actions gated by preset caps
- Extend ProviderResponse with capability flags derived from preset

Closes #1030
- Fix ProviderModelResponse missing local_params field (API contract)
- Add httpx error handling + terminal event in pull stream
- Add SSE generator catch-all for unexpected exceptions
- Extract _resolve_local_manager to reduce service.py under 800 lines
- Extract _parse_pull_line to reduce pull_model under 50 lines
- Condense LocalModelParams to reduce schema.py under 800 lines
- Split local model tests to test_service_local_models.py (under 800)
- Add repeat_penalty to LocalModelParams and dashboard config drawer
- Fix post-delete refresh failure (catch + log, don't propagate)
- Fix progress calc truthy check (isinstance + total > 0)
- Fix bare next() StopIteration risk in controller
- Add missing log-before-raise in 6+ error paths
- Refactor ModelPullDialog: proper overlay, a11y attrs, Escape handler
- Add aria-labels to model action buttons
- Fix parseInt NaN validation in ModelConfigDrawer
- Remove double padding in ModelConfigDrawer
- Process remaining SSE buffer after stream ends
- Add preset_name to frontend CreateProviderRequest
- Add Storybook stories for ModelPullDialog and ModelConfigDrawer
- Update CLAUDE.md and operations.md with new endpoints and features
- Log malformed JSON in pull stream instead of silent discard
- Change PullProgressEvent.error to NotBlankStr | None

Pre-reviewed by 8 agents, 22 findings addressed
Backend:
- Add API_SSE_PULL_MODEL_FAILED event constant, replace raw string
- SSE error events now emit PullProgressEvent-shaped data (status + error + done)
- Add MemoryError/RecursionError guard in SSE catch-all
- Add model_name character-allowlist validator on PullModelRequest
- Add Parameter(max_length=256) on model_id path params
- Add missing log before raise in update_model_config assertion
- Add capability check (supports_model_config) in update_model_config
- Validate base_url is non-empty before creating local model manager
- Add httpx.HTTPError handler in OllamaModelManager.delete_model
- Add PullProgressEvent cross-field validators (error->done, bytes)
- Add base_url validation in OllamaModelManager constructor
- Split pull_model into pull_model + _consume_pull_stream (50-line rule)
- Fix module docstring (Ollama only, not LM Studio)
- Add ProviderPreset/ProviderResponse docstring Attributes for new fields

Frontend:
- Rewrite SSE parser: parse event: field, handle error events, track
  terminal done event, throw on stream end without completion
- Fix URL encoding: use encodeModelIdPath preserving / for :path params
- Handle 401 in fetch path (clear stale auth token)
- Replace custom modal with Radix AlertDialog (focus trap, a11y)
- Use individual Zustand selectors in ModelPullDialog and ProviderDetailPage
- Use strict Number() parsing in ModelConfigDrawer (reject '12abc')
- Abort existing pull controller before starting new one
- Add Storybook stories for delete/config action columns

Design tokens:
- PresetPicker: gap-3 -> gap-grid-gap, p-4 -> p-card
- ProviderFormModal: p-3 -> p-card on warning banner
- TestConnectionResult: px-3 py-2 -> p-card on result banner
- ModelConfigDrawer/ModelPullDialog: gap-4 -> gap-section-gap

Tests:
- Replace vendor name 'anthropic' with 'example-provider'/'openrouter'
- Fix misleading test docstring (yields error event, not raises)

Docs:
- Fix event module count 56 -> 58 in operations.md
- Strip preset_name from external CreateProviderRequest at controller
  level to prevent capability flag injection (only create_from_preset
  may set preset_name)
- Use RuntimeError (not ValueError) for non-404 delete errors so
  controller maps them to 422 instead of 404
- Add RuntimeError catch in delete_model controller
- Replace raw <button> with <Button> component in ProviderModelList
- Fix ModelPullDialog.stories.tsx: min-h-[400px] -> min-h-96, fix
  PullError story state (error requires done=true per validator)
- Instance-tied abort controller cleanup in store pullModel
- Track last SSE event -- show error toast if terminal event has error
- Use fully qualified API paths in operations.md local model section
- Fix TS2339 in stores/providers.ts: track lastError string instead
  of lastEvent object (avoids CFA narrowing to never for callback-
  mutated variables)
- Remove dead got_done variable in _consume_pull_stream (early return
  on event.done makes the flag unreachable)
- Differentiate model-not-found from other ProviderValidationError
  in update_model_config controller (404 vs 422)
Backend:
- RuntimeError from upstream delete failures now raises ApiError (500)
  instead of ApiValidationError (422) -- server errors should be 5xx
- Remove exclude_none=True from SSE progress events so nullable fields
  (progress_percent, total_bytes, completed_bytes, error) are always
  present as null instead of omitted
- Add _sse_error() helper for consistent PullProgressEvent-shaped error
  dicts in all SSE error branches
- Missing-model-after-update invariant violation now raises ApiError
  (500) instead of ApiValidationError (422)
- Update delete_model docstrings (protocol + impl) to distinguish
  ValueError (404 not-found) from RuntimeError (upstream/transport)

Frontend:
- Move set({ pullingModel: false }) inside controller equality check
  so older settled requests don't reset state for a newer active pull
- Refresh providers list cache (fetchProviders) after successful pull
  and delete so list-level model counts stay current
- SSE generator now emits event type 'error' (not 'complete') when a
  terminal PullProgressEvent carries an error field
- Guard _consume_pull_stream against non-object JSON payloads from
  Ollama (log warning and skip instead of AttributeError)
- Progress callback ignores events after controller.signal.aborted
  to prevent superseded/cancelled pulls from updating store state
Backend:
- Add API_MODEL_OPERATION_FAILED constant for delete/config errors
  (replaces misused API_SSE_PULL_MODEL_FAILED in non-SSE handlers)
- Add LocalModelParams Attributes docstring (5 fields)
- Add preset_name to ProviderConfig Attributes docstring
- Guard _consume_pull_stream against non-dict JSON payloads

Frontend:
- SSE parser: call onProgress before throwing on error events so
  pullProgress state reflects the terminal error
- Pass provider.preset_name to discoverModels on refresh
- Wrap pull status/error text in LiveRegion for screen readers

Tests:
- Add 3 tests for PullProgressEvent cross-field invariants
  (error requires done, completed <= total, error+done valid)

Stories:
- PullError: set pullingModel=false for post-failure state
- Rename EmptyParams to AllNullParams (differentiate from Default)
- ProviderCard LocalProvider: add capability flags for Ollama
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

♻️ Duplicate comments (4)
web/src/api/endpoints/providers.ts (1)

179-185: ⚠️ Potential issue | 🟠 Major

Reuse the shared unauthorized handler on this fetch path.

This still only clears localStorage on 401. Because the request bypasses apiClient, the auth store and redirect state can stay stale until some later Axios request fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/api/endpoints/providers.ts` around lines 179 - 185, Replace the
ad-hoc 401 cleanup that directly removes localStorage keys in this fetch path
with the app's shared unauthorized handler so the auth store and redirect flow
stay in sync; import and call the central handler (e.g., handleUnauthorized or
authStore.handleUnauthorized) when response.status === 401 instead of removing
'auth_token', 'auth_token_expires_at', and 'auth_must_change_password' manually,
then rethrow the error as before (i.e., call the shared handler before throwing
`new Error(\`Pull failed: HTTP ${response.status}\`)`).
src/synthorg/providers/management/service.py (2)

715-726: ⚠️ Potential issue | 🟠 Major

Revalidate config capability after acquiring self._lock.

Line 715 checks preset_name and base_url before the critical section, but Line 717 re-reads the provider under the lock. A concurrent update_provider() can swap the preset or clear the base URL in between, and this method will still persist local_params onto the new config. Move the capability check into the locked section, or repeat it after config = providers.get(name).

🔒 Re-check the preset after loading the locked config
-        await self._resolve_local_manager(name, capability="config")
         async with self._lock:
             providers = await self._config_resolver.get_provider_configs()
             config = providers.get(name)
             if config is None:
                 msg = f"Provider {name!r} not found"
@@
                 )
                 raise ProviderNotFoundError(msg)
+            preset = get_preset(config.preset_name) if config.preset_name else None
+            if preset is None or not preset.supports_model_config:
+                msg = f"Provider {name!r} does not support model config"
+                logger.warning(
+                    PROVIDER_VALIDATION_FAILED,
+                    provider=name,
+                    error=msg,
+                )
+                raise ProviderValidationError(msg)
+            if not config.base_url:
+                msg = f"Provider {name!r} has no base URL configured"
+                logger.warning(
+                    PROVIDER_VALIDATION_FAILED,
+                    provider=name,
+                    error=msg,
+                )
+                raise ProviderValidationError(msg)
             model_idx = next(
                 (i for i, m in enumerate(config.models) if m.id == model_id),
                 None,
             )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/management/service.py` around lines 715 - 726, The
code currently validates preset_name/base_url before acquiring self._lock but
reads the provider config inside the locked section, risking a race where
update_provider() changes the config; to fix, revalidate the provider's "config"
capability and the preset_name/base_url after fetching config under the lock (or
move the original checks into the async with self._lock block) so that the
local_params are only applied to the same config you just loaded; update logic
around _resolve_local_manager, async with self._lock, providers = await
self._config_resolver.get_provider_configs(), config = providers.get(name), and
local_params to perform capability/preset/base_url checks while holding the
lock.

731-739: ⚠️ Potential issue | 🟡 Minor

Use a dedicated not-found signal for missing models.

Raising ProviderValidationError here is what forces the controller to parse "not found" out of str(exc) to choose between 400 and 404. That coupling is brittle and can misclassify unrelated validation failures if the message text changes or a provider/model name contains that phrase. Raise a model-specific not-found exception, or at least a coded validation error, instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/management/service.py` around lines 731 - 739, The
current branch raises ProviderValidationError when model_idx is None which
forces callers to parse the error text to infer a 404; instead introduce and
raise a dedicated not-found exception (e.g., ProviderModelNotFound or
ModelNotFoundError) from this location: replace the raise
ProviderValidationError(msg) in the block that checks model_idx with raise
ProviderModelNotFound(msg) (or a coded ValidationError subtype) and keep the
existing logging call (PROVIDER_VALIDATION_FAILED, provider=name,
model=model_id, error=msg); also add the new exception class where other
provider errors are declared so the controller can catch it explicitly and map
to 404.
src/synthorg/api/controllers/providers.py (1)

322-328: ⚠️ Potential issue | 🟠 Major

Don't silently discard preset_name on create.

This makes a request with preset metadata look successful while creating a provider that can no longer resolve local-model capabilities, because ProviderManagementService._resolve_local_manager() keys off config.preset_name. Either preserve the field or reject it with a 4xx that points callers at /providers/from-preset.

🛡️ Fail fast instead of silently discarding the field
-        # Strip preset_name from external requests -- only
-        # create_from_preset may set it (capability flag injection).
-        safe_data = data.model_copy(update={"preset_name": None})
+        if data.preset_name is not None:
+            logger.warning(
+                API_VALIDATION_FAILED,
+                resource="provider",
+                name=data.name,
+                error="preset_name is only accepted by /providers/from-preset",
+            )
+            raise ApiValidationError(
+                "Use /providers/from-preset when supplying preset_name",
+            )
         try:
-            config = await app_state.provider_management.create_provider(
-                safe_data,
-            )
+            config = await app_state.provider_management.create_provider(data)
As per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/providers.py` around lines 322 - 328, The
handler currently strips preset_name via safe_data =
data.model_copy(update={"preset_name": None}) which silently accepts requests
with preset metadata but loses it and breaks
ProviderManagementService._resolve_local_manager; instead validate at the
boundary: before calling data.model_copy or
app_state.provider_management.create_provider, check if data.preset_name is set
and return a 4xx (HTTP 400) response guiding callers to use
/providers/from-preset (or document the correct flow), or alternatively only
allow preset_name to be set via the create_from_preset flow; update the logic
around safe_data, data.model_copy, and create_provider accordingly so
preset_name is either preserved when legitimately set by create_from_preset or
rejected with a clear client error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/synthorg/api/dto_providers.py`:
- Around line 485-486: Update the docstring example for the model_name attribute
to use a neutral identifier: replace "llama3.2:1b" with "test-local-001:latest"
in the docstring that documents the model_name attribute in dto_providers (look
for the Attributes: block and the model_name example string).

In `@src/synthorg/providers/management/service.py`:
- Around line 339-343: The AuthType.NONE branch currently seeds models =
preset.default_models which prevents _maybe_discover_preset_models from probing
live; change the AuthType.NONE handling so it does not populate the local models
variable (use an empty tuple or None to force discovery) and only use
preset.default_models as a fallback after _maybe_discover_preset_models returns
no live models; update the code around the models variable and the call to
_maybe_discover_preset_models to ensure AuthType.NONE presets trigger live
discovery while preserving preset.default_models as a fallback.

In `@tests/unit/providers/management/test_local_models.py`:
- Around line 246-260: Add tests to ensure LocalModelParams rejects non-finite
numeric values: create a new test (e.g., test_non_finite_validation) that uses
pytest.raises(ValidationError) when constructing LocalModelParams with
float("nan"), float("inf"), and float("-inf") for fields num_ctx,
num_gpu_layers, num_threads, num_batch and repeat_penalty; reference the
LocalModelParams class and the repeat_penalty field so the assertions cover all
numeric fields and will fail if allow_inf_nan=False is missing from the model
ConfigDict.

In `@tests/unit/providers/management/test_service_local_models.py`:
- Around line 54-67: The fake_pull generator ignores its model_name argument so
the test doesn't verify the service passed the correct identifier; change the
setup so the manager call is asserted: either make fake_pull record the incoming
model_name (e.g., append to a captured list) and after collecting events assert
the captured value equals "test-model:7b", or assign mock_manager.pull_model =
AsyncMock(side_effect=fake_pull) and after iterating assert
mock_manager.pull_model.assert_awaited_with("test-model:7b"); reference
get_local_model_manager, mock_manager, fake_pull and service.pull_model to
locate the change.

In `@tests/unit/providers/test_presets.py`:
- Around line 113-136: Combine the three tests into a single parametrized pytest
using get_preset to assert the three capability flags per preset: replace
test_ollama_supports_local_model_management,
test_lm_studio_local_management_deferred, and
test_vllm_no_local_model_management with one test function (e.g.,
test_preset_local_management) decorated with `@pytest.mark.parametrize` that
iterates over tuples like ("ollama", True, True, True), ("lm-studio", False,
False, False), ("vllm", False, False, False) and inside call preset =
get_preset(name) then assert preset is not None and assert
preset.supports_model_pull is pull_expected, preset.supports_model_delete is
delete_expected, preset.supports_model_config is config_expected.

In `@web/src/api/endpoints/providers.ts`:
- Around line 133-147: The catch block around JSON.parse in the pull stream
handler incorrectly rethrows all Errors (including SyntaxError) so malformed
JSON frames abort the pull; update the catch to first detect parse failures
(e.g., err instanceof SyntaxError or err?.name === 'SyntaxError') and handle
them by resetting state.currentEvent, logging the malformed-JSON warning and
continuing (do not rethrow), while still rethrowing non-parse Errors; ensure
this behavior applies to the block handling parsed as PullProgressEvent and
calls to onProgress/state.currentEvent.

In `@web/src/pages/providers/ModelConfigDrawer.tsx`:
- Around line 36-45: The parsing helpers parseIntStrict and parseFloatStrict
accept whitespace-only strings because they check !val instead of trimming;
update both to trim the input first (e.g., const s = val.trim()) and treat empty
trimmed strings as null, then parse s with Number and keep the existing
Number.isFinite/Number.isInteger checks so whitespace-only input is no longer
interpreted as 0.

In `@web/src/pages/providers/ModelPullDialog.tsx`:
- Line 2: The AlertDialog import is using the wrong package; update the import
that currently references AlertDialog from 'radix-ui' to import AlertDialog from
the scoped Radix package '@radix-ui/react-alert-dialog' so the component
resolves correctly with Radix UI v1.4.3 (look for the AlertDialog import
statement to replace).

---

Duplicate comments:
In `@src/synthorg/api/controllers/providers.py`:
- Around line 322-328: The handler currently strips preset_name via safe_data =
data.model_copy(update={"preset_name": None}) which silently accepts requests
with preset metadata but loses it and breaks
ProviderManagementService._resolve_local_manager; instead validate at the
boundary: before calling data.model_copy or
app_state.provider_management.create_provider, check if data.preset_name is set
and return a 4xx (HTTP 400) response guiding callers to use
/providers/from-preset (or document the correct flow), or alternatively only
allow preset_name to be set via the create_from_preset flow; update the logic
around safe_data, data.model_copy, and create_provider accordingly so
preset_name is either preserved when legitimately set by create_from_preset or
rejected with a clear client error.

In `@src/synthorg/providers/management/service.py`:
- Around line 715-726: The code currently validates preset_name/base_url before
acquiring self._lock but reads the provider config inside the locked section,
risking a race where update_provider() changes the config; to fix, revalidate
the provider's "config" capability and the preset_name/base_url after fetching
config under the lock (or move the original checks into the async with
self._lock block) so that the local_params are only applied to the same config
you just loaded; update logic around _resolve_local_manager, async with
self._lock, providers = await self._config_resolver.get_provider_configs(),
config = providers.get(name), and local_params to perform
capability/preset/base_url checks while holding the lock.
- Around line 731-739: The current branch raises ProviderValidationError when
model_idx is None which forces callers to parse the error text to infer a 404;
instead introduce and raise a dedicated not-found exception (e.g.,
ProviderModelNotFound or ModelNotFoundError) from this location: replace the
raise ProviderValidationError(msg) in the block that checks model_idx with raise
ProviderModelNotFound(msg) (or a coded ValidationError subtype) and keep the
existing logging call (PROVIDER_VALIDATION_FAILED, provider=name,
model=model_id, error=msg); also add the new exception class where other
provider errors are declared so the controller can catch it explicitly and map
to 404.

In `@web/src/api/endpoints/providers.ts`:
- Around line 179-185: Replace the ad-hoc 401 cleanup that directly removes
localStorage keys in this fetch path with the app's shared unauthorized handler
so the auth store and redirect flow stay in sync; import and call the central
handler (e.g., handleUnauthorized or authStore.handleUnauthorized) when
response.status === 401 instead of removing 'auth_token',
'auth_token_expires_at', and 'auth_must_change_password' manually, then rethrow
the error as before (i.e., call the shared handler before throwing `new
Error(\`Pull failed: HTTP ${response.status}\`)`).
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64dbca03-979b-4509-a9f9-fca8724a8687

📥 Commits

Reviewing files that changed from the base of the PR and between 301c062 and fa3da85.

📒 Files selected for processing (36)
  • CLAUDE.md
  • docs/design/operations.md
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/api/dto_providers.py
  • src/synthorg/config/schema.py
  • src/synthorg/observability/events/api.py
  • src/synthorg/observability/events/provider.py
  • src/synthorg/providers/management/_helpers.py
  • src/synthorg/providers/management/local_models.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/presets.py
  • tests/unit/providers/management/test_local_models.py
  • tests/unit/providers/management/test_service_local_models.py
  • tests/unit/providers/test_presets.py
  • web/src/__tests__/pages/ProviderDetailPage.test.tsx
  • web/src/__tests__/pages/ProvidersPage.test.tsx
  • web/src/__tests__/stores/setup-wizard.test.ts
  • web/src/__tests__/utils/providers.test.ts
  • web/src/__tests__/utils/setup-validation.property.test.ts
  • web/src/__tests__/utils/setup-validation.test.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/types.ts
  • web/src/pages/ProviderDetailPage.tsx
  • web/src/pages/providers/ModelConfigDrawer.stories.tsx
  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/pages/providers/ModelPullDialog.stories.tsx
  • web/src/pages/providers/ModelPullDialog.tsx
  • web/src/pages/providers/PresetPicker.stories.tsx
  • web/src/pages/providers/PresetPicker.tsx
  • web/src/pages/providers/ProviderCard.stories.tsx
  • web/src/pages/providers/ProviderDetailHeader.tsx
  • web/src/pages/providers/ProviderFormModal.tsx
  • web/src/pages/providers/ProviderModelList.stories.tsx
  • web/src/pages/providers/ProviderModelList.tsx
  • web/src/pages/providers/TestConnectionResult.tsx
  • web/src/stores/providers.ts

Comment on lines +339 to 343
elif preset.auth_type == AuthType.NONE:
# Local providers: skip static LiteLLM DB, rely on live
# discovery in _maybe_discover_preset_models below.
models = preset.default_models
else:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Don't let default_models short-circuit live discovery for no-auth presets.

_maybe_discover_preset_models() returns immediately when models is truthy. Seeding models = preset.default_models here means any AuthType.NONE preset with built-in models will skip the live probe and can fall back to stale static counts again. Pass an empty tuple into discovery and keep preset.default_models only as fallback if you still want seeded models.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/management/service.py` around lines 339 - 343, The
AuthType.NONE branch currently seeds models = preset.default_models which
prevents _maybe_discover_preset_models from probing live; change the
AuthType.NONE handling so it does not populate the local models variable (use an
empty tuple or None to force discovery) and only use preset.default_models as a
fallback after _maybe_discover_preset_models returns no live models; update the
code around the models variable and the call to _maybe_discover_preset_models to
ensure AuthType.NONE presets trigger live discovery while preserving
preset.default_models as a fallback.

Comment on lines +246 to +260
def test_all_none_by_default(self) -> None:
params = LocalModelParams()
assert params.num_ctx is None
assert params.num_gpu_layers is None
assert params.num_threads is None
assert params.num_batch is None

def test_positive_validation(self) -> None:
from pydantic import ValidationError

with pytest.raises(ValidationError):
LocalModelParams(num_ctx=0) # gt=0

def test_gpu_layers_allows_zero(self) -> None:
params = LocalModelParams(num_gpu_layers=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.

🧹 Nitpick | 🔵 Trivial

Cover the non-finite LocalModelParams cases too.

These tests never assert that the new numeric fields reject NaN/Inf, and repeat_penalty is still unpinned here. If the schema ever loses its non-finite guard, this file stays green while the API starts accepting invalid persisted model config values.

As per coding guidelines, "In all ConfigDict declarations, use allow_inf_nan=False to reject NaN/Inf in numeric fields at validation time."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/providers/management/test_local_models.py` around lines 246 - 260,
Add tests to ensure LocalModelParams rejects non-finite numeric values: create a
new test (e.g., test_non_finite_validation) that uses
pytest.raises(ValidationError) when constructing LocalModelParams with
float("nan"), float("inf"), and float("-inf") for fields num_ctx,
num_gpu_layers, num_threads, num_batch and repeat_penalty; reference the
LocalModelParams class and the repeat_penalty field so the assertions cover all
numeric fields and will fail if allow_inf_nan=False is missing from the model
ConfigDict.

Comment on lines +113 to +136
def test_ollama_supports_local_model_management(self) -> None:
"""Ollama preset supports pull, delete, and config."""
preset = get_preset("ollama")
assert preset is not None
assert preset.supports_model_pull is True
assert preset.supports_model_delete is True
assert preset.supports_model_config is True

def test_lm_studio_local_management_deferred(self) -> None:
"""LM Studio preset flags are False until API stabilizes."""
preset = get_preset("lm-studio")
assert preset is not None
assert preset.supports_model_pull is False
assert preset.supports_model_delete is False
assert preset.supports_model_config is False

def test_vllm_no_local_model_management(self) -> None:
"""vLLM has no pull/delete/config API."""
preset = get_preset("vllm")
assert preset is not None
assert preset.supports_model_pull is False
assert preset.supports_model_delete is False
assert preset.supports_model_config is False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider parameterizing the three local-capability tests to remove duplication.

The assertions are correct, but these cases can be expressed as one parametrized test for easier extension.

♻️ Suggested refactor
-    def test_ollama_supports_local_model_management(self) -> None:
-        """Ollama preset supports pull, delete, and config."""
-        preset = get_preset("ollama")
-        assert preset is not None
-        assert preset.supports_model_pull is True
-        assert preset.supports_model_delete is True
-        assert preset.supports_model_config is True
-
-    def test_lm_studio_local_management_deferred(self) -> None:
-        """LM Studio preset flags are False until API stabilizes."""
-        preset = get_preset("lm-studio")
-        assert preset is not None
-        assert preset.supports_model_pull is False
-        assert preset.supports_model_delete is False
-        assert preset.supports_model_config is False
-
-    def test_vllm_no_local_model_management(self) -> None:
-        """vLLM has no pull/delete/config API."""
-        preset = get_preset("vllm")
-        assert preset is not None
-        assert preset.supports_model_pull is False
-        assert preset.supports_model_delete is False
-        assert preset.supports_model_config is False
+    `@pytest.mark.parametrize`(
+        ("name", "supports_pull", "supports_delete", "supports_config"),
+        [
+            ("ollama", True, True, True),
+            ("lm-studio", False, False, False),
+            ("vllm", False, False, False),
+        ],
+    )
+    def test_local_model_management_capabilities(
+        self,
+        name: str,
+        supports_pull: bool,
+        supports_delete: bool,
+        supports_config: bool,
+    ) -> None:
+        """Local model management capability flags match preset behavior."""
+        preset = get_preset(name)
+        assert preset is not None
+        assert preset.supports_model_pull is supports_pull
+        assert preset.supports_model_delete is supports_delete
+        assert preset.supports_model_config is supports_config

As per coding guidelines: "Prefer @pytest.mark.parametrize for testing similar cases."

📝 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
def test_ollama_supports_local_model_management(self) -> None:
"""Ollama preset supports pull, delete, and config."""
preset = get_preset("ollama")
assert preset is not None
assert preset.supports_model_pull is True
assert preset.supports_model_delete is True
assert preset.supports_model_config is True
def test_lm_studio_local_management_deferred(self) -> None:
"""LM Studio preset flags are False until API stabilizes."""
preset = get_preset("lm-studio")
assert preset is not None
assert preset.supports_model_pull is False
assert preset.supports_model_delete is False
assert preset.supports_model_config is False
def test_vllm_no_local_model_management(self) -> None:
"""vLLM has no pull/delete/config API."""
preset = get_preset("vllm")
assert preset is not None
assert preset.supports_model_pull is False
assert preset.supports_model_delete is False
assert preset.supports_model_config is False
`@pytest.mark.parametrize`(
("name", "supports_pull", "supports_delete", "supports_config"),
[
("ollama", True, True, True),
("lm-studio", False, False, False),
("vllm", False, False, False),
],
)
def test_local_model_management_capabilities(
self,
name: str,
supports_pull: bool,
supports_delete: bool,
supports_config: bool,
) -> None:
"""Local model management capability flags match preset behavior."""
preset = get_preset(name)
assert preset is not None
assert preset.supports_model_pull is supports_pull
assert preset.supports_model_delete is supports_delete
assert preset.supports_model_config is supports_config
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/providers/test_presets.py` around lines 113 - 136, Combine the
three tests into a single parametrized pytest using get_preset to assert the
three capability flags per preset: replace
test_ollama_supports_local_model_management,
test_lm_studio_local_management_deferred, and
test_vllm_no_local_model_management with one test function (e.g.,
test_preset_local_management) decorated with `@pytest.mark.parametrize` that
iterates over tuples like ("ollama", True, True, True), ("lm-studio", False,
False, False), ("vllm", False, False, False) and inside call preset =
get_preset(name) then assert preset is not None and assert
preset.supports_model_pull is pull_expected, preset.supports_model_delete is
delete_expected, preset.supports_model_config is config_expected.

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { AlertDialog } from 'radix-ui'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the Radix UI version and import patterns used in the project
echo "=== package.json radix dependencies ==="
cat web/package.json | jq '.dependencies | with_entries(select(.key | test("radix")))'

echo -e "\n=== Existing radix imports in codebase ==="
rg -n "from ['\"](`@radix-ui`|radix-ui)" web/src --type ts --type tsx -g '*.tsx' -g '*.ts' | head -20

Repository: Aureliolo/synthorg

Length of output: 217


🏁 Script executed:

#!/bin/bash
# Check the actual import in ModelPullDialog.tsx
echo "=== ModelPullDialog.tsx imports (first 20 lines) ==="
head -20 web/src/pages/providers/ModelPullDialog.tsx | cat -n

echo -e "\n=== Search for `@radix-ui` imports in the codebase ==="
rg "@radix-ui" web/src --max-count 15

Repository: Aureliolo/synthorg

Length of output: 1221


Fix the Radix UI import for AlertDialog.

The project uses Radix UI v1.4.3, which requires scoped imports. Line 2 should be import { AlertDialog } from '@radix-ui/react-alert-dialog' instead of from 'radix-ui'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ModelPullDialog.tsx` at line 2, The AlertDialog
import is using the wrong package; update the import that currently references
AlertDialog from 'radix-ui' to import AlertDialog from the scoped Radix package
'@radix-ui/react-alert-dialog' so the component resolves correctly with Radix UI
v1.4.3 (look for the AlertDialog import statement to replace).

- Replace 'llama3.2:1b' with 'test-local-001:latest' in PullModelRequest
  docstring (vendor-agnostic)
- SSE parser: catch SyntaxError (JSON parse) separately from Error
  (intentional error events) to avoid aborting on malformed frames
- ModelConfigDrawer: trim whitespace before Number() parse to prevent
  whitespace-only strings becoming 0
- Add parametrized test for LocalModelParams non-finite rejection
  (NaN, Inf, -Inf across all numeric fields)
- Verify model_name argument passed to pull manager in service test
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/synthorg/api/dto_providers.py (1)

407-433: 🧹 Nitpick | 🔵 Trivial

Consider logging when preset lookup fails for a non-None preset_name.

If config.preset_name is set but get_preset() returns None (e.g., due to a typo or removed preset), the capabilities silently default to False. This may mask configuration errors.

♻️ Optional: Add debug logging for unknown preset
+    from synthorg.observability import get_logger
+    logger = get_logger(__name__)
+
     preset = get_preset(config.preset_name) if config.preset_name else None
+    if config.preset_name and preset is None:
+        logger.debug(
+            "preset_lookup_miss",
+            preset_name=config.preset_name,
+            msg="Unknown preset; capabilities will be False",
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/dto_providers.py` around lines 407 - 433, When
config.preset_name is provided but get_preset(config.preset_name) returns None
the code silently falls back to False for capabilities; update the logic around
get_preset and the preset variable to detect this case and emit a debug or
warning log (including the requested preset name) before constructing the
ProviderResponse (functions/variables: get_preset, config.preset_name, preset,
ProviderResponse) so mis-typed or missing presets are visible in logs while
preserving the existing fallback behavior.
♻️ Duplicate comments (2)
web/src/api/endpoints/providers.ts (1)

181-188: ⚠️ Potential issue | 🟠 Major

Mirror the shared 401 logout flow for consistency.

The 401 handling here only clears localStorage keys, but the axios interceptor in client.ts (lines 40-55) also dynamically imports and calls useAuthStore.getState().logout() to sync Zustand state. This inconsistency can leave the UI appearing logged in after a 401 during a pull operation.

🔧 Suggested fix
   if (!response.ok || !response.body) {
     if (response.status === 401) {
       localStorage.removeItem('auth_token')
       localStorage.removeItem('auth_token_expires_at')
       localStorage.removeItem('auth_must_change_password')
+      // Sync Zustand auth state -- mirrors apiClient interceptor behavior
+      import('@/stores/auth')
+        .then(({ useAuthStore }) => {
+          useAuthStore.getState().logout()
+        })
+        .catch((importErr: unknown) => {
+          console.error('Auth store cleanup failed during 401 handling:', importErr)
+        })
     }
     throw new Error(`Pull failed: HTTP ${response.status}`)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/api/endpoints/providers.ts` around lines 181 - 188, The 401 branch in
the pull response handling currently only clears localStorage keys; update it to
mirror the shared logout flow used in client.ts by also dynamically importing
and calling useAuthStore.getState().logout() so Zustand auth state is cleared;
keep the existing removals of 'auth_token', 'auth_token_expires_at', and
'auth_must_change_password', and ensure this logic is placed in the same 401
conditional inside the response handling in providers.ts so the UI state and
storage are synchronized on 401.
web/src/pages/providers/ModelConfigDrawer.tsx (1)

34-58: 🧹 Nitpick | 🔵 Trivial

Consider adding validation feedback for invalid numeric input.

The strict parsing functions correctly reject invalid input (e.g., "12abc" → null), but users receive no visual feedback when their input is silently discarded. An empty field and an invalid field both result in null, making it unclear whether the value was intentionally cleared or rejected.

Consider tracking parse errors and using InputField's error prop to display validation messages. This is a UX improvement rather than a bug, since null values are valid per the LocalModelParams schema.

♻️ Optional enhancement with validation errors
+  const [errors, setErrors] = useState<Record<string, string | null>>({})
+
   const handleSave = async () => {
+    const newErrors: Record<string, string | null> = {}
     const parseIntStrict = (val: string, field: string): number | null => {
       const s = val.trim()
       if (!s) return null
       const n = Number(s)
-      return Number.isFinite(n) && Number.isInteger(n) ? n : null
+      if (Number.isFinite(n) && Number.isInteger(n)) return n
+      newErrors[field] = 'Must be a valid integer'
+      return null
     }
     // ... similar for parseFloatStrict
+    setErrors(newErrors)
+    if (Object.values(newErrors).some(Boolean)) {
+      setSaving(false)
+      return
+    }

Then pass error={errors.numCtx} to each InputField.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/ModelConfigDrawer.tsx` around lines 34 - 58, The
handleSave logic currently uses parseIntStrict/parseFloatStrict to turn invalid
numeric strings into null without user feedback; update handleSave to collect
per-field parse errors (e.g., errors.numCtx, errors.numGpuLayers,
errors.numThreads, errors.numBatch, errors.repeatPenalty) when
parseIntStrict/parseFloatStrict returns null for non-empty input, set those
errors into component state, and prevent calling updateModelConfig until errors
are cleared; then pass the corresponding error state into each InputField's
error prop so users see validation messages; keep the newParams creation and
updateModelConfig call but only proceed when there are no validation errors and
still allow null for intentionally blank fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/unit/providers/management/test_local_models.py`:
- Around line 246-251: The test test_all_none_by_default is missing an assertion
that the LocalModelParams.repeat_penalty field defaults to None; update the
test_all_none_by_default to include an assertion that params.repeat_penalty is
None so the test verifies all fields on LocalModelParams (e.g., num_ctx,
num_gpu_layers, num_threads, num_batch, repeat_penalty) are None by default.
- Around line 88-196: Add a new unit test that verifies
OllamaModelManager.pull_model handles httpx transport errors by yielding a
single terminal error event: create a test named test_pull_transport_error that
mocks httpx.AsyncClient.stream to raise an httpx.ConnectError (or
httpx.HTTPError) via MagicMock/side_effect, instantiate OllamaModelManager with
that client, collect events from manager.pull_model("test-model"), and assert
there's exactly one event with a non-None error and done is True; reference the
existing test helpers _make_stream_cm/_make_aiter and the class
OllamaModelManager to place the test alongside the other
TestOllamaModelManagerPull methods.

---

Outside diff comments:
In `@src/synthorg/api/dto_providers.py`:
- Around line 407-433: When config.preset_name is provided but
get_preset(config.preset_name) returns None the code silently falls back to
False for capabilities; update the logic around get_preset and the preset
variable to detect this case and emit a debug or warning log (including the
requested preset name) before constructing the ProviderResponse
(functions/variables: get_preset, config.preset_name, preset, ProviderResponse)
so mis-typed or missing presets are visible in logs while preserving the
existing fallback behavior.

---

Duplicate comments:
In `@web/src/api/endpoints/providers.ts`:
- Around line 181-188: The 401 branch in the pull response handling currently
only clears localStorage keys; update it to mirror the shared logout flow used
in client.ts by also dynamically importing and calling
useAuthStore.getState().logout() so Zustand auth state is cleared; keep the
existing removals of 'auth_token', 'auth_token_expires_at', and
'auth_must_change_password', and ensure this logic is placed in the same 401
conditional inside the response handling in providers.ts so the UI state and
storage are synchronized on 401.

In `@web/src/pages/providers/ModelConfigDrawer.tsx`:
- Around line 34-58: The handleSave logic currently uses
parseIntStrict/parseFloatStrict to turn invalid numeric strings into null
without user feedback; update handleSave to collect per-field parse errors
(e.g., errors.numCtx, errors.numGpuLayers, errors.numThreads, errors.numBatch,
errors.repeatPenalty) when parseIntStrict/parseFloatStrict returns null for
non-empty input, set those errors into component state, and prevent calling
updateModelConfig until errors are cleared; then pass the corresponding error
state into each InputField's error prop so users see validation messages; keep
the newParams creation and updateModelConfig call but only proceed when there
are no validation errors and still allow null for intentionally blank fields.
🪄 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: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 77223a6c-8665-4946-a4d4-06091fd587d2

📥 Commits

Reviewing files that changed from the base of the PR and between fa3da85 and 6f7c30c.

📒 Files selected for processing (5)
  • src/synthorg/api/dto_providers.py
  • tests/unit/providers/management/test_local_models.py
  • tests/unit/providers/management/test_service_local_models.py
  • web/src/api/endpoints/providers.ts
  • web/src/pages/providers/ModelConfigDrawer.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Dashboard Test
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Build Backend
  • GitHub Check: Build Web
🧰 Additional context used
📓 Path-based instructions (7)
web/src/**/*.{tsx,ts}

📄 CodeRabbit inference engine (web/CLAUDE.md)

web/src/**/*.{tsx,ts}: ALWAYS reuse existing components from web/src/components/ui/ before creating new ones (StatusBadge, MetricCard, Sparkline, SectionCard, AgentCard, DeptHealthBar, ProgressGauge, StatPill, Avatar, Button, Toast, Skeleton, EmptyState, ErrorBoundary, ConfirmDialog, CommandPalette, InlineEdit, AnimatedPresence, StaggerGroup, Drawer, InputField, SelectField, SliderField, ToggleField, TaskStatusIndicator, PriorityBadge, ProviderHealthBadge, TokenUsageBar, CodeMirrorEditor, SegmentedControl, ThemeToggle, LiveRegion, MobileUnsupportedOverlay, LazyCodeMirrorEditor, TagInput, MetadataGrid, ProjectStatusBadge, ContentTypeBadge)
Use Tailwind semantic classes (text-foreground, bg-card, text-accent, text-success, bg-danger, etc.) or CSS variables (var(--so-accent)) for colors. NEVER hardcode hex values in .tsx/.ts files.
Use font-sans or font-mono for typography (maps to Geist tokens). NEVER set fontFamily directly.
Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing. NEVER hardcode pixel values for layout spacing.
Use token variables (var(--so-shadow-card-hover), border-border, border-bright) for shadows and borders. NEVER hardcode values.
Import cn from @/lib/utils for conditional class merging in new components.
Do NOT recreate status dots inline -- use <StatusBadge> component.
Do NOT build card-with-header layouts from scratch -- use <SectionCard> component.
Do NOT create metric displays with text-metric font-bold -- use <MetricCard> component.
Do NOT render initials circles manually -- use <Avatar> component.
Do NOT create complex (>8 line) JSX inside .map() -- extract to a shared component.
TypeScript strict mode must pass type checking (run npm --prefix web run type-check); all type errors must be resolved before proceeding.
Do NOT hardcode Framer Motion transition durations -- use @/lib/motion presets.

Files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/api/endpoints/providers.ts
web/src/**/*.{tsx,ts,css}

📄 CodeRabbit inference engine (web/CLAUDE.md)

Do NOT use rgba() with hardcoded values -- use design token variables.

Files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/api/endpoints/providers.ts
web/src/**/*.{tsx,ts,js,jsx}

📄 CodeRabbit inference engine (web/CLAUDE.md)

ESLint must enforce zero warnings (run npm --prefix web run lint); all warnings must be fixed before proceeding.

Files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
  • web/src/api/endpoints/providers.ts
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: No from __future__ import annotations — Python 3.14 has PEP 649 native lazy annotations.
Use except A, B: (no parentheses) for PEP 758 except syntax on Python 3.14.
Type hints required on all public functions; mypy strict mode enforced.
Docstrings required on public classes and functions using Google style; enforced by ruff D rules.
Create new objects instead of mutating existing ones. For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction + MappingProxyType wrapping for read-only enforcement.
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, serializing for persistence).
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (using model_copy(update=...)) for runtime state that evolves (e.g. agent execution state, task progress). Never mix static config fields with mutable runtime fields in one model.
Use Pydantic v2 (BaseModel, model_validator, computed_field, ConfigDict). In all ConfigDict declarations, use allow_inf_nan=False to reject NaN/Inf in numeric fields at validation time.
Use @computed_field for derived values instead of storing and validating redundant fields (e.g. TokenUsage.total_tokens).
Use NotBlankStr (from core.types) for all identifier/name fields — including optional (NotBlankStr | None) and tuple (tuple[NotBlankStr, ...]) variants — instead of manual whitespace validators.
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (e.g. multiple tool invocations, parallel agent calls). Prefer structured concurrency over bare create_task.
Line length maximum is 88 characters, enforced by ruff.
Functions must be < 50 lines; files < 800 lines.
Handle errors explicitly; never silently swallow exceptions.
Validate at system boundaries (use...

Files:

  • tests/unit/providers/management/test_service_local_models.py
  • src/synthorg/api/dto_providers.py
  • tests/unit/providers/management/test_local_models.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow markers on test functions.
Global test timeout: 30 seconds per test (configured in pyproject.toml). Do not add per-file pytest.mark.timeout(30) markers; non-default overrides like timeout(60) are allowed.
Prefer @pytest.mark.parametrize for testing similar cases.
Tests must use test-provider, test-small-001, etc. instead of real vendor names.
Use Hypothesis for property-based testing in Python: @given + @settings. Profiles configured in tests/conftest.py: ci (deterministic, max_examples=10 + derandomize=True — fixed seed per test), dev (1000 examples), fuzz (10,000 examples, no deadline). Controlled via HYPOTHESIS_PROFILE env var.

Files:

  • tests/unit/providers/management/test_service_local_models.py
  • tests/unit/providers/management/test_local_models.py

⚙️ CodeRabbit configuration file

Test files do not require Google-style docstrings on classes or functions -- ruff D rules are only enforced on src/. A bare @settings() decorator with no arguments on Hypothesis property tests is a no-op and should not be suggested -- the HYPOTHESIS_PROFILE env var controls example counts via registered profiles, which @given() honors automatically.

Files:

  • tests/unit/providers/management/test_service_local_models.py
  • tests/unit/providers/management/test_local_models.py
src/synthorg/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/synthorg/**/*.py: Every module with business logic MUST have: from synthorg.observability import get_logger then logger = get_logger(__name__).
Never use import logging / logging.getLogger() / print() in application code. Exceptions: observability/setup.py, observability/sinks.py, observability/syslog_handler.py, and observability/http_handler.py may use stdlib logging and print(..., file=sys.stderr) for handler construction, bootstrap, and error reporting.
Use event name constants from domain-specific modules under synthorg.observability.events (e.g., API_REQUEST_STARTED from events.api, TOOL_INVOKE_START from events.tool). Import directly: from synthorg.observability.events.<domain> import EVENT_CONSTANT.
Always use structured kwargs in logging: logger.info(EVENT, key=value) — never logger.info("msg %s", val).
All error paths must log at WARNING or ERROR with context before raising.
All state transitions must log at INFO level.
DEBUG logging for object creation, internal flow, and entry/exit of key functions.
Retryable errors (is_retryable=True): RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError. Non-retryable errors raise immediately without retry.
Never use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: example-provider, example-large-001, example-medium-001, example-small-001, large/medium/small as aliases.

Files:

  • src/synthorg/api/dto_providers.py
src/**/*.py

⚙️ CodeRabbit configuration file

This project uses Python 3.14+ with PEP 758 except syntax: "except A, B:" (comma-separated, no parentheses) is correct and mandatory -- do NOT flag it as a typo or suggest parenthesized form. The "except builtins.MemoryError, RecursionError: raise" pattern is intentional project convention for system-error propagation. When evaluating the 50-line function limit, count only the function body excluding the signature lines, decorators, and docstring. Functions 1-5 lines over due to docstrings or multi-line signatures should not be flagged. Do not suggest extracting single-use helper functions called exactly once -- this reduces readability without improving maintainability.

Files:

  • src/synthorg/api/dto_providers.py
🧠 Learnings (21)
📓 Common learnings
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.
📚 Learning: 2026-04-01T14:22:06.315Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T14:22:06.315Z
Learning: Applies to {**/*.py,web/src/**/*.{ts,tsx}} : Validate at system boundaries (user input, external APIs, config files)

Applied to files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
📚 Learning: 2026-04-03T10:54:21.472Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: web/CLAUDE.md:0-0
Timestamp: 2026-04-03T10:54:21.472Z
Learning: Applies to web/src/**/*.{tsx,ts} : Use density-aware tokens (`p-card`, `gap-section-gap`, `gap-grid-gap`) or standard Tailwind spacing. NEVER hardcode pixel values for layout spacing.

Applied to files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
📚 Learning: 2026-03-27T22:32:26.927Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-27T22:32:26.927Z
Learning: Applies to web/src/**/*.{tsx,ts} : Use density-aware tokens (p-card, gap-section-gap, gap-grid-gap) or standard Tailwind spacing; never hardcode pixel values for layout spacing

Applied to files:

  • web/src/pages/providers/ModelConfigDrawer.tsx
📚 Learning: 2026-03-20T08:28:32.845Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T08:28:32.845Z
Learning: Applies to src/synthorg/providers/**/*.py : Providers: LLM provider abstraction (LiteLLM adapter), auth types (api_key/oauth/custom_header/none), presets (PROVIDER_PRESETS), runtime CRUD (ProviderManagementService with asyncio.Lock serialization), hot-reload via AppState swap.

Applied to files:

  • tests/unit/providers/management/test_service_local_models.py
  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-02T21:38:30.127Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T21:38:30.127Z
Learning: Applies to {**/*.py,web/src/**/*.{ts,tsx}} : Handle errors explicitly; never silently swallow exceptions

Applied to files:

  • web/src/api/endpoints/providers.ts
📚 Learning: 2026-03-31T21:07:37.469Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-31T21:07:37.469Z
Learning: Applies to src/synthorg/providers/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-03T16:11:48.610Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T16:11:48.610Z
Learning: Applies to src/synthorg/providers/**/*.py : `RetryConfig` and `RateLimiterConfig` are set per-provider in `ProviderConfig`.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-20T11:18:48.128Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-20T11:18:48.128Z
Learning: Applies to src/synthorg/**/*.py : Set `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-03T16:11:48.610Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T16:11:48.610Z
Learning: Applies to src/synthorg/**/*.py : Never use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: `example-provider`, `example-large-001`, `example-medium-001`, `example-small-001`, `large`/`medium`/`small` as aliases.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-01T15:36:39.993Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T15:36:39.993Z
Learning: Applies to {src/synthorg/**/*.py,tests/**/*.py,web/src/**/*.{ts,tsx}} : NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples — use generic names: `example-provider`, `example-large-001`, `example-medium-001`, `example-small-001`, `large`/`medium`/`small`

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-01T15:36:39.993Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T15:36:39.993Z
Learning: Applies to {src,tests}/**/*.py : Vendor-agnostic everywhere: NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: `example-provider`, `example-large-001`, `example-medium-001`, `example-small-001`, `large`/`medium`/`small` as aliases

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-15T18:28:13.207Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:28:13.207Z
Learning: Applies to {src,tests,web,cli,site}/**/*.{py,ts,tsx,go,astro} : Vendor-agnostic everywhere: NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples. Use generic names: example-provider, example-large-001, example-medium-001, example-small-001. Vendor names may only appear in: (1) Operations design page provider list (docs/design/operations.md), (2) .claude/ skill/agent files, (3) third-party import paths/module names.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-15T19:14:27.144Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T19:14:27.144Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 BaseModel, model_validator, computed_field, ConfigDict.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-15T18:42:17.990Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-15T18:42:17.990Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-17T22:08:13.456Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T22:08:13.456Z
Learning: Applies to src/synthorg/**/*.py : Use Pydantic v2 conventions: `BaseModel`, `model_validator`, `computed_field`, `ConfigDict`. For derived values use `computed_field` instead of storing + validating redundant fields. Use `NotBlankStr` (from `core.types`) for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-03T16:11:48.610Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-03T16:11:48.610Z
Learning: Applies to **/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`). In all `ConfigDict` declarations, use `allow_inf_nan=False` to reject `NaN`/`Inf` in numeric fields at validation time.

Applied to files:

  • src/synthorg/api/dto_providers.py
  • tests/unit/providers/management/test_local_models.py
📚 Learning: 2026-04-02T11:49:21.202Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-02T11:49:21.202Z
Learning: Applies to **/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`) with `allow_inf_nan=False` in all `ConfigDict` declarations

Applied to files:

  • src/synthorg/api/dto_providers.py
  • tests/unit/providers/management/test_local_models.py
📚 Learning: 2026-03-16T19:13:36.562Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-16T19:13:36.562Z
Learning: Applies to src/synthorg/providers/**/*.py : RetryConfig and RateLimiterConfig are set per-provider in ProviderConfig.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-03-17T11:41:02.964Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-17T11:41:02.964Z
Learning: Applies to src/**/*.py : Models: Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`). Use `computed_field` for derived values instead of storing + validating redundant fields. Use `NotBlankStr` for all identifier/name fields — including optional (`NotBlankStr | None`) and tuple (`tuple[NotBlankStr, ...]`) variants — instead of manual whitespace validators.

Applied to files:

  • src/synthorg/api/dto_providers.py
📚 Learning: 2026-04-01T15:36:39.993Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-01T15:36:39.993Z
Learning: Applies to src/**/*.py : Use Pydantic v2 (`BaseModel`, `model_validator`, `computed_field`, `ConfigDict`). Use `allow_inf_nan=False` in all `ConfigDict` declarations to reject `NaN`/`Inf` in numeric fields at validation time

Applied to files:

  • src/synthorg/api/dto_providers.py
  • tests/unit/providers/management/test_local_models.py
🔇 Additional comments (29)
web/src/api/endpoints/providers.ts (4)

118-121: LGTM!

The encodeModelIdPath helper correctly preserves / delimiters for wildcard path params while encoding individual segments, enabling model IDs like llama3:latest or org/model to be safely included in URL paths.


123-153: LGTM!

The SSE parsing logic correctly:

  1. Calls onProgress(parsed) before throwing on error events (line 138), ensuring the store receives terminal error payloads
  2. Handles SyntaxError separately (lines 144-147), allowing malformed JSON frames to be logged and skipped without aborting the stream
  3. Rethrows other Error instances to propagate intentional errors

225-230: LGTM!

The deleteModel function correctly uses encodeModelIdPath for model IDs containing / and follows the same pattern as deleteProvider.


232-242: LGTM!

The updateModelConfig function correctly wraps parameters as { local_params: params }, matching the UpdateModelConfigRequest schema expected by the backend controller.

web/src/pages/providers/ModelConfigDrawer.tsx (3)

1-14: LGTM!

Clean imports reusing existing UI components, and the props interface correctly allows model to be null for the closed drawer state.


60-107: LGTM!

The form follows coding guidelines: uses gap-section-gap density token, reuses InputField and Button components, and properly handles the saving state with disabled buttons and loading text.


109-126: LGTM!

The drawer wrapper correctly uses key={model.id} to reset form state when switching between models, and conditionally renders the form only when a model is selected.

src/synthorg/api/dto_providers.py (7)

12-15: LGTM!

Import additions for LocalModelParams and ProviderModelConfig are correctly placed with the noqa: TC001 annotation for runtime type checking needs.


68-71: LGTM!

The local_params field is properly typed as optional with a sensible None default, allowing models without local launch parameters to remain unaffected.


165-165: LGTM!

The preset_name field correctly uses NotBlankStr | None for optional preset association.


305-308: LGTM!

The capability flags default to False, which is the safe and correct default for providers without explicit capability declarations.


463-463: LGTM!

The local_params field is correctly passed through from the model config to the response.


479-505: LGTM!

The PullModelRequest DTO has proper validation:

  • Regex pattern covers common model identifier formats (tags, org prefixes, versions)
  • NotBlankStr ensures non-empty input
  • max_length=256 provides reasonable bounds
  • Docstring example uses a vendor-agnostic identifier

508-517: LGTM!

The UpdateModelConfigRequest correctly requires local_params as a mandatory field, ensuring the update always has configuration data.

tests/unit/providers/management/test_local_models.py (5)

1-17: LGTM!

File setup is correct with proper imports, unit test marker applied at module level, and vendor-agnostic test identifiers.


20-63: LGTM!

Comprehensive test coverage for PullProgressEvent:

  • Immutability (frozen model)
  • Default values
  • Terminal-error invariant (error requires done=True)
  • Bytes ordering invariant (completed_bytes <= total_bytes)

66-85: LGTM!

Factory tests cover all expected cases: Ollama returns a manager, LM Studio/vLLM return None (not yet implemented), and unknown/None presets safely return None.


199-235: LGTM!

Delete tests verify correct request construction (method, URL, JSON body, timeout) and proper error handling for 404 responses.


263-278: The review comment is based on a misunderstanding. num_batch is typed as int | None, not float. The allow_inf_nan=False ConfigDict setting applies specifically to float field validation; integer fields cannot hold NaN/Inf by definition. The test already covers integer field type coercion failure via num_ctx, num_gpu_layers, and num_threads. Adding num_batch would not meaningfully test allow_inf_nan=False — it would only duplicate existing int field behavior. The float field repeat_penalty is the only field where allow_inf_nan=False validation actually applies, and it is already tested.

			> Likely an incorrect or invalid review comment.
tests/unit/providers/management/test_service_local_models.py (10)

1-17: LGTM!

File setup is correct with proper imports and unit test marker. Uses vendor-agnostic provider and model names.


22-38: LGTM!

The helper method cleanly encapsulates Ollama provider creation with mocked discovery, avoiding code duplication across tests.


40-74: LGTM!

Test correctly verifies that pull_model delegates to the local model manager with the correct model name, and that events are properly yielded.


76-90: LGTM!

Test verifies that providers without pull capability (no preset_name or unsupported preset) correctly raise ProviderValidationError. This confirms capability gating happens at the service layer.


92-115: LGTM!

Test correctly verifies that delete_model:

  1. Delegates to the local model manager with the correct model ID
  2. Triggers model discovery refresh afterward

117-136: LGTM!

Test verifies that update_model_config correctly persists LocalModelParams on the target model configuration.


138-152: LGTM!

Test correctly verifies that updating configuration for a nonexistent model raises ProviderValidationError.


155-194: LGTM!

Comprehensive tests for the bug fix ensuring local presets (auth_type=NONE) skip models_from_litellm:

  • Parametrized test covers all local presets (ollama, lm-studio, vllm)
  • Verifies models come from discovery instead
  • Patch target correctly references the service module's import

196-217: LGTM!

Test correctly verifies that cloud presets (with auth_type != NONE) continue using models_from_litellm for model population.


219-243: LGTM!

Test correctly verifies that user-provided models bypass both models_from_litellm and discover_models, ensuring explicit configuration is honored.

- Assert repeat_penalty is None in test_all_none_by_default
- Add test_pull_transport_error: httpx.ConnectError yields terminal
  error event with done=True and error message
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 3, 2026 17:09 — with GitHub Actions Inactive
@Aureliolo Aureliolo merged commit e1b14d3 into main Apr 3, 2026
32 checks passed
@Aureliolo Aureliolo deleted the feat/local-model-management branch April 3, 2026 17:14
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview April 3, 2026 17:14 — with GitHub Actions Inactive
Aureliolo added a commit that referenced this pull request Apr 3, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.6.0](v0.5.9...v0.6.0)
(2026-04-03)


### Features

* dashboard UI for ceremony policy settings
([#1038](#1038))
([865554c](865554c)),
closes [#979](#979)
* implement tool-based memory retrieval injection strategy
([#1039](#1039))
([329270e](329270e)),
closes [#207](#207)
* local model management for Ollama and LM Studio
([#1037](#1037))
([e1b14d3](e1b14d3)),
closes [#1030](#1030)
* workflow execution -- instantiate tasks from WorkflowDefinition
([#1040](#1040))
([e9235e3](e9235e3)),
closes [#1004](#1004)


### Maintenance

* shared Hypothesis failure DB + deterministic CI profile
([#1041](#1041))
([901ae92](901ae92))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
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.

feat: local model management for Ollama and LM Studio (browse, pull, configure)

2 participants