mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
* feat(lite): offer each model group's reasoning efforts in pi, OpenCode and Codex lite pi, lite opencode and lite codex now read supported_reasoning_efforts from /model_group/info and write them into each client's model config. pi gets reasoning and a thinkingLevelMap, OpenCode gets reasoning_options plus one variant per effort, and Codex fallback entries get supported_reasoning_levels with a default level. The proxy-served Codex catalog on /v1/models?client_version= offers the same levels Codex before 0.138.0 fails the whole catalog on an effort it cannot name, so those releases, and an unreadable version, get only the six efforts every release parses. A group with unknown efforts keeps today's output, and a group with none is written as non-reasoning The strength order and nearest_declared_reasoning_effort move to an SDK-free module so the lite CLI, which loads without initializing the SDK, can import the Codex catalog * fix(lite): describe team models by their public name and keep Codex's none default /model_group/info now looks up a team's own deployments when the caller's team serves the requested name, so a team model reports its reasoning levels. Codex fallback entries default to none when the group lists it, and the proxy builds the Codex catalog in a worker thread * fix(lite): round Codex's default level down and describe a team's shared-name model from its own deployments Move the reasoning strength helper to litellm_core_utils so lite and the Codex catalog import it without the SDK or the router, and let make lint-format-check-changed diff the working tree so a staged rename passes. * fix(lite): keep pi's system role on reasoning model groups pi switches its system prompt to the developer role for any model marked reasoning, so an operator's cache_control_injection_points entry for role system stopped matching on Claude groups. lite pi now sets compat.supportsDeveloperRole false on those entries, so pi sends system as it did before. * fix(codex): treat a client_version too long to parse as an old Codex and keep undescribed stock levels A client_version past Python's integer digit limit made the Codex catalog answer 500. It now gets the levels every Codex parses. A stock Codex level with no description validates again, as on base, and lite codex treats undecodable codex --version output as an unknown version * fix(lite): keep REASONING_EFFORT_STRENGTH_ORDER importable from router_utils and skip the Codex sync on undecodable debug models output --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
371 lines
14 KiB
Python
371 lines
14 KiB
Python
import json
|
|
import os
|
|
import stat
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from litellm.proxy.client.cli.commands.pi import (
|
|
ListingFailure,
|
|
ModelLimits,
|
|
PiSyncError,
|
|
fetch_model_ids,
|
|
fetch_model_limits,
|
|
fetch_model_listing,
|
|
models_json_path,
|
|
provider_block,
|
|
sync_models_json,
|
|
)
|
|
|
|
|
|
def test_listing_failure_is_str_enum():
|
|
assert issubclass(ListingFailure, str)
|
|
assert ListingFailure.REJECTED.value == "rejected"
|
|
assert ListingFailure("rejected") is ListingFailure.REJECTED
|
|
assert str(ListingFailure.REJECTED.value) == "rejected"
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("not json")
|
|
return self._payload
|
|
|
|
|
|
def _refused(*args, **kwargs):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
|
|
class TestFetchModelIds:
|
|
def test_returns_ids_in_proxy_order_deduped(self):
|
|
captured = {}
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
return _FakeResponse(
|
|
200,
|
|
{"data": [{"id": "m-b"}, {"id": "m-a"}, {"id": "m-b"}]},
|
|
)
|
|
|
|
assert fetch_model_ids("http://localhost:4000/", "sk-key", get=fake_get) == ("m-b", "m-a")
|
|
assert captured["url"] == "http://localhost:4000/v1/models"
|
|
assert captured["headers"] == {"Authorization": "Bearer sk-key"}
|
|
|
|
def test_returns_rows_with_optional_source_model_and_dedups_identical_rows(self):
|
|
result = fetch_model_listing(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(
|
|
200,
|
|
{"data": [{"id": "emitted", "source_model": "source"}, {"id": "emitted", "source_model": "source"}]},
|
|
),
|
|
)
|
|
assert not isinstance(result, PiSyncError)
|
|
assert tuple((model.id, model.source_model) for model in result) == (("emitted", "source"),)
|
|
|
|
@pytest.mark.parametrize(
|
|
"entry",
|
|
[
|
|
{"id": ""},
|
|
{"id": "emitted", "source_model": ""},
|
|
{"id": "emitted", "source_model": 1},
|
|
],
|
|
)
|
|
def test_rejects_invalid_model_identity(self, entry):
|
|
result = fetch_model_listing(
|
|
"http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": [entry]})
|
|
)
|
|
assert isinstance(result, PiSyncError) and result.kind is ListingFailure.BAD_BODY
|
|
|
|
def test_rejects_conflicting_emitted_id_mappings(self):
|
|
result = fetch_model_listing(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(
|
|
200,
|
|
{"data": [{"id": "emitted", "source_model": "one"}, {"id": "emitted", "source_model": "two"}]},
|
|
),
|
|
)
|
|
assert isinstance(result, PiSyncError) and result.kind is ListingFailure.BAD_BODY
|
|
|
|
def test_network_error_is_a_value(self):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
result = fetch_model_ids("http://localhost:4000", "sk-key", get=boom)
|
|
assert isinstance(result, PiSyncError)
|
|
assert "Could not list models" in result.message
|
|
|
|
def test_non_200_is_a_value(self):
|
|
result = fetch_model_ids("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500))
|
|
assert isinstance(result, PiSyncError)
|
|
assert "HTTP 500" in result.message
|
|
|
|
def test_malformed_body_is_a_value(self):
|
|
result = fetch_model_ids(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(200, {"data": "nope"}),
|
|
)
|
|
assert isinstance(result, PiSyncError)
|
|
|
|
def test_empty_model_list_is_a_value(self):
|
|
result = fetch_model_ids(
|
|
"http://localhost:4000",
|
|
"sk-key",
|
|
get=lambda *a, **k: _FakeResponse(200, {"data": []}),
|
|
)
|
|
assert isinstance(result, PiSyncError)
|
|
assert "no models" in result.message
|
|
assert result.kind is ListingFailure.EMPTY
|
|
|
|
@pytest.mark.parametrize(
|
|
("get", "kind"),
|
|
[
|
|
(_refused, ListingFailure.UNREACHABLE),
|
|
(lambda *a, **k: _FakeResponse(401), ListingFailure.REJECTED),
|
|
(lambda *a, **k: _FakeResponse(403), ListingFailure.REJECTED),
|
|
(lambda *a, **k: _FakeResponse(500), ListingFailure.OTHER),
|
|
(lambda *a, **k: _FakeResponse(200), ListingFailure.BAD_BODY),
|
|
],
|
|
ids=["unreachable", "401", "403", "500", "bad-body"],
|
|
)
|
|
def test_the_failure_kind_is_decided_where_the_response_is_classified(self, get, kind):
|
|
result = fetch_model_ids("http://localhost:4000", "sk-key", get=get)
|
|
assert isinstance(result, PiSyncError) and result.kind is kind
|
|
|
|
|
|
class TestFetchModelLimits:
|
|
def test_maps_group_limits_and_hits_model_group_info(self):
|
|
captured = {}
|
|
|
|
def fake_get(url, headers, timeout):
|
|
captured["url"] = url
|
|
return _FakeResponse(
|
|
200,
|
|
{
|
|
"data": [
|
|
{
|
|
"model_group": "m-a",
|
|
"max_input_tokens": 131072,
|
|
"max_output_tokens": 8192,
|
|
"supported_reasoning_efforts": ["low", "high", "max"],
|
|
},
|
|
{"model_group": "m-b", "max_input_tokens": None, "max_output_tokens": None},
|
|
{"model_group": "m-c", "supported_reasoning_efforts": []},
|
|
]
|
|
},
|
|
)
|
|
|
|
limits = fetch_model_limits("http://localhost:4000/", "sk-key", get=fake_get)
|
|
assert captured["url"] == "http://localhost:4000/model_group/info"
|
|
assert limits["m-a"] == ModelLimits(
|
|
context_window=131072, max_tokens=8192, supported_reasoning_efforts=("low", "high", "max")
|
|
)
|
|
assert limits["m-b"] == ModelLimits(context_window=None, max_tokens=None, supported_reasoning_efforts=None)
|
|
assert limits["m-c"].supported_reasoning_efforts == ()
|
|
|
|
def test_non_200_degrades_to_no_limits(self):
|
|
assert fetch_model_limits("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(403)) == {}
|
|
|
|
def test_network_error_degrades_to_no_limits(self):
|
|
def boom(*a, **k):
|
|
raise requests.ConnectionError("refused")
|
|
|
|
assert fetch_model_limits("http://localhost:4000", "sk-key", get=boom) == {}
|
|
|
|
def test_malformed_body_degrades_to_no_limits(self):
|
|
assert (
|
|
fetch_model_limits(
|
|
"http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": "nope"})
|
|
)
|
|
== {}
|
|
)
|
|
|
|
|
|
class TestModelsJsonPath:
|
|
def test_env_override_wins(self):
|
|
assert models_json_path({"PI_CODING_AGENT_DIR": "/custom/dir"}) == Path("/custom/dir/models.json")
|
|
|
|
def test_defaults_to_home_pi_agent(self):
|
|
assert models_json_path({}) == Path.home() / ".pi" / "agent" / "models.json"
|
|
|
|
|
|
class TestProviderBlock:
|
|
def test_points_pi_at_proxy_with_env_interpolated_key(self):
|
|
block = provider_block("http://localhost:4000/", ("m-1", "m-2"))
|
|
assert block == {
|
|
"baseUrl": "http://localhost:4000/v1",
|
|
"api": "openai-completions",
|
|
"compat": {
|
|
"supportsStore": False,
|
|
"supportsLongCacheRetention": False,
|
|
"sendSessionAffinityHeaders": True,
|
|
"sessionAffinityFormat": "openrouter",
|
|
},
|
|
"apiKey": "$LITELLM_PROXY_API_KEY",
|
|
"models": [{"id": "m-1"}, {"id": "m-2"}],
|
|
}
|
|
|
|
def test_known_limits_become_context_window_and_max_tokens(self):
|
|
block = provider_block(
|
|
"http://localhost:4000",
|
|
("m-1", "m-2"),
|
|
{
|
|
"m-1": ModelLimits(context_window=131072, max_tokens=8192),
|
|
"m-2": ModelLimits(context_window=None, max_tokens=None),
|
|
},
|
|
)
|
|
assert block["models"] == [
|
|
{"id": "m-1", "contextWindow": 131072, "maxTokens": 8192},
|
|
{"id": "m-2"},
|
|
]
|
|
|
|
@pytest.mark.parametrize(
|
|
("efforts", "thinking_level_map"),
|
|
[
|
|
(
|
|
("low", "medium", "high", "xhigh", "max"),
|
|
{
|
|
"off": None,
|
|
"minimal": None,
|
|
"low": "low",
|
|
"medium": "medium",
|
|
"high": "high",
|
|
"xhigh": "xhigh",
|
|
"max": "max",
|
|
},
|
|
),
|
|
(
|
|
("none", "low", "high", "max"),
|
|
{"off": "none", "minimal": None, "low": "low", "medium": None, "high": "high", "max": "max"},
|
|
),
|
|
(
|
|
("none", "minimal", "low", "medium", "high"),
|
|
{"off": "none", "minimal": "minimal", "low": "low", "medium": "medium", "high": "high"},
|
|
),
|
|
(
|
|
("high", "turbo"),
|
|
{"off": None, "minimal": None, "low": None, "medium": None, "high": "high"},
|
|
),
|
|
],
|
|
)
|
|
def test_reasoning_efforts_become_pi_thinking_levels_and_keep_the_system_role(self, efforts, thinking_level_map):
|
|
block = provider_block(
|
|
"http://localhost:4000",
|
|
("m-1",),
|
|
{"m-1": ModelLimits(context_window=131072, max_tokens=8192, supported_reasoning_efforts=efforts)},
|
|
)
|
|
assert block["models"] == [
|
|
{
|
|
"id": "m-1",
|
|
"contextWindow": 131072,
|
|
"maxTokens": 8192,
|
|
"reasoning": True,
|
|
"thinkingLevelMap": thinking_level_map,
|
|
"compat": {"supportsDeveloperRole": False},
|
|
}
|
|
]
|
|
|
|
@pytest.mark.parametrize("efforts", [None, ()])
|
|
def test_unknown_or_no_reasoning_efforts_leave_the_entry_as_it_was(self, efforts):
|
|
block = provider_block(
|
|
"http://localhost:4000",
|
|
("m-1",),
|
|
{"m-1": ModelLimits(context_window=131072, max_tokens=8192, supported_reasoning_efforts=efforts)},
|
|
)
|
|
assert block["models"] == [{"id": "m-1", "contextWindow": 131072, "maxTokens": 8192}]
|
|
|
|
|
|
class TestSyncModelsJson:
|
|
def test_creates_file_and_parent_dirs(self, tmp_path):
|
|
path = tmp_path / "agent" / "models.json"
|
|
assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None
|
|
written = json.loads(path.read_text())
|
|
assert written["providers"]["litellm"]["baseUrl"] == "http://localhost:4000/v1"
|
|
assert written["providers"]["litellm"]["models"] == [{"id": "m-1"}]
|
|
|
|
def test_preserves_other_providers_and_top_level_keys(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"somethingElse": True,
|
|
"providers": {
|
|
"ollama": {"baseUrl": "http://localhost:11434/v1"},
|
|
"litellm": {"baseUrl": "http://stale:1234/v1", "models": []},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None
|
|
written = json.loads(path.read_text())
|
|
assert written["somethingElse"] is True
|
|
assert written["providers"]["ollama"] == {"baseUrl": "http://localhost:11434/v1"}
|
|
assert written["providers"]["litellm"]["baseUrl"] == "http://localhost:4000/v1"
|
|
assert written["providers"]["litellm"]["models"] == [{"id": "m-1"}]
|
|
|
|
def test_write_leaves_no_staging_file_behind(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None
|
|
assert [p.name for p in tmp_path.iterdir()] == ["models.json"]
|
|
|
|
def test_written_file_is_private(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None
|
|
if os.name != "nt":
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
path.write_text(json.dumps({"providers": {"other": {"apiKey": "literal-secret"}}}))
|
|
path.chmod(0o644)
|
|
assert sync_models_json(path, "http://localhost:4000", ("m-2",)) is None
|
|
if os.name != "nt":
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
def test_concurrent_syncs_do_not_collide(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
model_lists = (("m-a",), ("m-b",))
|
|
|
|
def sync(model_ids):
|
|
return sync_models_json(path, "http://localhost:4000", model_ids)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
results = [result for _ in range(30) for result in executor.map(sync, model_lists)]
|
|
|
|
assert results == [None] * 60
|
|
written = json.loads(path.read_text())
|
|
assert written["providers"]["litellm"]["models"] in ([{"id": "m-a"}], [{"id": "m-b"}])
|
|
assert list(tmp_path.glob("models.json.*.tmp")) == []
|
|
|
|
def test_invalid_json_is_a_value_and_file_untouched(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
path.write_text("{not json")
|
|
result = sync_models_json(path, "http://localhost:4000", ("m-1",))
|
|
assert isinstance(result, PiSyncError)
|
|
assert path.read_text() == "{not json"
|
|
|
|
def test_non_object_providers_is_a_value(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
path.write_text(json.dumps({"providers": ["nope"]}))
|
|
result = sync_models_json(path, "http://localhost:4000", ("m-1",))
|
|
assert isinstance(result, PiSyncError)
|
|
|
|
def test_top_level_non_object_is_a_value(self, tmp_path):
|
|
path = tmp_path / "models.json"
|
|
path.write_text(json.dumps(["nope"]))
|
|
result = sync_models_json(path, "http://localhost:4000", ("m-1",))
|
|
assert isinstance(result, PiSyncError)
|
|
|
|
def test_unwritable_path_is_a_value(self, tmp_path):
|
|
blocker = tmp_path / "agent"
|
|
blocker.write_text("i am a file, not a directory")
|
|
result = sync_models_json(blocker / "models.json", "http://localhost:4000", ("m-1",))
|
|
assert isinstance(result, PiSyncError)
|
|
assert "Could not" in result.message
|