From 379d9211fab61db2da9b34d6f385fc240e00a061 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 9 Aug 2026 15:26:10 +0200 Subject: feat: store per-model metadata in models.ini Cloud models have no presets.ini entry, so context size, vision and prices are recorded per model in a file the app writes. A cancelled dialog leaves a marker so a model tried once never asks again, which is distinct from an absent section meaning never asked. Co-Authored-By: Claude Opus 5 --- test_llamachat.py | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) (limited to 'test_llamachat.py') diff --git a/test_llamachat.py b/test_llamachat.py index 29f305f..8b53d55 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -1380,6 +1380,103 @@ def test_config_providers(): print("ok config provider table") +def test_models_store(): + """models.ini round-trips per-model metadata and the cancel record.""" + from llamachat import models + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "models.ini" + store = models.ModelStore(path) + + # Nothing recorded yet. + assert store.get("together:Qwen/Qwen2.5") is None + assert store.was_offered("together:Qwen/Qwen2.5") is False + + store.save( + "together:Qwen/Qwen2.5", + models.ModelInfo( + ctx_size=32768, vision=False, price_in=1.2, price_out=1.2 + ), + ) + # A cancelled dialog records that it was offered, nothing more. + store.mark_skipped("together:Llama-Vision-Free") + + # Reread from disk, not from memory: this is the round trip. + fresh = models.ModelStore(path) + info = fresh.get("together:Qwen/Qwen2.5") + assert info.ctx_size == 32768 + assert info.vision is False + assert info.price_in == 1.2 + assert info.price_out == 1.2 + assert fresh.was_offered("together:Qwen/Qwen2.5") is True + + assert fresh.get("together:Llama-Vision-Free") is None + assert fresh.was_offered("together:Llama-Vision-Free") is True + + # Partial entries are legal: prices may be left blank. + fresh.save("together:cheap", models.ModelInfo(ctx_size=8192)) + again = models.ModelStore(path) + partial = again.get("together:cheap") + assert partial.ctx_size == 8192 + assert partial.price_in is None + assert partial.vision is None + + # A model id with a colon must survive being an ini section name. + again.save("together:org/name:v2", models.ModelInfo(ctx_size=4096)) + assert models.ModelStore(path).get("together:org/name:v2").ctx_size == 4096 + + # Real ids carry slashes, dots and mixed case. Section names are + # case sensitive, unlike keys, so the id must come back verbatim. + for real in ( + "together:Qwen/Qwen2.5-72B-Instruct-Turbo", + "together:deepseek-ai/DeepSeek-V3", + "together:meta-llama/Llama-3.3-70B", + ): + again.save(real, models.ModelInfo(ctx_size=128000, vision=True)) + back = models.ModelStore(path).get(real) + assert back.ctx_size == 128000, real + assert back.vision is True, real + + # vision=False must not be written as "False" and read back as None: + # bool is an int subclass, so the write order matters. + again.save("together:novision", models.ModelInfo(vision=False)) + assert models.ModelStore(path).get("together:novision").vision is False + + # Cancelling a dialog over a model we already know must not erase it. + again.mark_skipped("together:Qwen/Qwen2.5") + assert models.ModelStore(path).get("together:Qwen/Qwen2.5").ctx_size == 32768 + + # An all-blank save is still an answer: asked, learned nothing. It + # must not read back as never offered, or the dialog reopens forever. + # configparser does round-trip a keyless section, so the marker is + # belt and braces against an empty section being dropped by hand. + again.save("together:blank", models.ModelInfo()) + reread = models.ModelStore(path) + assert reread.get("together:blank") is None + assert reread.was_offered("together:blank") is True + + # A hand-edited file must degrade, not raise: "32k" is not an int. + # The [DEFAULT] value is deliberately a *valid* int, so this catches + # the leak itself rather than an unparseable value hiding it. + path.write_text( + "[DEFAULT]\nctx_size = 999\n\n" + "[together:junk]\nctx_size = 32k\nprice_in = free\n\n" + "[together:empty]\n", + encoding="utf-8", + ) + edited = models.ModelStore(path) + assert edited.get("together:junk") is None + assert edited.was_offered("together:junk") is True + # Would be ctx_size 999, inherited from [DEFAULT], if the store used + # configparser's real default section. + assert edited.get("together:empty") is None + + # A file that is not ini at all reads as empty rather than raising. + path.write_text("this is not an ini file\n", encoding="utf-8") + assert models.ModelStore(path).was_offered("together:junk") is False + print("ok models.ini storage") + + class _FakeResponse: """Enough of an http.client response for urlopen's context manager.""" @@ -2123,6 +2220,7 @@ if __name__ == "__main__": test_model_ids_and_filtering() test_key_resolution() test_config_providers() + test_models_store() test_search_tool_schema() test_search_results_sanitising() test_tool_call_accumulation() -- cgit v1.2.3