dheraingoud's picture
feat: synchronize proxy with upstream commits up to 5305bd7 and integrate NIM key rotator
0a54372
Raw
History Blame Contribute Delete
5.35 kB
"""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