"""LM Studio provider implementation (OpenAI-compatible chat completions). Switched from LM Studio's native Anthropic Messages endpoint (2026-07-04): the newer ``/v1/messages`` path renders Claude Code conversations through the model's jinja chat template with strict role-alternation rules and a fragile ``[TOOL_CALLS]`` parser — observed leaking control tokens into tool names (``[TOOL_CALLS]Read``) and dumping whole tool calls into text (``Read[ARGS]{...}``), which ends agent runs silently. The OpenAI ``/v1/chat/completions`` path is LM Studio's mature parsing route, and fcc's OpenAI provider layers its own tool-call assembly, think-tag parsing, and heuristic recovery on top. """ import time import httpx from loguru import logger from free_claude_code.core.anthropic import ReasoningReplayMode, get_token_count from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.core.reasoning import ( DEFAULT_REASONING_POLICY, ReasoningEffort, ReasoningPolicy, ) from free_claude_code.providers.admission import ProviderAdmissionController from free_claude_code.providers.base import ProviderConfig from free_claude_code.providers.failure_policy import ( context_window_exceeded_provider_failure, ) from free_claude_code.providers.openai_chat import ( NamedEffortReasoning, OpenAIChatProfile, OpenAIChatProvider, OpenAIChatRequestPolicy, ) _PROFILE = OpenAIChatProfile( OpenAIChatRequestPolicy( provider_name="LMSTUDIO", reasoning_replay=ReasoningReplayMode.DISABLED, ), NamedEffortReasoning( ( (ReasoningEffort.MINIMAL, "low"), (ReasoningEffort.LOW, "low"), (ReasoningEffort.MEDIUM, "medium"), (ReasoningEffort.HIGH, "high"), (ReasoningEffort.XHIGH, "high"), (ReasoningEffort.MAX, "high"), ), disabled_value="none", enabled_value="high", budget_field="reasoning_tokens", ), ) class LMStudioProvider(OpenAIChatProvider): """LM Studio via its OpenAI-compatible chat completions endpoint.""" # LM Studio truncates the stream silently (no terminal event) when the # prompt exceeds the loaded context. Refuse clearly over-budget prompts # up front as a context-window failure so protocol adapters can tell their # clients to compact/retry instead of letting the stream die silently. _CONTEXT_CACHE_TTL_S = 30.0 def __init__( self, config: ProviderConfig, *, admission: ProviderAdmissionController ): super().__init__( config, profile=_PROFILE, admission=admission, ) self._loaded_context_cache: tuple[float, int | None] = (0.0, None) def preflight_stream( self, request: MessagesRequest, *, reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY, ) -> None: super().preflight_stream(request, reasoning=reasoning) self._preflight_context_budget(request) def _preflight_context_budget(self, request: MessagesRequest) -> None: loaded_context = self._loaded_context_length() if loaded_context is None: return estimate = get_token_count( request.messages, request.system, request.tools, ) # The estimate is cl100k-based and undercounts local tokenizers # (observed ~8% low vs devstral); a request above 90% of the loaded # context is already past where client-side compaction should have # fired, and letting it through risks a silent LM Studio truncation. budget = int(loaded_context * 0.9) if estimate > budget: raise context_window_exceeded_provider_failure( f"Estimated provider input ({estimate} tokens) exceeds the safe " f"LM Studio context budget ({budget} tokens; 90% of loaded " f"context {loaded_context})." ) def _loaded_context_length(self) -> int | None: """Best-effort loaded context length from LM Studio's REST API, cached.""" cached_at, cached_value = self._loaded_context_cache if time.monotonic() - cached_at < self._CONTEXT_CACHE_TTL_S: return cached_value value: int | None = None try: root = self._base_url root = root[: -len("/v1")] if root.endswith("/v1") else root response = httpx.get(f"{root}/api/v0/models", timeout=2.0) response.raise_for_status() loaded = [ model.get("loaded_context_length") for model in response.json().get("data", []) if model.get("state") == "loaded" and isinstance(model.get("loaded_context_length"), int) ] # ponytail: single-model setups in practice; with several loaded # models the most generous ceiling still makes a valid backstop. value = max(loaded) if loaded else None except Exception as error: # backstop only — never block the request logger.debug( "LMSTUDIO context preflight unavailable: {}", type(error).__name__ ) value = None self._loaded_context_cache = (time.monotonic(), value) return value