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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ The server listens on `127.0.0.1` only. To serve other machines, set `GEMSTONE_H
Clients then send `Authorization: Bearer <key>`. `GEMSTONE_ORIGINS` allows more browser origins and
`--reload` (or `GEMSTONE_DEV=1`) turns on auto-reload for development.

**Point existing clients at Gemstone** (it listens on `23100`, not Ollama's `11434` or OpenAI's HTTPS host):

```bash
OLLAMA_HOST=http://127.0.0.1:23100 ollama run qwen3 # the ollama CLI and Ollama clients
OPENAI_BASE_URL=http://127.0.0.1:23100/v1 OPENAI_API_KEY=x python my_script.py # OpenAI SDKs
```

`qwen3`, `qwen3:latest` and `qwen3:0.6b` are the built-in model (fetched on first use); any Hugging Face
model is pulled by its id, e.g. `ollama pull Org/Model`. Other Ollama library names (`llama3.2`) are not
available. When `GEMSTONE_API_KEY` is set on the server, send it as the key: `OPENAI_API_KEY=<key>` for
OpenAI SDKs, or an `Authorization: Bearer <key>` header for other clients (if your Ollama client cannot send
a header, use it on a loopback server without a key). `think: false` (Ollama) and `reasoning_effort: "none"`
(OpenAI) switch Qwen3's reasoning off.

**3. Run a native client**

```bash
Expand Down
7 changes: 6 additions & 1 deletion api/src/main/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
`model.generate` at a time, with no batched request running. See `Engine.__call__`.
"""
from contextlib import contextmanager
from typing import Dict, Generator, List, Optional, Union
from typing import Any, Dict, Generator, List, Optional, Union
import itertools
import logging
import os
Expand Down Expand Up @@ -199,11 +199,15 @@ def __call__(
repeat_penalty: float = 1.0,
seed: Optional[int] = None,
cancel: Optional[threading.Event] = None,
chat_template_kwargs: Optional[Dict[str, Any]] = None,
**kwargs
) -> Generator[str, None, None]:
"""
Stream the reply to `messages` as text chunks.

`chat_template_kwargs` are extra variables for the chat template (`enable_thinking=False`
switches Qwen3's reasoning off). Unlike `kwargs` they do not make the request exclusive.

`stream` is accepted for compatibility with the model layer and ignored: the reply is
always produced as a stream. `max_new_tokens <= 0` means "as many as fit": up to the context
length, but when the request can join the batch, up to what the paged KV cache holds
Expand All @@ -223,6 +227,7 @@ def __call__(
chat_template=self.chat_template,
add_generation_prompt=True,
tokenize=False,
**(chat_template_kwargs or {}),
)
input_ids = self.tokenizer(prompt, add_special_tokens=False)["input_ids"]
prompt_length = len(input_ids)
Expand Down
38 changes: 22 additions & 16 deletions api/src/main/ollama_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
`Registry.resolve` (derived models included); residency and keep_alive are handled by
`registry.Registry`.
"""
from importlib import metadata
from typing import Iterator, List, Optional
import itertools
import json
Expand All @@ -25,9 +24,10 @@
from starlette.concurrency import run_in_threadpool

from . import registry as registry_module
from .version import VERSION
from .leases import LeasedStreamingResponse, acquire, once
from .registry import (DERIVED_PARAMETERS, ModelBusy, ModelUnavailable, UnsupportedModel, alias_key, format_parameters, is_builtin_name, now_iso,
parse_keep_alive)
from .registry import (DERIVED_PARAMETERS, ModelBusy, ModelUnavailable, UnsupportedModel, alias_key, format_parameters,
in_catalogue, is_builtin_name, now_iso, parse_keep_alive)


router = APIRouter()
Expand Down Expand Up @@ -246,14 +246,19 @@ def _count_tokens(engine, text: str) -> Optional[int]:
return None


def _prompt_tokens(engine, messages, tools) -> Optional[int]:
def _template_kwargs(think: Optional[bool]) -> dict:
""" `think` -> the chat template's `enable_thinking` (Qwen3 and others); unset leaves the template's default. """
return {} if think is None else {"chat_template_kwargs": {"enable_thinking": think}}


def _prompt_tokens(engine, messages, tools, template_kwargs=None) -> Optional[int]:
tokenizer = getattr(engine, "tokenizer", None)
if tokenizer is None:
return None
try:
prompt = tokenizer.apply_chat_template(
messages, tools=tools or None, chat_template=getattr(engine, "chat_template", None),
add_generation_prompt=True, tokenize=False)
add_generation_prompt=True, tokenize=False, **(template_kwargs or {}))
except Exception:
return None
return _count_tokens(engine, prompt)
Expand Down Expand Up @@ -288,8 +293,9 @@ def _generate(lease, messages, tools, kwargs, stops, think, cancel, started, rel
first_token = None
chunks = None
try:
prompt_eval_count = _prompt_tokens(engine, messages, tools)
chunks = engine(messages, tools=tools or None, cancel=cancel, **kwargs)
template = _template_kwargs(think)
prompt_eval_count = _prompt_tokens(engine, messages, tools, template.get("chat_template_kwargs"))
chunks = engine(messages, tools=tools or None, cancel=cancel, **template, **kwargs)
for chunk in chunks:
if first_token is None:
first_token = time.perf_counter()
Expand Down Expand Up @@ -381,11 +387,11 @@ async def _run(body: dict, messages: list, tools, render, finish):
if keep_alive == 0:
await run_in_threadpool(reg.unload, hf_id)
return JSONResponse(finish(name, "unload"))
lease = await acquire(reg, hf_id, keep_alive)
lease = await acquire(reg, hf_id, keep_alive, in_catalogue(name))
reg.release(lease)
return JSONResponse(finish(name, "load"))

lease = await acquire(reg, hf_id, keep_alive)
lease = await acquire(reg, hf_id, keep_alive, in_catalogue(name))
cancel = threading.Event()
release = once(lambda: reg.release(lease))
events = _generate(lease, messages, tools, kwargs, stops, think, cancel, started, release)
Expand Down Expand Up @@ -785,7 +791,7 @@ async def _embed(body: dict, inputs: List[str]) -> tuple:
keep_alive = parse_keep_alive(body.get("keep_alive"))
truncate = body.get("truncate", True) is not False
reg = _registry()
lease = await acquire(reg, reg.resolve(name), keep_alive)
lease = await acquire(reg, reg.resolve(name), keep_alive, in_catalogue(name))
try:
embed = getattr(lease.engine, "embed", None)
if embed is None:
Expand Down Expand Up @@ -828,10 +834,10 @@ async def embeddings(request: Request):
return {"embedding": vectors[0] if vectors else []}


@router.get("/api/version")
@router.api_route("/api/version", methods=["GET", "HEAD"])
async def version():
""" The Gemstone version (clients use it to check that the server is up). """
try:
return {"version": metadata.version("gemstone")}
except metadata.PackageNotFoundError:
return {"version": "0.0.0"}
"""
The Gemstone version (SPEC S1.15). It is a plain `MAJOR.MINOR.PATCH` string, which is what Ollama
clients compare as semver; it is above every Ollama 0.x, so a "needs Ollama >= 0.x" check passes.
"""
return {"version": VERSION}
31 changes: 25 additions & 6 deletions api/src/main/openai_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from . import registry as registry_module
from .leases import LeasedStreamingResponse, acquire, once
from .registry import ModelBusy, ModelUnavailable, in_catalogue, parse_keep_alive
from .registry import ModelBusy, ModelUnavailable, in_catalogue, parse_keep_alive, unknown_model_message


router = APIRouter(prefix="/v1")
Expand Down Expand Up @@ -58,6 +58,21 @@ class ChatCompletionRequest(Schema):
stream: bool = False
stream_options: Optional[Dict[str, Any]] = None
n: Optional[int] = None
reasoning_effort: Optional[str] = None # "none" / "minimal" switch Qwen3-style thinking off
chat_template_kwargs: Optional[Dict[str, Any]] = None # extension, as in vLLM


def _template_kwargs(req: "ChatCompletionRequest") -> Dict[str, Any]:
"""
Extra variables for the chat template. `reasoning_effort` (OpenAI's field) maps to
`enable_thinking`: `none` / `minimal` is off, any other value on. `chat_template_kwargs`
(vLLM's extension) is passed through and wins. Neither set: the template's own default.
"""
out: Dict[str, Any] = {}
if req.reasoning_effort is not None:
out["enable_thinking"] = req.reasoning_effort.strip().lower() not in ("none", "minimal")
out.update(req.chat_template_kwargs or {})
return out


class APIError(Exception):
Expand Down Expand Up @@ -264,11 +279,14 @@ class Generation:
or None once the reply is complete; `close` stops generation and releases the engine.
"""

def __init__(self, engine, messages, tools, parameters: Dict[str, Any], stops: List[str]):
def __init__(self, engine, messages, tools, parameters: Dict[str, Any], stops: List[str],
template_kwargs: Optional[Dict[str, Any]] = None):
self.engine, self.messages, self.tools = engine, messages, tools
self.template_kwargs = template_kwargs or {}
extra = dict(chat_template_kwargs=self.template_kwargs) if self.template_kwargs else {}
self.max_new_tokens = parameters.get("max_new_tokens", 0)
self.cancel = threading.Event()
self.tokens: Iterator[str] = engine(messages, tools=tools, cancel=self.cancel, **parameters)
self.tokens: Iterator[str] = engine(messages, tools=tools, cancel=self.cancel, **extra, **parameters)
self.parser = OutputParser(parse_tools=bool(tools))
self.stopper = _StopMatcher(stops)
self.raw: List[str] = []
Expand Down Expand Up @@ -319,7 +337,7 @@ def _usage(self) -> Dict[str, int]:
completion = len(tokenizer.encode("".join(self.raw), add_special_tokens=False))
text = tokenizer.apply_chat_template(
self.messages, tools=self.tools, chat_template=getattr(self.engine, "chat_template", None),
add_generation_prompt=True, tokenize=False,
add_generation_prompt=True, tokenize=False, **self.template_kwargs,
)
prompt = len(tokenizer.encode(text, add_special_tokens=False))
except Exception: # usage is informative; never fail a reply over it
Expand Down Expand Up @@ -398,7 +416,8 @@ async def _chat_completions(request: Request):
try:
hf_id = registry.resolve(req.model) # derived models (`/api/create`, `/api/copy`) too
except LookupError:
raise APIError(404, f"The model '{req.model}' does not exist.", param="model", code="model_not_found")
raise APIError(404, f"The {unknown_model_message(req.model)}",
param="model", code="model_not_found")

derived = registry.derived(req.model) or {}
messages = _engine_messages(req.messages)
Expand Down Expand Up @@ -446,7 +465,7 @@ async def _complete(req: ChatCompletionRequest, lease, release, messages, tools,
parameters["seed"] = req.seed
stops = [req.stop] if isinstance(req.stop, str) else list(req.stop or derived.get("stop") or [])

generation = Generation(engine, messages, tools, parameters, stops)
generation = Generation(engine, messages, tools, parameters, stops, _template_kwargs(req))

def finish():
try:
Expand Down
22 changes: 19 additions & 3 deletions api/src/main/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class CatalogueEntry:
**{name: entry.hf_id for name, entry in CATALOGUE.items()}, # qwen3, default
"qwen3:0.6b": "Qwen/Qwen3-0.6B", # Ollama library name
"qwen3:latest": "Qwen/Qwen3-0.6B",
"qwen3:0.6b-fp16": "Qwen/Qwen3-0.6B", # Ollama's unquantised tag; the weights are served as published
"smollm2:135m": "HuggingFaceTB/SmolLM2-135M-Instruct", # Ollama's smollm2:135m is the instruct model
}

Expand All @@ -79,12 +80,27 @@ def resolve_model_name(name: str) -> str:
repo, _, tag = name.partition(":")
if "/" in repo and tag in ("", "latest") and re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
return repo
raise LookupError(f"model '{name}' not found")
raise LookupError(unknown_model_message(name))


def unknown_model_message(name: str) -> str:
""" The 404 text for a name that is neither known nor a Hugging Face id: what exists and how to add more. """
names = ", ".join(sorted(n for n in MODEL_ALIASES if n != "default"))
return (f"model '{name}' not found. Gemstone serves these names: {names}. "
"Any Hugging Face model can be pulled by its id (`Org/Model`), "
"for example `ollama pull HuggingFaceTB/SmolLM2-360M-Instruct`; Ollama library names such as "
"`llama3.2` are not available.")


def in_catalogue(name: str) -> bool:
""" True for a Gemstone model name (`qwen3`, `default`). """
return isinstance(name, str) and name.strip().lower() in CATALOGUE
"""
True for a Gemstone catalogue model, by its own name (`qwen3`, `default`) or an Ollama name that
maps to it (`qwen3:latest`, `qwen3:0.6b`): such a model is fetched on first use (S1.14).
"""
if not isinstance(name, str):
return False
key = name.strip().lower()
return key in CATALOGUE or MODEL_ALIASES.get(key) in {e.hf_id for e in CATALOGUE.values()}


_plain_classes: dict = {}
Expand Down
8 changes: 7 additions & 1 deletion api/src/main/server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.responses import RedirectResponse, FileResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
import anyio
Expand Down Expand Up @@ -40,6 +40,12 @@ def root():
return FileResponse(os.path.join(WEBPACK_DIR, "gemstone.html"))


@app.head("/")
def root_head():
""" Health check, as Ollama answers `HEAD /` (SPEC S1.15): 200 with no body and no key. """
return Response(status_code=200, media_type="text/plain")


@app.get("/composeResources/{path:path}")
def resource(path: str):
""" Redirect to the chat page """
Expand Down
6 changes: 6 additions & 0 deletions api/src/main/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
The Gemstone version, defined once. The project is not installed as a package (no build system),
so `importlib.metadata` cannot supply it; `pyproject.toml` must carry the same string
(`api/tests/test_client_compat.py` checks that).
"""
VERSION = "1.0.0"
Loading
Loading