aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 11:22:32 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 11:22:32 +0200
commit2cae7f19ac90e6df9d4008e6e364bae30e8b389a (patch)
tree045eae84caefd1ebd97836fe0a5b97ae181022a2
parent3f984ccb8ec25ef3b06109d638eed4022db82d45 (diff)
downloadllamachat-2cae7f19ac90e6df9d4008e6e364bae30e8b389a.tar.gz
llamachat-2cae7f19ac90e6df9d4008e6e364bae30e8b389a.zip
fix: prevent cloud reasoning models from exhausting default max_tokens
- Send max_tokens = 32768 for non-local providers so reasoning models have room for both chain-of-thought and answer. - Add per-provider thinking_budget option for endpoints (currently SiliconFlow) that cap reasoning tokens separately. - Accept bare JSON arrays from /v1/models; some OpenAI-compatible endpoints omit the {"data": [...]} envelope.
-rw-r--r--CHANGELOG.md14
-rw-r--r--llamachat/backend.py27
-rw-r--r--llamachat/config.py7
-rw-r--r--llamachat/providers.py4
-rwxr-xr-xtest_llamachat.py74
5 files changed, 118 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3df977..6e9a831 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`model`. Token counts come from the stream's final usage chunk and power the
cost readout; the model column records which model actually replied, so a
switched conversation prices each turn correctly.
+- Per-provider `thinking_budget` option, for endpoints (currently SiliconFlow)
+ that cap chain-of-thought tokens separately from the final answer. Unset
+ providers skip the key entirely so other endpoints do not receive an unknown
+ parameter.
+
+### Fixed
+
+- Model listing crashed when a provider's `/v1/models` endpoint returned a
+ bare JSON array instead of the standard `{"data": [...]}` envelope. The
+ parser now accepts both shapes.
+- Cloud chat requests no longer rely on the provider's default `max_tokens`,
+ which could be too small for a reasoning model. Non-local providers now send
+ `max_tokens = 32768`, leaving room for both chain-of-thought reasoning and
+ the answer.
Tool calling and reasoning output vary between providers. Web search on a
cloud model may not work as reliably as it does with a local router.
diff --git a/llamachat/backend.py b/llamachat/backend.py
index ca76559..4f15936 100644
--- a/llamachat/backend.py
+++ b/llamachat/backend.py
@@ -170,10 +170,19 @@ def build_user_content(text: str, attachments: list[Attachment]):
class Client:
"""Minimal OpenAI-compatible client for the local router."""
- def __init__(self, base_url: str, timeout: int = 300, api_key: str = ""):
+ def __init__(
+ self,
+ base_url: str,
+ timeout: int = 300,
+ api_key: str = "",
+ is_local: bool = False,
+ thinking_budget: int | None = None,
+ ):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.api_key = api_key
+ self.is_local = is_local
+ self.thinking_budget = thinking_budget
def _headers(self) -> dict:
"""Bearer auth when the provider needs it, nothing when it does not."""
@@ -200,7 +209,10 @@ class Client:
raise BackendError(f"Cannot reach router at {self.base_url}: {exc}")
except ValueError as exc:
raise BackendError(f"Router sent invalid JSON: {exc}")
- return [m["id"] for m in payload.get("data", []) if "id" in m]
+ # Some OpenAI-compatible providers return a bare array instead of
+ # the standard {"data": [...]} envelope. Accept both.
+ data = payload if isinstance(payload, list) else payload.get("data", [])
+ return [m["id"] for m in data if "id" in m]
def complete(self, model: str, messages: list[dict], max_tokens: int = 48) -> str:
"""One short non-streaming reply, for side errands like titling.
@@ -359,6 +371,15 @@ class Client:
# estimate, at no extra request.
"stream_options": {"include_usage": True},
}
+ # Cloud providers apply their own max_tokens default when none is
+ # sent, and that default can be small enough that a reasoning model
+ # spends it all on reasoning_content and never produces a content
+ # reply. The local router has no such ceiling, so only set this for
+ # non-local providers.
+ if not self.is_local:
+ body["max_tokens"] = 32768
+ if self.thinking_budget is not None:
+ body["thinking_budget"] = self.thinking_budget
if tools:
body["tools"] = tools
try:
@@ -413,6 +434,8 @@ class MultiClient:
provider.base_url,
timeout=self.timeout,
api_key=self.resolver.resolve(provider),
+ is_local=provider.is_local,
+ thinking_budget=provider.thinking_budget,
)
return self._clients[name]
diff --git a/llamachat/config.py b/llamachat/config.py
index 4add5ed..81b799b 100644
--- a/llamachat/config.py
+++ b/llamachat/config.py
@@ -247,6 +247,12 @@ def write_default(path: Path = CONFIG_PATH) -> Path:
'# dialog saves goes to models.ini beside this file, so none of\n'
'# these has to be set here.\n'
'#\n'
+ '# thinking_budget sets a provider-specific cap on chain-of-thought\n'
+ '# tokens. It is currently sent only to SiliconFlow; other endpoints\n'
+ '# ignore it when unset, so leaving it out is safe. SiliconFlow\'s\n'
+ '# default is 4096; raise it for complex questions that need more\n'
+ '# room to reason.\n'
+ '#\n'
'# [providers.together]\n'
'# base_url = "https://api.together.xyz"\n'
'# api_key = "pass:api/together"\n'
@@ -254,6 +260,7 @@ def write_default(path: Path = CONFIG_PATH) -> Path:
'# ctx_size = 32768\n'
'# price_in = 0.60\n'
'# price_out = 0.60\n'
+ '# thinking_budget = 8192\n'
)
return path
diff --git a/llamachat/providers.py b/llamachat/providers.py
index ce2d01d..a8b2350 100644
--- a/llamachat/providers.py
+++ b/llamachat/providers.py
@@ -46,6 +46,9 @@ class Provider:
vision: bool | None = None
price_in: float | None = None
price_out: float | None = None
+ # Provider-specific reasoning token budget. Currently SiliconFlow only;
+ # ignored when unset so other endpoints do not receive an unknown key.
+ thinking_budget: int | None = None
@property
def is_local(self) -> bool:
@@ -185,6 +188,7 @@ def parse(values: dict, warnings: list[str] | None = None) -> dict[str, Provider
vision=None if vision is None else bool(vision),
price_in=_number(entry.get("price_in"), float),
price_out=_number(entry.get("price_out"), float),
+ thinking_budget=_number(entry.get("thinking_budget"), int),
)
return out
diff --git a/test_llamachat.py b/test_llamachat.py
index 98776b8..3f1f678 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -923,11 +923,12 @@ def test_provider_parsing():
"together": {
"base_url": "https://api.example.org",
"api_key": "env:TEST_KEY_NAME",
- "filter": ["qwen", "deepseek"],
- "ctx_size": 32768,
- "price_in": 0.6,
- "price_out": 0.9,
- },
+ "filter": ["qwen", "deepseek"],
+ "ctx_size": 32768,
+ "price_in": 0.6,
+ "price_out": 0.9,
+ "thinking_budget": 8192,
+ },
}
}
)
@@ -939,6 +940,7 @@ def test_provider_parsing():
assert parsed["together"].ctx_size == 32768
assert parsed["together"].price_in == 0.6
assert parsed["together"].price_out == 0.9
+ assert parsed["together"].thinking_budget == 8192
# An old config: bare base_url, no providers table at all.
legacy = providers.parse({"base_url": "http://localhost:8181"})
@@ -1395,12 +1397,14 @@ def test_config_providers():
"ctx_size = 32768\n"
"price_in = 0.6\n"
"price_out = 0.9\n"
+ "thinking_budget = 8192\n"
)
cfg = config.load(path)
assert set(cfg.providers) == {"local", "together"}
assert cfg.providers["together"].api_key == "pass:api/together"
assert cfg.providers["together"].filter == ["qwen"]
assert cfg.providers["together"].price_out == 0.9
+ assert cfg.providers["together"].thinking_budget == 8192
# A config with no base_url and no providers still loads, with the
# built-in default synthesizing local.
@@ -1863,6 +1867,13 @@ def test_client_auth_header():
).models()
assert sent["headers"]["Authorization"] == "Bearer sk-test-not-a-real-key"
+ # A provider that returns a bare array instead of {"data": [...]}
+ # must still parse: some OpenAI-compatible endpoints skip the envelope.
+ def _bare_list(url, timeout=None, headers=None):
+ return _HttpxResponse([{"id": "bare"}])
+ with _patched(httpx, "get", _bare_list):
+ assert backend.Client("http://x.example.org").models() == ["bare"]
+
# The streaming path is the one that carries every real turn, and no
# other test reaches it: the search tests all stub _stream_once out.
class _Stream:
@@ -1897,6 +1908,56 @@ def test_client_auth_header():
print("ok client authorization header")
+def test_stream_body_for_cloud():
+ """Cloud providers get max_tokens and thinking_budget; local does not."""
+ captured = {}
+
+ class _Stream:
+ status_code = 200
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def iter_lines(self):
+ return iter(
+ ['data: {"choices":[{"delta":{"content":"hi"}}]}']
+ )
+
+ def _recorder(method, url, json=None, timeout=None, headers=None):
+ captured["json"] = json
+ return _Stream()
+
+ import httpx
+ with _patched(httpx, "stream", _recorder):
+ # Local client: no max_tokens, no thinking_budget.
+ local = backend.Client("http://localhost:8181", is_local=True)
+ list(local._stream_once("m", [], tools=None))
+ assert "max_tokens" not in captured["json"]
+ assert "thinking_budget" not in captured["json"]
+
+ # Cloud client with thinking_budget set.
+ cloud = backend.Client(
+ "http://api.example.org",
+ is_local=False,
+ thinking_budget=8192,
+ )
+ list(cloud._stream_once("m", [], tools=None))
+ assert captured["json"]["max_tokens"] == 32768
+ assert captured["json"]["thinking_budget"] == 8192
+
+ # Cloud client without thinking_budget: max_tokens still sent.
+ cloud_no_budget = backend.Client(
+ "http://api.example.org", is_local=False
+ )
+ list(cloud_no_budget._stream_once("m", [], tools=None))
+ assert captured["json"]["max_tokens"] == 32768
+ assert "thinking_budget" not in captured["json"]
+ print("ok stream body for cloud providers")
+
+
def test_multi_client():
"""Models fan out across providers; requests route by model id."""
from llamachat import providers
@@ -1926,7 +1987,7 @@ def test_multi_client():
built = []
class _StubClient:
- def __init__(self, base_url, timeout=300, api_key=""):
+ def __init__(self, base_url, timeout=300, api_key="", **kwargs):
self.base_url = base_url
self.api_key = api_key
built.append(self)
@@ -2820,6 +2881,7 @@ if __name__ == "__main__":
test_metadata_and_cost()
test_token_column_migration()
test_client_auth_header()
+ test_stream_body_for_cloud()
test_multi_client()
test_model_dialog_values()
test_cost_label_text()