diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f856e..6715618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.26.2] — 2026-09-09 + +### Fixed +- **Character-Sheet Generation Could Stop on HTTP 405.** If an OpenAI-compatible LLM gateway rejects the configured + version-prefixed chat route (`/v1/chat/completions`) with 405, character-sheet extraction now retries the gateway's + root chat route (`/chat/completions`). This applies to both the live stream and its non-streaming fallback, so an + otherwise healthy gateway no longer drops the current passage. + +--- + ## [1.26.1] — 2026-09-08 ### Added diff --git a/VERSION b/VERSION index dd43a14..c7c3f33 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.26.1 +1.26.2 diff --git a/routes/conversation.py b/routes/conversation.py index bcf9a4e..33632b4 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -395,11 +395,23 @@ def _response_error_text(resp) -> str: def _llm_chat_completion_urls(llm_url: str) -> list[str]: + """Return compatible chat-completions routes for an LLM base URL. + + Most OpenAI-compatible servers use a ``/v1`` prefix, but a few routing + gateways expose the same API at their root. In particular, gateways can + reply 405 (rather than 404) when the version-prefixed route is disabled. + Trying the root route after a versioned route prevents a single passage + from being lost just because the configured gateway uses that variant. + """ base = (llm_url or "http://localhost:11434/v1").rstrip("/") + versioned = bool(re.search(r"/v\d+(?:\.\d+)?$", base)) + root = re.sub(r"/v\d+(?:\.\d+)?$", "", base) if versioned else base urls: list[str] = [] - if not re.search(r"/v\d+(?:\.\d+)?$", base): + if not versioned: urls.append(f"{base}/v1/chat/completions") urls.append(f"{base}/chat/completions") + if versioned: + urls.append(f"{root}/chat/completions") return list(dict.fromkeys(urls)) diff --git a/tests/test_conversation.py b/tests/test_conversation.py index b50e8d9..f19c213 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -28,6 +28,7 @@ from routes.conversation import ( match_characters_voices, _request_timeout_seconds, _response_error_text, + _llm_chat_completion_urls, _persist_consistent_image_artifacts, _data_image_digest, ) @@ -112,6 +113,25 @@ class AttributionParserTests(unittest.TestCase): reason = "Bad Gateway" self.assertEqual(len(_response_error_text(ResponseStub())), 2000) + def test_chat_completion_urls_fall_back_from_versioned_gateway_route(self): + # Some OpenAI-compatible routing gateways return 405 for /v1 but + # accept their chat endpoint at the root. Keep the configured route + # first, then try that compatible alternative. + self.assertEqual( + _llm_chat_completion_urls("http://gateway:28080/v1"), + [ + "http://gateway:28080/v1/chat/completions", + "http://gateway:28080/chat/completions", + ], + ) + self.assertEqual( + _llm_chat_completion_urls("http://gateway:28080"), + [ + "http://gateway:28080/v1/chat/completions", + "http://gateway:28080/chat/completions", + ], + ) + def test_repairs_epigraph_split_before_mixed_guillemet_close(self): raw = '{"segments":[' \ '{"speaker":"Schwester Larissa","type":"dialogue","text":"»Von dem Windhager Felsgesteyn bis zur Kueste von Mendena ziehet es sich hin, von Ifirns Eisgebirg bis zu dem dampfenden Waldt der Mohas, und"},' \