Files
kite/backend/ai/generic_openai_provider.py
T
curo1305andClaude Sonnet 4.6 ac2dded35b fix(celery+lmstudio): PYTHONPATH for forked workers + qwen3.5-9b thinking model support
Three root causes fixed:

1. docker-compose.yml — celery-worker and celery-beat missing PYTHONPATH=/app
   ForkPoolWorker processes inherit sys.path with '' (CWD), but fork can change
   CWD so lazy imports like `from db.session import ...` raised ModuleNotFoundError.
   Adding PYTHONPATH=/app ensures /app is always explicit in the path.

2. provider_config.py — lmstudio SUPPORTS_JSON_MODE set to False
   LM Studio only accepts response_format type 'json_schema' or 'text', not
   'json_object'. GenericOpenAIProvider now omits response_format for lmstudio
   and relies on parse_classification() fallback (same path as Gemini).

3. generic_openai_provider.py — max_tokens raised 1024→4096 (classify) / 256→1024 (suggest)
   Qwen3 thinking models (like qwen/qwen3.5-9b) consume reasoning tokens from the
   same budget. With max_tokens=1024 the model exhausts the budget in the thinking
   trace, leaving content=''. 4096 gives room for both thinking and the JSON output.

Also updates lmstudio PROVIDER_DEFAULTS model to qwen/qwen3.5-9b (confirmed loaded
in LM Studio) and context_chars to 32_000 (Qwen3 context window).

Tested: classify(invoice_text, ['Finance','Legal','HR']) → topics=['Finance'] ✓
Backend tests: 376 passed ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:00:26 +02:00

104 lines
4.0 KiB
Python

"""GenericOpenAIProvider — unified OpenAI-compatible provider for all 8 compat vendors.
Covers: Groq, xAI/Grok, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat,
Ollama, LMStudio (D-16/D-17/D-18).
Key design decisions:
- Subclasses OpenAIProvider to inherit singleton _client and _truncate (D-07/D-13).
- Conditionally passes response_format={"type":"json_object"} based on
supports_json_mode flag (D-01) — Gemini preset sets this to False (D-02).
- Always parses the raw response with parse_classification / parse_suggestions
imported from ai.utils (D-02 last-resort fallback contract — NEVER redefine
locally; CLAUDE.md shared module map rule).
"""
from __future__ import annotations
from ai.openai_provider import OpenAIProvider
from ai.utils import parse_classification, parse_suggestions # D-02 contract
class GenericOpenAIProvider(OpenAIProvider):
"""OpenAI-compatible provider that enforces JSON mode on every call.
Named presets (Groq, xAI, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat,
Ollama, LMStudio) are factory shortcuts in ai/__init__.py that pass the known
base_url default for each vendor.
supports_json_mode=False routes the provider through the parse_classification()
fallback path without sending response_format — used for Gemini (D-02).
"""
supports_json_mode: bool = True # class-level default; overridden per instance
def __init__(
self,
api_key: str,
model: str,
base_url: str | None,
context_chars: int = 8000,
supports_json_mode: bool = True,
):
super().__init__(
api_key=api_key,
model=model,
base_url=base_url,
context_chars=context_chars,
)
# Instance-level flag (may differ from class-level default)
self.supports_json_mode = supports_json_mode
async def classify(
self,
document_text: str,
existing_topics: list[str],
system_prompt: str,
):
topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)"
user_msg = (
f"Existing topics: [{topics_str}]\n\n"
f"Document text:\n{self._truncate(document_text)}"
)
create_kwargs = dict(
model=self._model,
max_tokens=4096,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
],
)
if self.supports_json_mode:
# D-01: enforce structured JSON output on all supporting providers
create_kwargs["response_format"] = {"type": "json_object"}
# else: Gemini preset — omit response_format, fall back to parse_classification()
response = await self._client.chat.completions.create(**create_kwargs)
raw = response.choices[0].message.content or ""
# D-02: parse_classification is the last-resort fallback — always called
return parse_classification(raw)
async def suggest_topics(
self,
document_text: str,
system_prompt: str,
) -> list[str]:
user_msg = (
"Suggest 3-5 topic names for this document. "
"Return ONLY valid JSON: {\"suggested_topics\": [\"topic1\", \"topic2\"]}\n\n"
f"Document text:\n{self._truncate(document_text)}"
)
create_kwargs = dict(
model=self._model,
max_tokens=1024,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
],
)
if self.supports_json_mode:
create_kwargs["response_format"] = {"type": "json_object"}
response = await self._client.chat.completions.create(**create_kwargs)
raw = response.choices[0].message.content or ""
# D-02: parse_suggestions is the last-resort fallback — always called
return parse_suggestions(raw)
# health_check is inherited from OpenAIProvider — no override needed