Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/api-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,32 @@ jobs:

- name: Test
run: uv run --extra torch pytest -v -m "real_model or not real_model"

quickstart:
# The documented start command, run literally, must come up without a model and answer.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
python-version: "3.13"

- name: Install (as in the README)
run: uv sync --extra torch

- name: Start the server and ask for its version
run: |
uv run --extra torch python -m api run server > server.log 2>&1 &
pid=$!
ok=0
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:23100/api/version; then ok=1; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
kill -INT "$pid" 2>/dev/null || true
wait "$pid" || true
cat server.log
test "$ok" = 1
2 changes: 1 addition & 1 deletion PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ docs/ INTENT, SPEC, locale/, guide/ (GitHub Pages), serving/ (서
uv sync --extra torch # Python 의존성 (Python 3.13). torchnative 는 --extra torchnative
# torchnative 는 PyPI 프리릴리스(2026-10-03 기준 0.1.0b4). 버전 하한은 결정 대기
uv run --extra torch pytest # Python 테스트 (api/tests)
python -m api run server # 0.0.0.0:23100
uv run --extra torch python -m api run server # 127.0.0.1:23100 (GEMSTONE_HOST 로 변경)
./gradlew :app:run # 데스크톱
./gradlew :app:installDebug # Android
./gradlew :app:wasmJsBrowserDevelopmentRun # 웹 개발 서버
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ uv sync --extra torch # or --extra torchnative (the two cannot be insta
**2. Start the model server** (port `23100`; the model downloads on first use)

```bash
python -m api run server
uv run --extra torch python -m api run server
```

Open <http://127.0.0.1:23100/> for the web client the server bundles.

The server listens on `127.0.0.1` only. To serve other machines, set `GEMSTONE_HOST` (like
`OLLAMA_HOST`) and an API key: `GEMSTONE_HOST=0.0.0.0:23100 GEMSTONE_API_KEY=<key> python -m api run server`.
`OLLAMA_HOST`) and an API key: `GEMSTONE_HOST=0.0.0.0:23100 GEMSTONE_API_KEY=<key> uv run --extra torch python -m api run server`.
Clients then send `Authorization: Bearer <key>`. `GEMSTONE_ORIGINS` allows more browser origins and
`--reload` (or `GEMSTONE_DEV=1`) turns on auto-reload for development.

Expand Down
5 changes: 3 additions & 2 deletions api/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ def main(argv):
sys.exit("usage: python -m api run server [host] [port] [--reload]\n"
"env: GEMSTONE_HOST=host[:port], GEMSTONE_API_KEY, GEMSTONE_ORIGINS, GEMSTONE_DEV=1")
try:
from api.src.main import security
from api.src.main import security, settings
server_object = "api.src.main.server:app"
except ImportError:
from src.main import security
from src.main import security, settings
server_object = "src.main.server:app"
settings.configure_logging()
config = security.resolve_server_config(argv)
security.warn_if_open(config.host)
run_uvicorn(
Expand Down
48 changes: 23 additions & 25 deletions api/src/main/models/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import traceback
import logging
from re import finditer, DOTALL
from dataclasses import dataclass
from typing import Generator, Optional, List, Dict, Union
Expand All @@ -7,6 +7,8 @@
from ..engine import Engine, ModelBusy
from ..utils import FunctionCalling, FunctionCallResult

log = logging.getLogger("gemstone.model")


@dataclass
class Tags:
Expand Down Expand Up @@ -92,24 +94,21 @@ def parse_tool_calling(
)

# Finalize the tool calls
print("\n")
spinner = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
stat = 0
waiting = False
while True:
queued = len(result_obj.job_list)
final_result = result_obj.finalize(
chat_history,
(self.special_tags.TOOLCALL, self.special_tags.TOOLCALL_END),
print_output=print_output
(self.special_tags.TOOLCALL, self.special_tags.TOOLCALL_END)
)
if queued > 0 and final_result is False:
print(f"\r{spinner[stat % len(spinner)]} Waiting for tool calls to finish...", end="", flush=True)
if queued > 0 and final_result is False and not waiting:
waiting = True
log.debug("Waiting for %d tool call(s) to finish", queued)
if final_result is False:
stat += 1
result_obj.wait(0.1) # sleeps until a tool finishes; wakes to animate the spinner
result_obj.wait(0.1) # sleeps until a tool finishes
continue
if queued > 0:
print("\r[✔] Tool calls are finalized successfully.", flush=True)
log.debug("Tool calls are finalized")

if stream:
yield final_result
Expand Down Expand Up @@ -150,7 +149,8 @@ def chat(
stream (bool, optional): Stream. Defaults to True.
max_new_tokens (int, optional): Max new tokens. Defaults to 1024.
repeat_penalty (float, optional): Repeat penalty. Defaults to 1.0.
print_output (bool, optional): Print output. Defaults to False.
print_output (bool, optional): Ignored, kept for callers. Prompts and answers are logged at DEBUG
(GEMSTONE_LOG_LEVEL=DEBUG), never printed.
tool_call_caches (dict, optional): The session's tool-result cache (call id -> result).
**kwargs: Additional arguments
"""
Expand Down Expand Up @@ -184,11 +184,8 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6):
prompt = prompt[:-1] # Remove the last user prompt if it's None
user_prompt = None # Reset user prompt to None after appending

if print_output and initial_operation:
print("PROMPT:")
for line in prompt:
print(line)
print("\nANSWER:")
if initial_operation and log.isEnabledFor(logging.DEBUG):
log.debug("PROMPT:\n%s", "\n".join(str(line) for line in prompt))
if initial_operation:
initial_operation = False
# TODO: Add kv cache control for tool-calling here
Expand All @@ -213,36 +210,37 @@ def adaptive_special_tag_buffering(outs, wait_tokens_for=6):
chat_history=chat_history,
tools=tools,
stream=stream,
print_output=print_output,
tool_call_caches=tool_call_caches
)

if stream:
answer = []
try:
for word in outputs:
if word:
if self.special_tags.TOOLCALL in word and self.special_tags.TOOLCALL_END in word:
function_called = True # flag on
if print_output: print(word, end="", flush=True)
answer.append(word)
yield word
except ValueError as e: # Over token limit error
traceback.print_exc()
log.exception("Chat failed")
if "token" in str(e) and "limit" in str(e):
message = "\n\nERROR: Chat is unexpectedly terminated due to token limit. Please shorten your prompt or chat history."
else:
message = f"\n\nERROR: {type(e)} - Something went wrong while processing the chat. Please try again later.\n{e}"
if print_output: print(message, end="", flush=True)
answer.append(message)
yield message
except ModelBusy:
raise # the server maps it to 503 / WebSocket close 1013, so it must not become text
except Exception as e: # never let a failure kill the stream without a message
traceback.print_exc()
log.exception("Chat failed")
message = f"\n\nERROR: {type(e).__name__} - Something went wrong while processing the chat.\n{e}"
if print_output: print(message, end="", flush=True)
answer.append(message)
yield message
finally:
print()
if log.isEnabledFor(logging.DEBUG):
log.debug("ANSWER:\n%s", "".join(answer))
else:
if self.special_tags.TOOLCALL in outputs and self.special_tags.TOOLCALL_END in outputs:
function_called = True # flag on
print(outputs, flush=True)
log.debug("ANSWER:\n%s", outputs)
3 changes: 2 additions & 1 deletion api/src/main/models/qwen3/model.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import List, Dict, Union, Generator, Optional

from ..base import ChatHistory, FunctionCalling, BaseModel
Expand Down Expand Up @@ -42,7 +43,7 @@
3. Call a new tool only when there is no relevant cache or the cached data is insufficient.

Remember: Your role is to be a reliable, knowledgeable professional assistant who thinks carefully before responding and actively seeks current information when needed."""
print("INFO: Use default system prompt -", system_prompt)
logging.getLogger("gemstone.model").debug("Use default system prompt - %s", system_prompt)


class Qwen3Model(BaseModel):
Expand Down
29 changes: 23 additions & 6 deletions api/src/main/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import anyio
import uvicorn

import traceback
import contextlib
import logging
import threading
import json
import os

from .settings import STATIC_DIR, WEBPACK_DIR, Session
from .settings import STATIC_DIR, WEBPACK_DIR, Session, configure_logging
from . import registry as registry_module
from .leases import acquire
from .registry import DEFAULT_KEEP_ALIVE, ModelBusy, ModelUnavailable, in_catalogue
Expand All @@ -20,7 +21,23 @@
from . import security


app = FastAPI()
configure_logging() # GEMSTONE_LOG_LEVEL
log = logging.getLogger("gemstone.server")


@contextlib.asynccontextmanager
async def lifespan(_app):
""" On shutdown close every loaded engine (stops the batching loops, frees the memory). """
yield
log.info("Shutting down: unloading models")
try:
# Read the module attribute at call time: the registry may be replaced (tests).
await run_in_threadpool(registry_module.registry.unload)
except Exception:
log.exception("Unloading models on shutdown failed")


app = FastAPI(lifespan=lifespan)
security.install(app) # Origin policy and the optional API key (SPEC S1.1)
app.include_router(ollama_router) # Ollama-compatible API (SPEC S1.15), including POST /api/chat
app.mount("/static", StaticFiles(directory=STATIC_DIR, html=True), name="static")
Expand Down Expand Up @@ -105,7 +122,7 @@ async def chat_with_streaming(websocket: WebSocket):
session = Session(session_id=session_id)
hf_id = registry_module.registry.resolve(session.model_id) # derived models too
except Exception:
traceback.print_exc()
log.exception("WebSocket chat failed")
await websocket.close(code=1008, reason="Invalid session ID or model not found.")
return

Expand All @@ -126,7 +143,7 @@ async def chat_with_streaming(websocket: WebSocket):
await websocket.close(code=1011, reason=_reason(e.reason))
return
except Exception:
traceback.print_exc()
log.exception("Loading the model failed")
await websocket.close(code=1008, reason="Model not found or failed to load.")
return

Expand All @@ -138,7 +155,7 @@ async def chat_with_streaming(websocket: WebSocket):
done = object()
try:
tokens = lease.model.chat( # a generator: nothing runs until the first `next`
chat_history, user_prompt, print_output=True, cancel=cancel, tool_call_caches=session.tool_call_caches
chat_history, user_prompt, cancel=cancel, tool_call_caches=session.tool_call_caches
)
while (token := await run_in_threadpool(next, tokens, done)) is not done:
await websocket.send_text(token)
Expand Down
56 changes: 50 additions & 6 deletions api/src/main/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
from datetime import datetime
from uuid import uuid4
import logging
import os
import time

logger = logging.getLogger("gemstone.session")

# Idle seconds after which a session expires (GEMSTONE_SESSION_TTL; 0 = never). Default: 24 hours.
DEFAULT_SESSION_TTL = 24 * 3600.0
clock = time.monotonic # replaced by a fake clock in tests


def configure_logging() -> None:
"""
Set the level of the `gemstone` loggers from GEMSTONE_LOG_LEVEL (default INFO; DEBUG also logs
prompts and answers) and make sure they reach the console in uvicorn's format.
"""
name = os.environ.get("GEMSTONE_LOG_LEVEL", "INFO").strip().upper()
level = logging.getLevelName(name)
log = logging.getLogger("gemstone")
log.setLevel(level if isinstance(level, int) else logging.INFO)
if not any(getattr(h, "_gemstone", False) for h in log.handlers):
handler = logging.StreamHandler()
handler._gemstone = True
handler.setFormatter(logging.Formatter("%(levelname)s: %(name)s - %(message)s"))
log.addHandler(handler)
log.propagate = False


def session_ttl() -> float:
try:
return max(0.0, float(os.environ.get("GEMSTONE_SESSION_TTL", DEFAULT_SESSION_TTL)))
except ValueError:
return DEFAULT_SESSION_TTL


# The model catalogue lives in the registry (registry.CATALOGUE, SPEC S1.2).
Expand All @@ -19,8 +51,11 @@ class Session:

def __new__(cls, model_id: str = None, session_id: str = None):
""" Create a new session or return an existing one """
cls.sweep()
if session_id and session_id in cls.__sessions:
return cls.__sessions[session_id]
session = cls.__sessions[session_id]
session.last_used = clock()
return session
else:
return super().__new__(cls)

Expand All @@ -34,18 +69,27 @@ def __init__(self, model_id: str = None, session_id: str = None):
self.model_id = model_id
# The random suffix keeps sessions created in the same second apart (each has its own cache)
self.session_id = f"{model_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid4().hex[:8]}"
print("INFO: Session", self.session_id, "is CREATED for model", model_id)
self.last_used = clock()
self.__sessions[self.session_id] = self
print("INFO: Current sessions:", list(self.__sessions))
logger.info("Session %s is CREATED for model %s (%d open)", self.session_id, model_id, len(self.__sessions))
self.tool_call_caches: dict[str, str] = {} # tool call id -> result (S1.6)

@classmethod
def sweep(cls) -> None:
""" Drop the sessions idle longer than the TTL, with their tool caches. """
ttl = session_ttl()
if ttl <= 0:
return
now = clock()
for sid in [sid for sid, s in cls.__sessions.items() if now - s.last_used > ttl]:
logger.info("Session %s EXPIRED after %.0f s idle", sid, ttl)
cls.__sessions.pop(sid).tool_call_caches.clear()

@classmethod
def close(cls, session_id: str):
""" Close the session. The model stays resident for its keep_alive (S1.14). """
if session_id in cls.__sessions:
print("INFO: Session", session_id, "is DELETED for model", cls.__sessions[session_id].model_id)
cls.__sessions[session_id].tool_call_caches.clear()
del cls.__sessions[session_id]
print("INFO: Current sessions:", list(cls.__sessions))
logger.info("Session %s is DELETED for model %s", session_id, cls.__sessions.pop(session_id).model_id)
else:
raise ValueError(f"Session {session_id} not found")
9 changes: 0 additions & 9 deletions api/src/main/utils/web_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@
pass


# Load environment variables from .env file
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# dotenv not installed, skip
pass


class WebSearchAPI:
"""Web search API wrapper using SerpApi with robust fallbacks."""

Expand Down
2 changes: 1 addition & 1 deletion api/tests/test_lease_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def test_a_finished_reply_releases_the_lease_once(setup, route, stream):
response = client.post(path, json={**body, "stream": stream})
assert response.status_code == 200
assert "Hello" in response.text
assert_released_once_and_switch_completes(setup)
assert_released_once_and_switch_completes(setup) # before shutdown unloads the model


@pytest.mark.parametrize("route", list(STREAM_ROUTES))
Expand Down
Loading
Loading