Add local model provider boundary

This commit is contained in:
Hamza Ayed
2026-10-01 13:26:22 +03:00
parent 6b271ad499
commit d459d6c318
3 changed files with 172 additions and 85 deletions
+4 -4
View File
@@ -19,8 +19,8 @@
## المرحلة 2 — أساس موثوق للواجهة والـ API ## المرحلة 2 — أساس موثوق للواجهة والـ API
- [ ] فصل عقد التطبيق عن تفاصيل Ollama عبر طبقة مزوّد موحدة (Model Provider)، مع بقاء Ollama أول مزوّد. - [x] فصل عقد التطبيق عن تفاصيل Ollama عبر طبقة مزوّد موحدة (Model Provider)، مع بقاء Ollama أول تنفيذ للمزوّد. (2026-10-01: المحادثة والبث والوكيل وقراءة مساحة العمل والويب تستخدم العقد الموحدة؛ `MODEL_PROVIDER=ollama` هو التنفيذ المتاح حاليًا. بعد إعادة تشغيل FastAPI أكد `/health` المزوّد، وأعاد `/v1/models` ثلاثة نماذج، ونجح طلب محادثة حي عبر Gemma.)
- [x] جلب قائمة Ollama المحلية والتحقق من اختيار النموذج قبل الإرسال؛ [ ] إضافة بيانات قدرات كل نموذج. - [x] جلب قائمة النماذج المحلية والتحقق من اختيار النموذج قبل الإرسال؛ [ ] إضافة بيانات قدرات كل نموذج.
- توحيد أخطاء API ومعرّفات الطلبات والمهل الزمنية، وإضافة اختبارات تكامل لعقد API. - توحيد أخطاء API ومعرّفات الطلبات والمهل الزمنية، وإضافة اختبارات تكامل لعقد API.
- إبقاء الخدمة محلية افتراضيًا؛ لا تُعرض على الشبكة قبل مصادقة المستخدم ومراجعة إعدادات الأمان. - إبقاء الخدمة محلية افتراضيًا؛ لا تُعرض على الشبكة قبل مصادقة المستخدم ومراجعة إعدادات الأمان.
- نقل إعدادات المستخدم من حقول مؤقتة إلى إعدادات محفوظة، وإظهار حالة الخادم والنموذج بوضوح. - نقل إعدادات المستخدم من حقول مؤقتة إلى إعدادات محفوظة، وإظهار حالة الخادم والنموذج بوضوح.
@@ -91,7 +91,7 @@
- [x] إلغاء التوليد وإظهار المدة وحالة الاتصال ورسالة الخطأ (2026-10-01: اختبارات Cubit وواجهة ناجحة). - [x] إلغاء التوليد وإظهار المدة وحالة الاتصال ورسالة الخطأ (2026-10-01: اختبارات Cubit وواجهة ناجحة).
- [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة. - [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة.
- إكمال طبقة مزوّد النموذج وعقد API قبل ربط أدوات الوكيل. - [x] إكمال طبقة مزوّد النموذج الأساسية قبل توسيع أدوات الوكيل؛ مزوّد Ollama فقط متاح الآن، وإضافات المزوّدين وبيانات قدرات النماذج لاحقة.
- حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة. - حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة.
- إضافة دعم Markdown موثوقًا، وروابط المصادر، ونسخ كتل الكود بصورة مستقلة. - إضافة دعم Markdown موثوقًا، وروابط المصادر، ونسخ كتل الكود بصورة مستقلة.
- وضع تقييم صغير بالعربية والبرمجة لقياس التغييرات على السرعة والجودة وعدم فقد الميزات. - وضع تقييم صغير بالعربية والبرمجة لقياس التغييرات على السرعة والجودة وعدم فقد الميزات.
@@ -102,7 +102,7 @@
1. [x] التحقق من إجراءات المحادثة الجديدة على Windows Debug (2026-10-01): تشغيل API وGemma وGroq، اختبار صوت من الميكروفون حتى التفريغ والرد والحفظ في SQLite، نجاح اختبار الواجهة على نافذة 800px ونجاح `flutter analyze`. 1. [x] التحقق من إجراءات المحادثة الجديدة على Windows Debug (2026-10-01): تشغيل API وGemma وGroq، اختبار صوت من الميكروفون حتى التفريغ والرد والحفظ في SQLite، نجاح اختبار الواجهة على نافذة 800px ونجاح `flutter analyze`.
2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا. 2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا.
3. [x] حالة النموذج والمدة والخطأ وإيقاف التوليد (2026-10-01): اختبار إلغاء الرد الجزئي وإلغاء إعادة التوليد قبل أول رمز مع الحفاظ على الإجابة السابقة، واختبار زر الإيقاف بالواجهة؛ `flutter analyze` و5 اختبارات Flutter ناجحة، وWindows Debug يعمل بعد hot restart. 3. [x] حالة النموذج والمدة والخطأ وإيقاف التوليد (2026-10-01): اختبار إلغاء الرد الجزئي وإلغاء إعادة التوليد قبل أول رمز مع الحفاظ على الإجابة السابقة، واختبار زر الإيقاف بالواجهة؛ `flutter analyze` و5 اختبارات Flutter ناجحة، وWindows Debug يعمل بعد hot restart.
4. إضافة طبقة مزود النموذج واكتشاف النماذج. 4. [x] إضافة طبقة مزود النموذج واكتشاف النماذج (2026-10-01): عقد موحد للطلب الكامل والبث والقائمة، واستخدامه في المحادثة والوكيل وقراءة مساحة العمل والويب؛ Ollama هو المزوّد المنفذ حاليًا. تحقق حي من `/health` و`/v1/models` وطلب محادثة باستخدام Gemma.
5. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم. 5. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم.
6. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة. 6. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة.
7. الهوية متعددة المستخدمين قبل أي نشر شبكي. 7. الهوية متعددة المستخدمين قبل أي نشر شبكي.
+29 -81
View File
@@ -17,6 +17,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from app import database from app import database
from app.model_provider import get_model_provider
from app import workspace from app import workspace
logger = logging.getLogger("sovereignai.audio") logger = logging.getLogger("sovereignai.audio")
@@ -58,7 +59,7 @@ class AgentRequest(BaseModel):
task: str = Field(description="مهمة قصيرة للوكيل المحلي") task: str = Field(description="مهمة قصيرة للوكيل المحلي")
model: str | None = Field( model: str | None = Field(
default=None, default=None,
description="اسم نموذج Ollama؛ اتركه فارغًا لاستخدام النموذج الافتراضي", description="اسم النموذج المتاح لدى المزوّد المحلي؛ اتركه فارغًا لاستخدام الافتراضي",
) )
@@ -94,7 +95,7 @@ class ConversationWrite(BaseModel):
class WebReadRequest(BaseModel): class WebReadRequest(BaseModel):
url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها") url: str = Field(min_length=8, max_length=2048, description="رابط صفحة ويب عامة تريد تحليلها")
question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000) question: str = Field(default="لخّص محتوى الصفحة وأهم نقاطها.", min_length=1, max_length=2000)
model: str | None = Field(default=None, description="نموذج Ollama المحلي؛ اتركه فارغًا للنموذج الافتراضي") model: str | None = Field(default=None, description="نموذج المزوّد المحلي؛ اتركه فارغًا للنموذج الافتراضي")
class _PageText(HTMLParser): class _PageText(HTMLParser):
@@ -223,26 +224,20 @@ def validate_conversation_id(value: str) -> str:
async def get_completion( async def get_completion(
payload: dict[str, Any], base_url: str, *, timeout_seconds: float = 180.0 payload: dict[str, Any], *, timeout_seconds: float = 180.0
) -> dict[str, Any]: ) -> dict[str, Any]:
try: provider = get_model_provider()
async with httpx.AsyncClient(timeout=timeout_seconds) as client: return await provider.complete(payload, timeout_seconds=timeout_seconds)
response = await client.post(f"{base_url}/chat/completions", json=payload)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] or "رفض خادم النموذج الطلب."
raise HTTPException(status_code=502, detail=f"خطأ من خادم النموذج المحلي: {detail}") from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=503, detail="تعذر الاتصال بـ Ollama المحلي على العنوان المضبوط.") from exc
@app.get("/health") @app.get("/health")
def health() -> dict[str, Any]: def health() -> dict[str, Any]:
provider = get_model_provider()
return { return {
"status": "ok", "status": "ok",
"model": os.getenv("LOCAL_MODEL", "gemma4:e2b"), "provider": provider.name,
"backend": os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1"), "model": provider.default_model,
"backend": provider.base_url,
"groq_transcription": "configured" if os.getenv("GROQ_API_KEY") else "not_configured", "groq_transcription": "configured" if os.getenv("GROQ_API_KEY") else "not_configured",
"conversation_database": "sqlite", "conversation_database": "sqlite",
"workspace_agent": "enabled" if workspace.configured_root() else "not_configured", "workspace_agent": "enabled" if workspace.configured_root() else "not_configured",
@@ -251,18 +246,10 @@ def health() -> dict[str, Any]:
@app.get("/v1/models") @app.get("/v1/models")
async def list_local_models() -> dict[str, Any]: async def list_local_models() -> dict[str, Any]:
"""List models installed in the configured local Ollama instance.""" """List models available from the configured provider."""
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/") provider = get_model_provider()
ollama_base = base_url[:-3] if base_url.endswith("/v1") else base_url models = await provider.list_models()
try: active = provider.default_model
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{ollama_base}/api/tags")
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(status_code=503, detail="تعذر جلب قائمة النماذج من Ollama المحلي.") from exc
models = [item["name"] for item in payload.get("models", []) if isinstance(item, dict) and item.get("name")]
active = os.getenv("LOCAL_MODEL", "gemma4:e2b")
if active not in models: if active not in models:
models.insert(0, active) models.insert(0, active)
return {"data": [{"id": model, "object": "model"} for model in models]} return {"data": [{"id": model, "object": "model"} for model in models]}
@@ -278,7 +265,7 @@ async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
if not files: if not files:
raise HTTPException(status_code=404, detail="لم أجد نصوصًا مطابقة في ملفات مساحة العمل.") raise HTTPException(status_code=404, detail="لم أجد نصوصًا مطابقة في ملفات مساحة العمل.")
context = "\n\n".join(f"--- ملف: {name} ---\n{content}" for name, content in files) context = "\n\n".join(f"--- ملف: {name} ---\n{content}" for name, content in files)
model = request.model or os.getenv("LOCAL_MODEL", "gemma4:e2b") model = request.model or get_model_provider().default_model
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": model, "model": model,
"messages": [ "messages": [
@@ -298,13 +285,7 @@ async def ask_workspace(request: WorkspaceAgentRequest) -> dict[str, Any]:
], ],
"stream": False, "stream": False,
} }
if model.lower().startswith("gemma4"): completion = await get_completion(payload, timeout_seconds=600.0)
payload["reasoning_effort"] = "none"
completion = await get_completion(
payload,
os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/"),
timeout_seconds=600.0,
)
return { return {
"task": request.task, "task": request.task,
"tool": "workspace-search-readonly", "tool": "workspace-search-readonly",
@@ -322,7 +303,7 @@ def get_local_user() -> dict[str, str]:
def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]: def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
model = request.model or os.getenv("LOCAL_MODEL", "gemma4:e2b") model = request.model or get_model_provider().default_model
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": model, "model": model,
"messages": ( "messages": (
@@ -332,7 +313,7 @@ def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
{ {
"role": "system", "role": "system",
"content": ( "content": (
"أنت مساعد ذكاء اصطناعي محلي يعمل عبر Ollama على جهاز المستخدم. " "أنت مساعد ذكاء اصطناعي يعمل عبر مزوّد النموذج المحلي المضبوط على جهاز المستخدم. "
"أجب بالعربية الواضحة وباختصار مناسب. إذا سُئلت أين أنت، أجب بهذه الصياغة: " "أجب بالعربية الواضحة وباختصار مناسب. إذا سُئلت أين أنت، أجب بهذه الصياغة: "
"أنا مساعد ذكاء اصطناعي يعمل على جهازك، ولا أملك وجودًا جسديًا أو موقع GPS. " "أنا مساعد ذكاء اصطناعي يعمل على جهازك، ولا أملك وجودًا جسديًا أو موقع GPS. "
"ولا تدّع معرفة " "ولا تدّع معرفة "
@@ -347,54 +328,27 @@ def chat_payload(request: ChatRequest, *, stream: bool) -> dict[str, Any]:
), ),
"stream": stream, "stream": stream,
} }
# Disable Gemma 4 thinking so Ollama places the answer in `content` for clients.
if model.lower().startswith("gemma4"):
payload["reasoning_effort"] = "none"
return payload return payload
@app.post("/v1/chat/completions") @app.post("/v1/chat/completions")
async def chat(request: ChatRequest) -> dict[str, Any]: async def chat(request: ChatRequest) -> dict[str, Any]:
"""ترحيل طلب المحادثة إلى النموذج المحلي.""" """إرسال طلب المحادثة إلى مزوّد النموذج المضبوط."""
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/")
payload = chat_payload(request, stream=False) payload = chat_payload(request, stream=False)
return await get_completion(payload, base_url) return await get_completion(payload)
@app.post("/v1/chat/stream") @app.post("/v1/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse: async def chat_stream(request: ChatRequest) -> StreamingResponse:
"""Pass Ollama token deltas to clients as newline-delimited JSON.""" """Pass provider token deltas to clients as newline-delimited JSON."""
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/") provider = get_model_provider()
payload = chat_payload(request, stream=True) payload = chat_payload(request, stream=True)
async def events(): async def events():
try: async for event in provider.stream(payload):
timeout = httpx.Timeout(connect=15.0, read=None, write=30.0, pool=30.0) yield json.dumps(event, ensure_ascii=False) + "\n"
async with httpx.AsyncClient(timeout=timeout) as client: if event.get("done") or event.get("error"):
async with client.stream( return
"POST", f"{base_url}/chat/completions", json=payload
) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace")[:400]
yield json.dumps({"error": f"Ollama {response.status_code}: {detail}"}) + "\n"
return
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if raw == "[DONE]":
yield '{"done":true}\n'
return
try:
event = json.loads(raw)
delta = event["choices"][0].get("delta", {}).get("content")
except (ValueError, KeyError, IndexError, TypeError):
continue
if delta:
yield json.dumps({"delta": delta}, ensure_ascii=False) + "\n"
yield '{"done":true}\n'
except httpx.RequestError:
yield json.dumps({"error": "تعذر الاتصال بـ Ollama المحلي."}, ensure_ascii=False) + "\n"
return StreamingResponse( return StreamingResponse(
events(), events(),
@@ -503,8 +457,7 @@ async def run_agent(request: AgentRequest) -> dict[str, Any]:
except (ValueError, SyntaxError, ZeroDivisionError): except (ValueError, SyntaxError, ZeroDivisionError):
pass pass
base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/") model = request.model or get_model_provider().default_model
model = request.model or os.getenv("LOCAL_MODEL", "gemma4:e2b")
payload = { payload = {
"model": model, "model": model,
"messages": [ "messages": [
@@ -513,9 +466,7 @@ async def run_agent(request: AgentRequest) -> dict[str, Any]:
], ],
"stream": False, "stream": False,
} }
if model.lower().startswith("gemma4"): completion = await get_completion(payload)
payload["reasoning_effort"] = "none"
completion = await get_completion(payload, base_url)
return { return {
"task": request.task, "task": request.task,
"tool": "local-llm", "tool": "local-llm",
@@ -528,7 +479,7 @@ async def run_agent(request: AgentRequest) -> dict[str, Any]:
async def read_web_page(request: WebReadRequest) -> dict[str, Any]: async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
"""Fetch a user-provided public web page and ask the local model about its text.""" """Fetch a user-provided public web page and ask the local model about its text."""
source_url, title, page_text = await _read_public_page(request.url) source_url, title, page_text = await _read_public_page(request.url)
model = request.model or os.getenv("LOCAL_MODEL", "gemma4:e2b") model = request.model or get_model_provider().default_model
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": model, "model": model,
"messages": [ "messages": [
@@ -553,11 +504,8 @@ async def read_web_page(request: WebReadRequest) -> dict[str, Any]:
], ],
"stream": False, "stream": False,
} }
if model.lower().startswith("gemma4"):
payload["reasoning_effort"] = "none"
completion = await get_completion( completion = await get_completion(
payload, payload,
os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/"),
timeout_seconds=600.0, timeout_seconds=600.0,
) )
return { return {
+139
View File
@@ -0,0 +1,139 @@
"""Provider boundary for local and future model backends."""
from __future__ import annotations
import json
import os
from collections.abc import AsyncIterator
from typing import Any, Protocol
import httpx
from fastapi import HTTPException
class ModelProvider(Protocol):
name: str
default_model: str
base_url: str
def prepare_payload(self, payload: dict[str, Any]) -> dict[str, Any]: ...
async def complete(
self, payload: dict[str, Any], *, timeout_seconds: float = 180.0
) -> dict[str, Any]: ...
async def stream(
self, payload: dict[str, Any]
) -> AsyncIterator[dict[str, Any]]: ...
async def list_models(self) -> list[str]: ...
class OllamaProvider:
name = "ollama"
def __init__(self, base_url: str, default_model: str) -> None:
self.base_url = base_url.rstrip("/")
self.default_model = default_model
def prepare_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
prepared = dict(payload)
model = str(prepared.get("model", self.default_model))
prepared.setdefault("model", model)
# Gemma 4 via Ollama otherwise returns its reasoning in a separate field.
if model.lower().startswith("gemma4"):
prepared.setdefault("reasoning_effort", "none")
return prepared
async def complete(
self, payload: dict[str, Any], *, timeout_seconds: float = 180.0
) -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.post(
f"{self.base_url}/chat/completions",
json=self.prepare_payload(payload),
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:400] or "رفض خادم النموذج الطلب."
raise HTTPException(
status_code=502,
detail=f"خطأ من خادم النموذج المحلي: {detail}",
) from exc
except httpx.RequestError as exc:
raise HTTPException(
status_code=503,
detail="تعذر الاتصال بـ Ollama المحلي على العنوان المضبوط.",
) from exc
except ValueError as exc:
raise HTTPException(
status_code=502, detail="أعاد Ollama استجابة JSON غير صالحة."
) from exc
async def stream(
self, payload: dict[str, Any]
) -> AsyncIterator[dict[str, Any]]:
try:
timeout = httpx.Timeout(connect=15.0, read=None, write=30.0, pool=30.0)
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
json=self.prepare_payload(payload),
) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace")[:400]
yield {"error": f"Ollama {response.status_code}: {detail}"}
return
async for line in response.aiter_lines():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if raw == "[DONE]":
yield {"done": True}
return
try:
event = json.loads(raw)
delta = event["choices"][0].get("delta", {}).get("content")
except (ValueError, KeyError, IndexError, TypeError):
continue
if delta:
yield {"delta": delta}
yield {"done": True}
except httpx.RequestError:
yield {"error": "تعذر الاتصال بـ Ollama المحلي."}
async def list_models(self) -> list[str]:
ollama_base = (
self.base_url[:-3] if self.base_url.endswith("/v1") else self.base_url
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"{ollama_base}/api/tags")
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="تعذر جلب قائمة النماذج من Ollama المحلي.",
) from exc
return [
item["name"]
for item in payload.get("models", [])
if isinstance(item, dict) and item.get("name")
]
def get_model_provider() -> ModelProvider:
provider_name = os.getenv("MODEL_PROVIDER", "ollama").strip().lower()
if provider_name != "ollama":
raise HTTPException(
status_code=503,
detail=f"مزوّد النموذج '{provider_name}' غير مدعوم حاليًا.",
)
return OllamaProvider(
base_url=os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
default_model=os.getenv("LOCAL_MODEL", "gemma4:e2b"),
)