feat(decisions): support LM Studio decision models (#48448)

* feat(decisions): support LM Studio decision models

Co-Authored-By: Codex <noreply@openai.com>

* fix(ci): resolve OSV dependency findings

Upgrade the extension MSAL dependency and document expiring exceptions for unused Windows filelock APIs while Python 3.10 remains supported.

Co-Authored-By: Codex <noreply@openai.com>

* Clean up ignored vulnerabilities in osv-scanner.tomlchore: remove unrelated dependency and scanner changes

Removed ignored vulnerabilities related to Windows issues and Python version compatibility.

* chore: remove unrelated dependency and scanner changes

* chore: remove unrelated dependency and scanner changes

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: ishaan-j-berri <ishaan.j@berri.ai>
This commit is contained in:
Jodok Batlogg 2026-10-27 03:24:09 +01:00 • committed by GitHub
parent 3b8ad37296
commit e16d79d635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 209 additions and 4 deletions

View file

@ -1729,6 +1729,9 @@ if TYPE_CHECKING:
from .llms.hosted_vllm.decisions.transformation import (
HostedVLLMDecisionsConfig as HostedVLLMDecisionsConfig,
)
from .llms.lm_studio.decisions.transformation import (
LMStudioDecisionsConfig as LMStudioDecisionsConfig,
)
from .llms.laya.decisions.transformation import (
LayaDecisionsConfig as LayaDecisionsConfig,
)

View file

@ -170,6 +170,7 @@ LLM_CONFIG_NAMES: Final = (
"StrandsDeciderDecisionsConfig",
"DatabricksDecisionsConfig",
"HostedVLLMDecisionsConfig",
"LMStudioDecisionsConfig",
"LayaDecisionsConfig",
"BespokeDecisionsConfig",
"OpenAIDecisionsConfig",
@ -746,6 +747,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.hosted_vllm.decisions.transformation",
"HostedVLLMDecisionsConfig",
),
"LMStudioDecisionsConfig": (".llms.lm_studio.decisions.transformation", "LMStudioDecisionsConfig"),
"LayaDecisionsConfig": (".llms.laya.decisions.transformation", "LayaDecisionsConfig"),
"BespokeDecisionsConfig": (".llms.bespoke.decisions.transformation", "BespokeDecisionsConfig"),
"OpenAIDecisionsConfig": (".llms.openai.decisions.transformation", "OpenAIDecisionsConfig"),

View file

@ -0,0 +1,16 @@
from typing import Final
from litellm.constants import PLACEHOLDER_API_KEY
from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig
class LMStudioDecisionsConfig(BaseDecisionsConfig):
api_key_env = ("LM_STUDIO_API_KEY",)
api_base_env = ("LM_STUDIO_API_BASE",)
api_key_required = False
supports_images = True
inlines_remote_images = True
def resolve_api_key(self, api_key: str | None) -> str | None:
resolved: Final = super().resolve_api_key(api_key)
return None if resolved == PLACEHOLDER_API_KEY else resolved

View file

@ -9610,6 +9610,8 @@ class ProviderConfigManager:
return litellm.DatabricksDecisionsConfig()
if provider == LlmProviders.HOSTED_VLLM:
return litellm.HostedVLLMDecisionsConfig()
if provider == LlmProviders.LM_STUDIO:
return litellm.LMStudioDecisionsConfig()
if provider == LlmProviders.LAYA:
return litellm.LayaDecisionsConfig()
if provider == LlmProviders.BESPOKE:

View file

@ -38,6 +38,7 @@ from litellm.types.decisions import (
from litellm.types.router import ModelInfo
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
from tests.unit.conftest import AsyncOnlyImageFetch
_QUESTIONS: Final[Mapping[str, object]] = MappingProxyType(
{
@ -682,7 +683,9 @@ _PPLX_USAGE: Final = {"model": "pplx-decider-v1-27b", "usage": {"input_tokens":
("reply", "detail"),
(
pytest.param(_PPLX_USAGE, "DecisionsResponse answers: Field required", id="usage"),
pytest.param({"error": "Rate limit reached"}, "DecisionsResponse answers: Field required", id="rate_limit_text"),
pytest.param(
{"error": "Rate limit reached"}, "DecisionsResponse answers: Field required", id="rate_limit_text"
),
pytest.param({"error": "Request timed out"}, "DecisionsResponse answers: Field required", id="timeout_text"),
pytest.param(
{**_PPLX_USAGE, "answers": {"rate_limit": None}},
@ -694,7 +697,9 @@ _PPLX_USAGE: Final = {"model": "pplx-decider-v1-27b", "usage": {"input_tokens":
"DecisionsResponse answers.is_defect",
id="answer_type_timeout_text",
),
pytest.param(["Rate limit reached"], "DecisionsResponse Input should be a valid dictionary", id="not_an_object"),
pytest.param(
["Rate limit reached"], "DecisionsResponse Input should be a valid dictionary", id="not_an_object"
),
),
)
async def test_a_malformed_upstream_reply_is_a_server_error_whatever_its_text(
@ -1525,7 +1530,12 @@ async def test_databricks_router_deployment_sends_its_own_key_to_its_own_base(
_PROVIDERS_WHOSE_CHAT_MODELS_ALSO_DECIDE: Final = frozenset(
{LlmProviders.OPENAI.value, LlmProviders.OPENROUTER.value, LlmProviders.HOSTED_VLLM.value}
{
LlmProviders.OPENAI.value,
LlmProviders.OPENROUTER.value,
LlmProviders.HOSTED_VLLM.value,
LlmProviders.LM_STUDIO.value,
}
)
_DEDICATED_DECIDER_PROVIDERS: Final[tuple[str, ...]] = tuple(
sorted(
@ -3791,7 +3801,12 @@ async def test_a_returned_model_registered_without_prices_is_billed_at_the_reque
monkeypatch.setitem(
litellm.model_cost,
"typesafe/jev-test-alias",
{"litellm_provider": "typesafe", "mode": "evaluation", "input_cost_per_token": 0.001, "output_cost_per_token": 0},
{
"litellm_provider": "typesafe",
"mode": "evaluation",
"input_cost_per_token": 0.001,
"output_cost_per_token": 0,
},
)
monkeypatch.setitem(litellm.model_cost, "typesafe/jev-test-unpriced", unpriced_row)
respx_mock.post("https://api.typesafe.ai/v1/systemone").respond(json={**_RESPONSE, "model": "jev-test-unpriced"})
@ -3893,3 +3908,120 @@ async def test_native_decisions_distinguishes_missing_usage_from_reported_zero(
expected = litellm.completion_cost(completion_response=response)
assert response._hidden_params["response_cost"] == pytest.approx(expected)
assert (expected == 0) == (usage["input_tokens"] == usage["output_tokens"] == 0)
@pytest.mark.asyncio
@pytest.mark.parametrize("use_async", (True, False), ids=("asystemone", "systemone"))
@pytest.mark.parametrize("api_base", ("http://lmstudio.local:1234", "http://lmstudio.local:1234/v1"))
async def test_lm_studio_systemone_preserves_questions_and_omits_placeholder_auth(
use_async: bool, api_base: str, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("LM_STUDIO_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key")
route: Final = respx_mock.post("http://lmstudio.local:1234/v1/systemone").respond(json=_RESPONSE)
kwargs: Final = {
"model": "lm_studio/local-decision",
"state": "review",
"questions": _QUESTIONS,
"api_base": api_base,
}
response: Final = await litellm.asystemone(**kwargs) if use_async else litellm.systemone(**kwargs)
assert json.loads(route.calls.last.request.content) == {
"model": "local-decision",
"state": "review",
"questions": dict(_QUESTIONS),
}
assert "authorization" not in route.calls.last.request.headers
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
assert response.answers["sentiment"].choice == "positive"
assert response.answers["severity"].score == 1
assert response.usage == DecisionsUsage(input_tokens=_INPUT_TOKENS, output_tokens=_OUTPUT_TOKENS)
@pytest.mark.asyncio
@pytest.mark.parametrize("use_async", (True, False), ids=("adecisions", "decisions"))
async def test_lm_studio_decisions_translates_openai_questions_and_answers(
use_async: bool, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("LM_STUDIO_API_KEY", raising=False)
route: Final = respx_mock.post("http://lmstudio.local:1234/v1/systemone").respond(json=_RESPONSE)
kwargs: Final = {
"model": "lm_studio/local-decision",
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
"api_base": "http://lmstudio.local:1234/v1",
}
response: Final = await litellm.adecisions(**kwargs) if use_async else litellm.decisions(**kwargs)
assert json.loads(route.calls.last.request.content) == {
"model": "local-decision",
"state": "review",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert response.answers[0].type == "predicate"
assert response.answers[0].name == "is_defect"
assert response.answers[0].probability == 0.9
@pytest.mark.asyncio
@pytest.mark.parametrize("explicit_key", (None, "deployment-key"))
async def test_lm_studio_decisions_resolves_environment_connection_and_explicit_key_override(
explicit_key: str | None, monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.setenv("LM_STUDIO_API_BASE", "http://lmstudio.local:1234/v1")
monkeypatch.setenv("LM_STUDIO_API_KEY", "environment-key")
route: Final = respx_mock.post("http://lmstudio.local:1234/v1/systemone").respond(json=_RESPONSE)
response: Final = await litellm.asystemone(
model="lm_studio/local-decision", state="review", questions=_QUESTIONS, api_key=explicit_key
)
assert route.calls.last.request.headers["authorization"] == f"Bearer {explicit_key or 'environment-key'}"
assert response.answers["is_defect"] == NoulAnswer(type="noul", noul=0.9)
@pytest.mark.asyncio
async def test_lm_studio_decisions_requires_a_server_before_http(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
) -> None:
monkeypatch.delenv("LM_STUDIO_API_BASE", raising=False)
with pytest.raises(litellm.BadRequestError, match="api_base is required"):
await litellm.asystemone(model="lm_studio/local-decision", state="review", questions=_QUESTIONS)
assert len(respx_mock.calls) == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("remote_image", (False, True), ids=("inline", "remote"))
async def test_lm_studio_decisions_sends_inline_images_to_systemone(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
remote_image: bool,
async_only_image_fetch: AsyncOnlyImageFetch,
) -> None:
monkeypatch.delenv("LM_STUDIO_API_KEY", raising=False)
image: Final = "https://images.example/input.png" if remote_image else "data:image/png;base64,AA=="
expected_image: Final = async_only_image_fetch.data_url if remote_image else image
route: Final = respx_mock.post("http://lmstudio.local:1234/v1/systemone").respond(json=_RESPONSE)
response: Final = await litellm.adecisions(
model="lm_studio/local-decision",
input=[
{
"role": "user",
"content": [{"type": "input_text", "text": "review"}, {"type": "input_image", "image_url": image}],
}
],
questions=[{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
api_base="http://lmstudio.local:1234/v1",
)
assert json.loads(route.calls.last.request.content) == {
"model": "local-decision",
"state": "review",
"images": [expected_image],
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert response.answers[0].probability == 0.9

View file

@ -1784,3 +1784,53 @@ def test_a_whole_number_request_size_limit_is_named_as_configured_in_the_413(
assert response.status_code == 413, response.text
assert response.json() == {"error": "Request size is too large. Max size is 1 MB"}
assert not upstream.called
@pytest.mark.parametrize("endpoint", ("/v1/systemone", "/v1/decisions"))
@pytest.mark.parametrize("api_key", (None, "deployment-key"))
def test_proxy_routes_lm_studio_decisions_with_optional_authentication(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
endpoint: str,
api_key: str | None,
) -> None:
monkeypatch.delenv("LM_STUDIO_API_KEY", raising=False)
deployment_router: Final = litellm.Router(
model_list=[
{
"model_name": "local-decision",
"litellm_params": {
"model": "lm_studio/served-decision",
"api_base": "http://lmstudio.local:1234/v1",
"api_key": api_key,
},
}
]
)
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", deployment_router)
upstream: Final = respx_mock.post("http://lmstudio.local:1234/v1/systemone").respond(json=_RESPONSE)
fields: Final = (
{"state": "review", "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}}
if endpoint.endswith("systemone")
else {
"input": "review",
"questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}],
}
)
response: Final = client.post(endpoint, json={"model": "local-decision", **fields})
assert response.status_code == 200, response.text
assert json.loads(upstream.calls.last.request.content) == {
"model": "served-decision",
"state": "review",
"questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}},
}
assert upstream.calls.last.request.headers.get("authorization") == (f"Bearer {api_key}" if api_key else None)
expected: Final = (
_RESPONSE["answers"]
if endpoint.endswith("systemone")
else [{"type": "predicate", "name": "is_defect", "probability": 0.9}]
)
assert response.json()["answers"] == expected