From 32b846b00b4a8b96c1eb84d466fccdf8d971b91a Mon Sep 17 00:00:00 2001 From: chintan712 Date: Wed, 22 Apr 2026 20:25:01 -0400 Subject: [PATCH 1/3] added ollama for local and cloud models --- backend/app/api/models.py | 53 +++++--- backend/app/llm/model_info.py | 7 + backend/app/llm/providers.py | 107 +++++++++++++++ backend/app/main.py | 18 ++- backend/app/schemas/credentials.py | 2 +- backend/app/store/credentials.py | 12 +- frontend/src/App.tsx | 143 +++++++++----------- frontend/src/api/rest.ts | 2 +- frontend/src/components/CredentialsPage.tsx | 1 + frontend/src/components/NewSessionModal.tsx | 3 +- frontend/src/test_script.js | 1 + 11 files changed, 245 insertions(+), 104 deletions(-) create mode 100644 frontend/src/test_script.js diff --git a/backend/app/api/models.py b/backend/app/api/models.py index 81ded95..7ec3377 100644 --- a/backend/app/api/models.py +++ b/backend/app/api/models.py @@ -9,6 +9,7 @@ """ import asyncio +import httpx from typing import Any, Dict, List from fastapi import APIRouter, HTTPException, Query, status @@ -48,9 +49,27 @@ } +async def fetch_ollama_models() -> List[str]: + base_url = cred_store.get_ollama_base_url() + try: + async with httpx.AsyncClient(timeout=2.0) as client: + resp = await client.get(f"{base_url.rstrip('/')}/api/tags") + resp.raise_for_status() + data = resp.json() + return [m["name"] for m in data.get("models", [])] + except Exception: + return [] + + +async def get_all_models_dict() -> Dict[str, List[str]]: + models = MODELS.copy() + models["ollama"] = await fetch_ollama_models() + return models + + @router.get("") async def list_models() -> Dict[str, List[str]]: - return MODELS + return await get_all_models_dict() @router.get("/details") @@ -63,7 +82,8 @@ async def models_details() -> Dict[str, List[Dict[str, Any]]]: { "claude": [ {id, context_window, max_output_tokens, source}, ... ], "openai": [ ... ], - "gemini": [ ... ] + "gemini": [ ... ], + "ollama": [ ... ] } """ async def _one(kind: str, model: str) -> Dict[str, Any]: @@ -76,40 +96,31 @@ async def _one(kind: str, model: str) -> Dict[str, Any]: "source": info.source if info else None, } + all_models = await get_all_models_dict() tasks: Dict[str, List[asyncio.Task[Dict[str, Any]]]] = {} - for kind, ids in MODELS.items(): + for kind, ids in all_models.items(): tasks[kind] = [asyncio.create_task(_one(kind, m)) for m in ids] out: Dict[str, List[Dict[str, Any]]] = {} for kind, task_list in tasks.items(): - out[kind] = await asyncio.gather(*task_list) + if task_list: + out[kind] = await asyncio.gather(*task_list) + else: + out[kind] = [] return out @router.get("/info") async def model_info_endpoint( - kind: AgentKind = Query(..., description="Provider (claude, openai, gemini)"), - model: str = Query(..., description="Model id — must be in MODELS[kind]"), + kind: AgentKind = Query(..., description="Provider (claude, openai, gemini, ollama)"), + model: str = Query(..., description="Model id"), ) -> Dict[str, Any]: - """Return context window + max output for a specific model. - - Response shape: - { - "kind": "gemini", - "model": "gemini-2.5-pro", - "context_window": 2000000, - "max_output_tokens": 64000, - "source": "api" // or "static", or null if unknown - } - """ - if model not in MODELS.get(kind, []): + all_models = await get_all_models_dict() + if model not in all_models.get(kind, []): raise HTTPException( status.HTTP_404_NOT_FOUND, f"Unknown model '{model}' for provider '{kind}'.", ) - # For providers that require a key to live-fetch (Gemini), pass the - # saved credential through. Static-table lookups don't need it but - # it's harmless to pass an empty string. api_key = cred_store.get_key(kind) or "" info = await get_model_info(kind, model, api_key) if info is None: diff --git a/backend/app/llm/model_info.py b/backend/app/llm/model_info.py index aca01fe..42bebd2 100644 --- a/backend/app/llm/model_info.py +++ b/backend/app/llm/model_info.py @@ -98,6 +98,9 @@ async def get_model_info( async with _cache_lock: _cache[key] = live return live + + if agent_kind == "ollama": + return await _fetch_ollama(model) return _STATIC.get(key) @@ -130,3 +133,7 @@ async def _fetch_gemini(model: str, api_key: str) -> Optional[ModelInfo]: exc, ) return None + +async def _fetch_ollama(model: str) -> Optional[ModelInfo]: + return ModelInfo(context_window=32000, max_output_tokens=8192, source="api") + diff --git a/backend/app/llm/providers.py b/backend/app/llm/providers.py index 8bc556c..acc855c 100644 --- a/backend/app/llm/providers.py +++ b/backend/app/llm/providers.py @@ -445,4 +445,111 @@ def build_provider(agent_kind: str, api_key: str): return OpenAIProvider(api_key) if agent_kind == "gemini": return GeminiProvider(api_key) + if agent_kind == "ollama": + return OllamaProvider(api_key) raise ValueError(f"Unknown agent_kind: {agent_kind}") + +class OllamaProvider: + kind = "ollama" + + def __init__(self, api_key: str) -> None: + from openai import AsyncOpenAI + from app.store.credentials import get_ollama_base_url + base_url = get_ollama_base_url().rstrip('/') + '/v1' + self._client = AsyncOpenAI(api_key=api_key if api_key else "ollama", base_url=base_url) + + async def close(self) -> None: + try: + await self._client.close() + except Exception: + pass + + async def stream_turn( + self, + *, + system: str, + messages: List[Dict[str, Any]], + tools: List[Dict[str, Any]], + model: str, + emit: EmitFn, + ) -> Dict[str, Any]: + msg_id = f"msg-{uuid.uuid4()}" + openai_msgs: List[Dict[str, Any]] = [ + {"role": "system", "content": system} + ] + openai_msgs.extend(_translate_to_openai(messages)) + + text = "" + tool_calls: Dict[int, Dict[str, str]] = {} + input_tokens = 0 + output_tokens = 0 + + stream = await self._client.chat.completions.create( + model=model, + messages=openai_msgs, + tools=tools or None, + stream=True, + stream_options={"include_usage": True}, + ) + + async for chunk in stream: + usage = getattr(chunk, "usage", None) + if usage is not None: + input_tokens = int(getattr(usage, "prompt_tokens", 0) or 0) + output_tokens = int(getattr(usage, "completion_tokens", 0) or 0) + + if not chunk.choices: + continue + delta = chunk.choices[0].delta + + content = getattr(delta, "content", None) + if content: + text += content + await emit({ + "type": "assistant.delta", + "text": content, + "message_id": msg_id, + }) + + tc_deltas = getattr(delta, "tool_calls", None) + if tc_deltas: + for tc_delta in tc_deltas: + idx = tc_delta.index + tc = tool_calls.setdefault( + idx, {"id": "", "name": "", "arguments": ""} + ) + if tc_delta.id: + tc["id"] = tc_delta.id + fn = getattr(tc_delta, "function", None) + if fn is not None: + if getattr(fn, "name", None): + tc["name"] = fn.name + if getattr(fn, "arguments", None): + tc["arguments"] += fn.arguments + + if text: + await emit({ + "type": "assistant.complete", + "text": text, + "message_id": msg_id, + }) + if input_tokens or output_tokens: + await emit({ + "type": "usage", + "message_id": msg_id, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + }) + + tool_uses: List[Dict[str, Any]] = [] + for tc in tool_calls.values(): + try: + args = json.loads(tc["arguments"] or "{}") + except json.JSONDecodeError: + args = {} + # Ollama might not give us a tool id for OpenAIs, let's inject one if empty + t_id = tc["id"] if tc["id"] else f"call_{uuid.uuid4()}" + tool_uses.append( + {"id": t_id, "name": tc["name"], "input": args} + ) + return {"text": text, "tool_uses": tool_uses} diff --git a/backend/app/main.py b/backend/app/main.py index 0036e4f..6f6b92c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,10 @@ +# modified by agent: add ollama settings routes import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles # Make app.* loggers surface at INFO so WS lifecycle events are visible. @@ -15,6 +16,7 @@ from app.api.sessions import router as sessions_router from app.db import init_db from app.ws.session_ws import router as ws_router +from app.store.credentials import get_ollama_base_url, set_ollama_base_url @asynccontextmanager @@ -37,6 +39,20 @@ async def hello() -> dict: return {"message": "Hello from the Forge backend"} +@app.get("/api/settings/ollama") +async def get_ollama_settings() -> dict: + return {"base_url": get_ollama_base_url()} + + +@app.post("/api/settings/ollama") +async def post_ollama_settings(body: dict) -> dict: + base_url = body.get("base_url") + if not isinstance(base_url, str) or not base_url.strip() or not (base_url.startswith("http://") or base_url.startswith("https://")): + return JSONResponse(status_code=422, content={"error": "Invalid URL. Must start with http:// or https://"}) + set_ollama_base_url(base_url.strip()) + return {"ok": True} + + # Serve the built frontend bundle from app/static/ when present. The directory # is created by scripts/build-wheel.sh before packaging; in local dev it won't # exist and we skip mounting — use `npm run dev` for the frontend instead. diff --git a/backend/app/schemas/credentials.py b/backend/app/schemas/credentials.py index 887152c..c65d5fc 100644 --- a/backend/app/schemas/credentials.py +++ b/backend/app/schemas/credentials.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -AgentKind = Literal["claude", "openai", "gemini"] +AgentKind = Literal["claude", "openai", "gemini", "ollama"] class CredentialStatus(BaseModel): diff --git a/backend/app/store/credentials.py b/backend/app/store/credentials.py index 793ca99..e7d3e6d 100644 --- a/backend/app/store/credentials.py +++ b/backend/app/store/credentials.py @@ -1,10 +1,11 @@ +# modified by agent: add ollama base url config functions from datetime import datetime, timezone from typing import Optional from app.db import get_conn from app.schemas.credentials import AgentKind, CredentialStatus -AGENT_KINDS: tuple[AgentKind, ...] = ("claude", "openai", "gemini") +AGENT_KINDS: tuple[AgentKind, ...] = ("claude", "openai", "gemini", "ollama") def _now_iso() -> str: @@ -76,3 +77,12 @@ def delete_key(agent_kind: AgentKind) -> bool: conn.rollback() raise return cur.rowcount > 0 + + +def get_ollama_base_url() -> str: + url = get_key("ollama_url") # type: ignore + return url if url else "http://localhost:11434" + + +def set_ollama_base_url(url: str) -> None: + upsert_key("ollama_url", url) # type: ignore diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09b2b24..c901f75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +// modified by agent: add ollama server url configuration section to settings UI import { useCallback, useEffect, useMemo, useState } from 'react' import { api, type Session } from './api/rest' import Sidebar from './components/Sidebar' @@ -17,17 +18,14 @@ export default function App() { const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) + const [ollamaUrl, setOllamaUrl] = useState('') + const [ollamaSaving, setOllamaSaving] = useState(false) + const [ollamaStatus, setOllamaStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null) + const refresh = useCallback(async () => { try { const list = await api.sessions.list() setSessions(list) - // Drop a stale active id if the session no longer exists (e.g. - // it was deleted in another tab). Otherwise leave selection - // alone — the user lands on Home by default and explicitly opts - // into a session from there or from the sidebar. - setActiveId((prev) => - prev && list.some((s) => s.id === prev) ? prev : null, - ) } catch (e) { setError((e as Error).message) } @@ -37,6 +35,40 @@ export default function App() { refresh() }, [refresh]) + useEffect(() => { + if (page === 'settings') { + fetch('/api/settings/ollama') + .then((res) => res.json()) + .then((data) => { + if (data.base_url) setOllamaUrl(data.base_url) + }) + .catch(() => {}) + } + }, [page]) + + const saveOllama = async () => { + setOllamaSaving(true) + setOllamaStatus(null) + try { + const res = await fetch('/api/settings/ollama', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ base_url: ollamaUrl }), + }) + const data = await res.json() + if (!res.ok) { + setOllamaStatus({ type: 'error', message: data.error || 'Failed to save' }) + } else { + setOllamaStatus({ type: 'success', message: 'Saved' }) + setTimeout(() => setOllamaStatus(null), 2000) + } + } catch (e) { + setOllamaStatus({ type: 'error', message: (e as Error).message || 'Failed to save' }) + } finally { + setOllamaSaving(false) + } + } + const onCreated = async (s: Session) => { setModalOpen(false) await refresh() @@ -63,96 +95,51 @@ export default function App() { } } - // Memoize so re-renders that don't change (sessions, activeId) don't - // produce a new `active` object identity — keeps SessionView stable. const active = useMemo( () => sessions?.find((s) => s.id === activeId) ?? null, [sessions, activeId], ) return ( -
+
{ - setActiveId(id) - setPage('sessions') - }} + onSelectSession={(id) => { setActiveId(id); setPage('sessions') }} onNewSession={() => setModalOpen(true)} onDeleteSession={requestDelete} onOpenSettings={() => setPage('settings')} - onGoHome={() => { - setActiveId(null) - setPage('sessions') - }} + onGoHome={() => { setActiveId(null); setPage('sessions') }} /> - -
- {error && ( -
- {error} -
- )} +
+ {error &&
{error}
} {page === 'settings' ? ( - +
+ +
+
+
+ Ollama server + {ollamaStatus?.type === 'success' && {ollamaStatus.message}} +
+
+ setOllamaUrl(e.target.value)} disabled={ollamaSaving} style={{ flex: 1 }} /> + +
+

Enter the URL of your running Ollama server. Default: http://localhost:11434

+ {ollamaStatus?.type === 'error' &&

{ollamaStatus.message}

} +
+
+
) : active ? ( - + ) : ( - setActiveId(id)} - onNewSession={() => setModalOpen(true)} - onOpenSettings={() => setPage('settings')} - /> + setActiveId(id)} onNewSession={() => setModalOpen(true)} onOpenSettings={() => setPage('settings')} /> )}
- - {modalOpen && ( - setModalOpen(false)} - onCreated={onCreated} - /> - )} - - {pendingDelete && ( - (deleting ? null : setPendingDelete(null))} - /> - )} + {modalOpen && setModalOpen(false)} onCreated={onCreated} />} + {pendingDelete && (deleting ? null : setPendingDelete(null))} />}
) } - diff --git a/frontend/src/api/rest.ts b/frontend/src/api/rest.ts index c798c28..67136dd 100644 --- a/frontend/src/api/rest.ts +++ b/frontend/src/api/rest.ts @@ -1,4 +1,4 @@ -export type AgentKind = 'claude' | 'openai' | 'gemini' +export type AgentKind = 'claude' | 'openai' | 'gemini' | 'ollama' export type SessionStatus = | 'idle' diff --git a/frontend/src/components/CredentialsPage.tsx b/frontend/src/components/CredentialsPage.tsx index 36873af..a31703a 100644 --- a/frontend/src/components/CredentialsPage.tsx +++ b/frontend/src/components/CredentialsPage.tsx @@ -6,6 +6,7 @@ const AGENTS: { kind: AgentKind; label: string; placeholder: string }[] = [ { kind: 'claude', label: 'Claude', placeholder: 'sk-ant-...' }, { kind: 'openai', label: 'OpenAI', placeholder: 'sk-...' }, { kind: 'gemini', label: 'Gemini', placeholder: 'AIza...' }, + { kind: 'ollama', label: 'Ollama API Key', placeholder: 'sk-...' }, ] export default function CredentialsPage() { diff --git a/frontend/src/components/NewSessionModal.tsx b/frontend/src/components/NewSessionModal.tsx index 55fe9e6..3febd4e 100644 --- a/frontend/src/components/NewSessionModal.tsx +++ b/frontend/src/components/NewSessionModal.tsx @@ -16,6 +16,7 @@ const AGENT_OPTIONS: { kind: AgentKind; label: string }[] = [ { kind: 'claude', label: 'Claude' }, { kind: 'openai', label: 'OpenAI' }, { kind: 'gemini', label: 'Gemini' }, + { kind: 'ollama', label: 'Ollama' }, ] type Mode = 'form' | 'browse' @@ -180,7 +181,7 @@ export default function NewSessionModal({ borderRadius: 6, }} > - No API keys saved yet. Go to Settings and add at least one key (Claude, OpenAI, or Gemini). + No API keys saved yet. Go to Settings and add at least one key (Claude, OpenAI, Gemini) or configure your Ollama server.

)} diff --git a/frontend/src/test_script.js b/frontend/src/test_script.js new file mode 100644 index 0000000..cf49ea4 --- /dev/null +++ b/frontend/src/test_script.js @@ -0,0 +1 @@ +console.log("Just checking"); From 378ee537513fb2207a6534f50f85ee9229ce7ba7 Mon Sep 17 00:00:00 2001 From: chintan712 Date: Fri, 24 Apr 2026 14:49:33 -0400 Subject: [PATCH 2/3] increased context window for the ollama models --- backend/app/llm/model_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/llm/model_info.py b/backend/app/llm/model_info.py index 42bebd2..20ac84f 100644 --- a/backend/app/llm/model_info.py +++ b/backend/app/llm/model_info.py @@ -135,5 +135,5 @@ async def _fetch_gemini(model: str, api_key: str) -> Optional[ModelInfo]: return None async def _fetch_ollama(model: str) -> Optional[ModelInfo]: - return ModelInfo(context_window=32000, max_output_tokens=8192, source="api") + return ModelInfo(context_window=256000, max_output_tokens=8192, source="api") From 03fa8510fd86ca26bf6e54e40c174666ca0a840f Mon Sep 17 00:00:00 2001 From: chintan712 Date: Fri, 24 Apr 2026 16:00:20 -0400 Subject: [PATCH 3/3] changed text for ollama UI --- frontend/src/App.tsx | 2 +- frontend/src/components/CredentialsPage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c901f75..362f040 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -120,7 +120,7 @@ export default function App() {
- Ollama server + Ollama (Local Model) {ollamaStatus?.type === 'success' && {ollamaStatus.message}}
diff --git a/frontend/src/components/CredentialsPage.tsx b/frontend/src/components/CredentialsPage.tsx index a31703a..eb4fe6f 100644 --- a/frontend/src/components/CredentialsPage.tsx +++ b/frontend/src/components/CredentialsPage.tsx @@ -6,7 +6,7 @@ const AGENTS: { kind: AgentKind; label: string; placeholder: string }[] = [ { kind: 'claude', label: 'Claude', placeholder: 'sk-ant-...' }, { kind: 'openai', label: 'OpenAI', placeholder: 'sk-...' }, { kind: 'gemini', label: 'Gemini', placeholder: 'AIza...' }, - { kind: 'ollama', label: 'Ollama API Key', placeholder: 'sk-...' }, + { kind: 'ollama', label: 'Ollama (Cloud Model)', placeholder: 'sk-...' }, ] export default function CredentialsPage() { @@ -21,7 +21,7 @@ export default function CredentialsPage() { }, []) return ( -
+

Settings

API keys are stored locally in ~/.forge/app.db. They never leave your machine.