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
5 changes: 4 additions & 1 deletion .claude/skills/update-account-server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ own: `.github/workflows/cloud.yml`, dispatched from ANY branch, runs the

The folder's `data/` is the service's whole state and its secret (signing
keys, token hashes) and `.env` holds the SMTP/Turnstile/OAuth credentials —
never read them out, copy them off the host, or overwrite them.
never read them out, copy them off the host, or overwrite them. The same
goes for `share.env` (the share host's client secret) and `share-data/`
(its published pages); the `share` service is a Gamma image pinned by tag
in `compose.yml` and is updated only by changing that tag.

## Publish from the branch

Expand Down
22 changes: 13 additions & 9 deletions CLAUDE.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Open a paper by pasting any link — arXiv, DOI, or a publisher page; Gamma find
- **Highlight** — select text or drag a box around a figure, pick a color, add a comment. Each highlight becomes a block in your notes. Highlights already saved in the file by Acrobat, Preview or SumatraPDF come in as blocks too.
- **Draw** — with a stylus or the mouse: circle a claim, sketch an arrow, highlight freely. Lasso strokes to move, resize, rotate or recolor them; erase whole strokes or part of one. Ink is a note block linked to its place on the page.
- **Follow citations** — references in the PDF are clickable; a global **← Back** unwinds jumps across documents, and a cited arXiv/DOI paper is one click from your library.
- **Translate** — redraw a page in your language in place, figures untouched, or translate just a selected sentence. Microsoft's free service works with no setup; a chat model, Google or Youdao are one setting away.

→ Guide: [Reading and highlighting](./docs/user_guide.md#reading-and-highlighting) · [Draw with a pen](./docs/user_guide.md#draw-with-a-pen) · [Links inside the PDF](./docs/user_guide.md#links-inside-the-pdf)
→ Guide: [Reading and highlighting](./docs/user_guide.md#reading-and-highlighting) · [Draw with a pen](./docs/user_guide.md#draw-with-a-pen) · [Links inside the PDF](./docs/user_guide.md#links-inside-the-pdf) · [Translate a paper](./docs/user_guide.md#translate-a-paper)

## Take notes

Expand Down
4 changes: 3 additions & 1 deletion backend/gamma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ auth.py session middleware → request.state.user; request → worksp
seed.py workspace file creation, guest welcome page, first admin
blocks_store.py recursive-CTE tree helpers
storage.py uploads (content-addressed) + orphan cleanup
ai_client.py provider HTTP protocols + streaming response parsing
ai_protocols/ one adapter per AI wire protocol (request, stream, usage, models, quota)
ai_client.py provider-agnostic AI transport (open, read, stream, errors)
ai_catalog.py live model listings + context windows (provider, then models.dev)
ai_context.py PDF attachments, extraction, and chat context assembly
logseq_import.py EDN / Markdown importers
app.py assembly + SPA serving
Expand Down
132 changes: 132 additions & 0 deletions backend/gamma/ai_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""What a provider entry offers, asked live: its model listing and each
model's context window. The protocol adapters (gamma/ai_protocols) build the
requests and read the answers; this module fetches and caches them. Nothing
here is a table of model names — model facts come from the provider, or
from the public models.dev catalog when the provider's listing carries no
context window (OpenAI's and DeepSeek's don't).

``fetch_json`` is the one fetch every listing, quota and credential check
goes through (a short, UI-friendly timeout)."""

import json
import re
import threading
import time
from urllib.request import Request as URLRequest, urlopen

from . import ai_protocols
from .logbuf import log

FETCH_TIMEOUT = 5
MODELS_DEV_URL = "https://models.dev/api.json"
MODELS_DEV_TIMEOUT = 15
# Like the Codex version: a good answer is kept for hours; a failed lookup
# is retried after minutes, the last good answer served meanwhile.
WINDOW_TTL = 6 * 3600
WINDOW_RETRY = 600

_listings = {} # "<provider id>|<base url>" -> {"windows": {model: n}, "until": t}
_listings_lock = threading.Lock()
_models_dev = {"windows": None, "until": 0.0} # windows: model id -> [(provider key, n)]
_models_dev_lock = threading.Lock()


def fetch_json(req: URLRequest):
with urlopen(req, timeout=FETCH_TIMEOUT) as resp:
return json.loads(resp.read())


def list_models(conf: dict) -> list:
"""The entry's chat models as ``[{id, context_window}]`` (0 = the
listing names none), in the order to offer them. Raises what the fetch
raises (an HTTPError carries the provider's status)."""
proto = ai_protocols.of(conf)
return proto.models(fetch_json(proto.models_request(conf)), conf)


def _listed_windows(provider_id: str, conf: dict) -> dict:
"""{model: window} from the entry's own listing, cached."""
key = f"{provider_id}|{conf['base_url']}"
with _listings_lock:
now = time.time()
cached = _listings.get(key)
if cached and now < cached["until"]:
return cached["windows"]
try:
windows = {m["id"]: m["context_window"] for m in list_models(conf) if m["context_window"]}
_listings[key] = {"windows": windows, "until": now + WINDOW_TTL}
except Exception as e:
log.warning(f"[ai] model listing for context windows failed ({conf.get('name')}): {e}")
_listings[key] = {"windows": cached["windows"] if cached else {}, "until": now + WINDOW_RETRY}
return _listings[key]["windows"]


def _models_dev_windows() -> dict:
"""models.dev's catalog as {lowercased model id: [(provider key, window)]},
cached; also indexed by the part after a "vendor/" prefix."""
with _models_dev_lock:
now = time.time()
if now < _models_dev["until"]:
return _models_dev["windows"] or {}
try:
with urlopen(URLRequest(MODELS_DEV_URL, headers={"Accept": "application/json",
"User-Agent": "Gamma/model-catalog"}),
timeout=MODELS_DEV_TIMEOUT) as resp:
data = json.loads(resp.read())
windows = {}
for pkey, provider in (data.items() if isinstance(data, dict) else []):
models = provider.get("models") if isinstance(provider, dict) else None
for mid, m in (models.items() if isinstance(models, dict) else []):
n = ((m.get("limit") or {}).get("context")) if isinstance(m, dict) else None
if not isinstance(n, int) or n <= 0:
continue
name = str(m.get("id") or mid).lower()
for alias in {name, name.rsplit("/", 1)[-1]}:
windows.setdefault(alias, []).append((str(pkey).lower(), n))
if not windows:
raise ValueError("empty catalog")
_models_dev.update(windows=windows, until=now + WINDOW_TTL)
except Exception as e:
log.warning(f"[ai] models.dev catalog lookup failed: {e}")
_models_dev["until"] = now + WINDOW_RETRY
return _models_dev["windows"] or {}


def _alnum(text: str) -> str:
return re.sub(r"[^a-z0-9]", "", text.lower())


def _catalog_window(model: str, conf: dict) -> int:
"""The model's window per models.dev. Several providers may list one
model, often with their own caps: the provider this entry talks to wins
(``Protocol.catalog_hints`` — by default the endpoint's host), else the
value most of them agree on."""
windows = _models_dev_windows()
name = model.lower()
found = windows.get(name) or windows.get(name.rsplit("/", 1)[-1]) or []
if not found:
return 0
names = set()
for hint in ai_protocols.of(conf).catalog_hints(conf):
# Whole host labels and runs of them: "api.moonshot.ai" names
# moonshot, moonshotai, … — never a substring like "a".
labels = [_alnum(label) for label in (hint or "").split(".")]
names |= {"".join(labels[i:j]) for i in range(len(labels)) for j in range(i + 1, len(labels) + 1)}
for pkey, n in found:
if _alnum(pkey) in names:
return n
counts = {}
for _, n in found:
counts[n] = counts.get(n, 0) + 1
return max(counts, key=lambda n: (counts[n], n))


def context_window(provider_id: str, conf: dict, model: str) -> tuple:
"""``(window, source)`` for one of the entry's models: source
``"provider"`` (its own listing) or ``"models.dev"``; ``(0, "")`` when
neither knows the model — callers show nothing rather than a guess."""
n = _listed_windows(provider_id, conf).get(model)
if n:
return n, "provider"
n = _catalog_window(model, conf)
return (n, "models.dev") if n else (0, "")
Loading
Loading