Skip to content

U3 — Providers & config (victor/providers/ + victor/config/)

134 modules / 41,015 LOC / 1,660 churn commits (12m). Reviewer: parallel unit review, 2026-09-03.

Overview

The 25-adapter story is better than the file count suggests: since TD-0002, most adapters are thin policy shells (xai 32 LOC, zai 99, deepseek 140) over a genuine kernel+dialect core (HttpxOpenAICompatProvider payload/parse template + SandhiTypedProviderMixin transport); only 6-7 "native-only" adapters still own raw HTTP. The recent sandhi idempotency work (#991/#992) is correct and verified. The real debt is concentrated in: an uncached Settings() god-module that gets re-built per LLM request, an Infrastructure→Runtime layering leak nothing guards, and three uncoordinated retry/circuit layers whose defaults contradict each other.

Findings

ID Sev Location Finding Why it matters Fix sketch Effort
F1 P1 victor/providers/sandhi_transport.py:429,459; victor/config/settings.py:2573-2583 _include_native_response() calls get_settings() inside _typed_request_from_openai_payload — a full Settings() construction (measured ~4.5-5.5 ms, .env disk read + double os.environ scan + 155-field pydantic validation) on every chat and stream request Pure CPU+I/O on the LLM request path, ~5 ms/turn tax and .env read per call; 54 more get_settings() call sites compound it Cache the snapshot (module-level lru_cache + invalidation via existing Settings.notify_change listeners settings.py:746, or read once at provider construction) S
F2 P1 victor/providers/base.py:421; ollama_provider.py:207; vllm_provider.py:267; llamacpp_provider.py:259; victor/config/classification_settings.py:16; decision_settings.py:24 Infrastructure layer imports victor.agent.*: get_tool_output_format imports victor.agent.format_strategies (4 providers); config modules import victor.agent.services.* at module scope victor.config.* is an explicitly allowed import surface for external verticals — importing a settings module transitively drags the whole runtime; no guard covers providers/config→agent Move ToolOutputFormat to victor.framework/contracts; make the two config imports TYPE_CHECKING + string annotations; add a guard test M
F3 P1 victor/providers/resilience.py:299-307 vs base.py:1170-1174; base.py:231; circuit_breaker.py:430-458 Retry layers contradict and multiply: ResilientProvider's default ProviderRetryConfig lists CircuitBreakerError/CircuitOpenError as retryable (base.py deliberately excludes them to fail fast); native-only adapters stack base _execute_with_circuit_breaker retry inside ResilientProvider retry inside SDK/Rust-handle retries; worst case 3×3+ attempts × 120 s wait_for each (resilience.py:824-827). Breaker names embed id(self) so state never aggregates across instances; CircuitBreakerRegistry._breakers never evicts An open circuit gets slept-through instead of failing over; one logical call can retry for many minutes; breaker semantics diluted by instance churn while the registry grows unboundedly Make CircuitOpen non-retryable in ResilientProvider; key breakers by (name, base_url) with eviction; collapse to one retry owner (Rust handle) + one fallback owner (ResilientProvider) M
F4 P2 victor/providers/deepseek_provider.py:118-137 DeepSeekProvider.stream buffers every chunk before yielding anything (to detect DSML tool calls), then replays DeepSeek streaming has zero incremental delivery: TTFT = full generation time Pass chunks through until _DSML_OPEN marker appears; buffer only then S
F5 P2 victor/providers/base.py:380-396 vs base.py:966-1097 context_window defined twice on BaseProvider; the second silently shadows the first, so the Optional[model]/DEFAULT_CONTEXT_WINDOW contract at :380 is dead; wildcard loop at :1081-1089 is dead (no "*" keys) fan_in=123 module whose most-queried capability method has a misleading duplicate; subclasses override against the shadowed signature Delete the first definition; fold DEFAULT_CONTEXT_WINDOW into the survivor; drop the dead wildcard loop S
F6 P2 timeouts.py:70; settings.py:374; base.py:197; httpx_openai_compat.py:88; zai_provider.py:21; deepseek_provider.py:27-29; resilience.py:708; sandhi_transport.py:786,822 Eight independent timeout constants govern one LLM call (300/300/60/60/300/120/120/90+5s grace); nothing derives or validates ordering User sets one knob, four others disagree; effective timeout is an accident of which layer fires first Derive all from one TimeoutConfig profile threaded SessionConfig → provider kwargs → handle; assert ordering at construction M
F7 P2 victor/config/settings.py:704-725,798-971 (147 mappings),573-695 Settings god module: 2,790 LOC, 155 fields, 46 nested groups, 147 legacy env mappings; model_validator(mode="before") scans os.environ twice per construction Highest churn×fan-in product in the unit; every PR collides here Make _NESTED_GROUPS + legacy mappings declarative (single registry driving imports and env mapping); split Settings aggregation from ProjectPaths (lines 89-332) L
F8 P2 victor/providers/stream_adapter.py (770 LOC); metrics_export.py; cache_audit.py; httpx_openai_compat.py:117-122 Dead weight: stream_adapter.py and metrics_export.py imported only by their own tests, cache_audit.py by nothing; HttpxOpenAICompatProvider.__init__ eagerly builds an httpx.AsyncClient for stub shells (only sandhi passes initialize_http_client=False) 770+ LOC of stale adapter surface; each policy-shell instantiation allocates an unused socket pool Delete or shim; flip initialize_http_client default to False S
F9 P2 base.py:549-581 vs resilience.py:568-607; base.py:450-459 vs resilience.py:310-320 Failure taxonomy duplicated: identical hard-rate-limit token tuples and connection-exception name sets copied between classify_error and ProviderRetryConfig, each with its own exception-chain walker Drift between "what classifies as hard-limit" and "what retries" silently changes outage behavior One shared module (victor/core/errors or providers/failure_taxonomy) with the tables and chain iterator S
F10 P3 circuit_breaker.py:222,270,288 Circuit breaker uses wall-clock time.time() for recovery timing NTP step/manual clock change can pin a breaker open or skip recovery Switch to time.monotonic() (resilience.py:205 assumes the same base — change both) S
F11 P3 httpx_openai_compat.py:294-312 _build_request_payload emits an INFO log with full tool-name list + system preview on every call, plus duplicate DEBUG lines Violates INFO=user-meaningful-events policy (PR #514); at 48 tools/call this is the hot-path log volume driver Demote to DEBUG; compute preview lazily S
F12 P3 resilience.py:1008-1017 Stream-fallback path appends a raw dict into a List[Message] Works only because build_openai_messages duck-types; any typed consumer breaks mid-fallback — the worst moment Wrap in Message(role="assistant", content=collected) S
F13 P3 sandhi_transport.py:781-792 Gateway mode puts run_id in the handle cache key, so the Rust handle (connection pool + circuit state) is rebuilt every agent run Per-call wire_headers_json already exists (:360-421) — run-id could ride per-call like step-id, keeping pools warm Move x-sandhi-run-id/x-sandhi-session to per-call headers; drop run_id from cache key M

Co-design opportunities

  1. Config snapshot ⇄ streaming-first runtime: immutable per-session settings snapshot (config exports once; providers receive at construction).
  2. One retry owner: Rust sandhi handle retries transport blips; Python owns logical fallback; delete the base wrapper once native-only adapters migrate.
  3. Failure taxonomy as a shared kernel type consumed by classification, retryability, and health scoring (smart_router._score_health).
  4. Cache-economics → timeout co-design: extend the CacheCostModel value-object pattern (base.py:149-179) to a TimeoutProfile.
  5. Kernel+dialects completion: port azure/vertex/huggingface to the policy-shell pattern or formally freeze them as tier-3 (ADR-029).
  6. Settings aggregation as data: declarative registry for 46 groups + 147 env mappings.

Keep intact

  1. Chat-only idempotency binding (resilience.py:803-811; streams deliberately do not bind — :957-968 documents the async-generator ContextVar leak). Verified: retries AND fallbacks of one logical call share the key. Do not "fix" the stream asymmetry.
  2. Session affinity in neutral metadata, never the body (sandhi_transport.py:530-537) — preserves prompt-cache byte-stability and vendor KV-cache mapping.
  3. No silent transport fallback (sandhi_transport.py:88-109) — missing binding is an installation error; replaying after FFI failure can duplicate billed requests.
  4. Ollama _get_client event-loop recreation (ollama_provider.py:125-141) — handles run_sync-created loops; removing the loop-id check resurrects the dead-pool failures (#490 cluster).
  5. aclosing discipline on stream chains (base.py:946-948, resilience.py:999) — the GC-driven GeneratorExit spam fix.

Quick metrics

  • Settings(): 155 fields, 46 nested groups, 147 legacy env mappings; 117 ms cold, ~4.5-5.5 ms warm per construction; uncached get_settings alias with 54 call sites.
  • Adapter thinness (post-TD-0002): xai 32 LOC, groq 113, zai 99, cerebras 158, deepseek 140 — vs base.py 1,190, sandhi_transport 1,248, resilience 1,084, factory 803, registry 577.
  • Only azure/vertex/huggingface still call _execute_with_circuit_breaker (3 of 55 modules).
  • base.py duplicate context_window verified: :380 and :966 (second shadows first); wildcard loop :1081-1089 iterates a table with zero "*" keys.
  • Sandhi handshake: KNOWN_CONTRACT_MINOR = 6 with fail-soft one-time check (sandhi_transport.py:195-233) — consistent with the minor-6 pin.