Merge C24 docs portal and client guide

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 23:47:35 +02:00
commit e81f1cba4c
17 files changed
+1328 -56

No files matched your search

+80 -1
View File
@@ -1,10 +1,89 @@
// Package llmgw holds what the binary carries besides code: its manual.
package llmgw
import "embed"
import (
"embed"
"io/fs"
"sort"
"strings"
)
// Docs is the documentation, read on any machine by `llmgw docs` and the
// console's manual without a checkout or the internet.
//
//go:embed README.md CHANGELOG.md docs/*.md
var Docs embed.FS
// Doc is one page of the manual.
type Doc struct {
// Key is what `llmgw docs KEY` takes, and the page's address under /docs/
// on the gateway.
Key string
// Path is the file in Docs.
Path string
// Title is the document's first heading.
Title string
}
// Source is the document's markdown.
func (d Doc) Source() string {
b, err := fs.ReadFile(Docs, d.Path)
if err != nil {
return "# " + d.Path + "\n\nNot in this build."
}
return string(b)
}
// docOrder is the documents in the order someone new reads them. Here rather
// than with either reader, because the console's manual and the gateway's
// docs portal both list them, and two lists drift.
var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding",
"clients", "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data",
"reference", "development"}
// Manual is every document, in reading order: those docOrder names, then any
// other under docs/ alphabetically, so a new one is never missing, then the
// README and the changes.
func Manual() []Doc {
var out []Doc
seen := map[string]bool{}
add := func(key, path string) {
if seen[key] {
return
}
b, err := fs.ReadFile(Docs, path)
if err != nil {
return
}
seen[key] = true
out = append(out, Doc{Key: key, Path: path, Title: titleOf(string(b), path)})
}
for _, k := range docOrder {
add(k, "docs/"+k+".md")
}
entries, _ := fs.ReadDir(Docs, "docs")
var rest []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".md") {
rest = append(rest, strings.TrimSuffix(e.Name(), ".md"))
}
}
sort.Strings(rest)
for _, k := range rest {
add(k, "docs/"+k+".md")
}
add("readme", "README.md")
add("changes", "CHANGELOG.md")
return out
}
// titleOf is a document's first top-level heading, or its path when it has
// none.
func titleOf(src, path string) string {
for _, l := range strings.Split(src, "\n") {
if strings.HasPrefix(l, "# ") {
return strings.TrimSpace(strings.TrimPrefix(l, "# "))
}
}
return path
}
+548
View File
@@ -0,0 +1,548 @@
# Using llmgw from code
Written for a developer whose program talks to a model through llmgw. There
is no llmgw SDK, and there will not be one: the gateway speaks OpenAI's API
and Anthropic's Messages API, so the official SDKs for both work as they are.
Only two things change — the base URL and the key. Everything llmgw adds on
top is an HTTP header, which every one of those SDKs lets you set.
The examples use `https://llm.example.com` for the gateway; use the address
your operator gave you, or the one on the gateway's own front page. Every
example reads the key from `LLMGW_API_KEY`. `<model-name>` is a model this
gateway serves: `GET /v1/models` lists the ones your key may use, and sending
the placeholder unchanged is answered with that list rather than with an
error.
## Getting a key
Keys are made on the gateway's **Account** page after signing in, or issued
by an operator. A key is shown once and never again — only its hash is kept —
so put it in a password manager straight away. `GET /me` with the key says
what it may do and what it has spent.
## The base URL and the key
| SDK | Base URL | Key |
|---|---|---|
| OpenAI (any language) | `https://llm.example.com/v1` | `Authorization: Bearer …`, as the SDK sends it |
| Anthropic (any language) | `https://llm.example.com` — the SDK adds `/v1/messages` | `x-api-key: …`, as the SDK sends it |
Both SDKs also read their usual environment variables, so a program that
already uses them needs no change at all:
```
export OPENAI_BASE_URL=https://llm.example.com/v1
export OPENAI_API_KEY=$LLMGW_API_KEY
export ANTHROPIC_BASE_URL=https://llm.example.com
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
```
## A first request
### curl
```
curl https://llm.example.com/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>",
"messages": [{"role": "user", "content": "Say hello in Turkish."}]}'
```
### Python
```
pip install openai
```
```python
import os
from openai import OpenAI
client = OpenAI(base_url="https://llm.example.com/v1",
api_key=os.environ["LLMGW_API_KEY"])
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Say hello in Turkish."}],
)
print(reply.choices[0].message.content)
```
### TypeScript
```
npm install openai
```
```typescript
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://llm.example.com/v1",
apiKey: process.env.LLMGW_API_KEY,
});
const reply = await client.chat.completions.create({
model: "<model-name>",
messages: [{ role: "user", content: "Say hello in Turkish." }],
});
console.log(reply.choices[0].message.content);
```
### Go
```
go get github.com/openai/openai-go/v3
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
func main() {
client := openai.NewClient(
option.WithBaseURL("https://llm.example.com/v1/"),
option.WithAPIKey(os.Getenv("LLMGW_API_KEY")),
)
reply, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{
Model: "<model-name>",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Say hello in Turkish.")},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.Choices[0].Message.Content)
}
```
The import path carries the SDK's major version; use the one your module
already has.
## Streaming
Set `stream` and read the chunks. llmgw may send a last chunk that carries
only the token counts and no choices, so check for a choice before reading
one — the OpenAI API does the same when usage is asked for.
```python
stream = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Count to ten."}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
print()
```
```typescript
const stream = await client.chat.completions.create({
model: "<model-name>",
messages: [{ role: "user", content: "Count to ten." }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
```
```go
stream := client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Model: "<model-name>",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Count to ten.")},
})
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
if err := stream.Err(); err != nil {
log.Fatal(err)
}
```
With curl, add `"stream": true` to the body and `-N` so curl prints the
events as they come.
## Tools
Function calling is OpenAI's: describe the tools, run the ones the model
asks for, send the results back. Whether a model calls tools well is the
model's business, not the gateway's; ask your operator which of the served
models are set up for it.
```python
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "The current weather in a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
messages = [{"role": "user", "content": "Is it raining in Ankara?"}]
reply = client.chat.completions.create(model="<model-name>", messages=messages, tools=tools)
msg = reply.choices[0].message
if msg.tool_calls:
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = {"city": args["city"], "raining": False} # your code here
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result)})
reply = client.chat.completions.create(model="<model-name>", messages=messages, tools=tools)
print(reply.choices[0].message.content)
```
## Embeddings
```python
result = client.embeddings.create(
model="<embedding-model>",
input=["llmgw routes requests", "to GPUs it can reach"],
)
vectors = [d.embedding for d in result.data]
```
```typescript
const result = await client.embeddings.create({
model: "<embedding-model>",
input: ["llmgw routes requests", "to GPUs it can reach"],
});
const vectors = result.data.map((d) => d.embedding);
```
An embedding model is a model of its own; a chat model does not answer
`/v1/embeddings`.
## The Responses API
`/v1/responses` is served too, for clients that speak nothing else (Codex
among them). llmgw translates it to chat completions, so it works with every
model the gateway serves; what has no equivalent there — OpenAI's hosted
tools, reasoning summaries — is ignored rather than refused.
```python
response = client.responses.create(
model="<model-name>",
instructions="Answer in one sentence.",
input="Why is the sky blue?",
)
print(response.output_text)
```
Streamed, the events are the Responses API's own:
```python
stream = client.responses.create(model="<model-name>", input="Count to ten.", stream=True)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
```
## Batches
For work that need not finish now: OpenAI's Files and Batch APIs. A batch runs
at the lowest priority, so it uses what interactive traffic leaves free, and
each line is counted against the key that submitted it.
```python
import json
with open("requests.jsonl", "w") as f:
for i, text in enumerate(["first document", "second document"]):
f.write(json.dumps({
"custom_id": f"doc-{i}",
"method": "POST",
"url": "/v1/chat/completions",
"body": {"model": "<model-name>",
"messages": [{"role": "user", "content": "Summarise: " + text}]},
}) + "\n")
batch_file = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
# Later:
batch = client.batches.retrieve(batch.id)
if batch.status == "completed":
for line in client.files.content(batch.output_file_id).text.splitlines():
result = json.loads(line)
print(result["custom_id"], result["response"]["body"]["choices"][0]["message"]["content"])
```
## The Anthropic SDK
`/v1/messages` is Anthropic's Messages API, translated to chat completions and
back, streamed or not, with tools and images. It serves the gateway's own
models — `model` is a model this gateway serves, not a Claude model.
```
pip install anthropic
```
```python
import os
import anthropic
client = anthropic.Anthropic(base_url="https://llm.example.com",
api_key=os.environ["LLMGW_API_KEY"])
message = client.messages.create(
model="<model-name>",
max_tokens=1024,
messages=[{"role": "user", "content": "Say hello in Turkish."}],
)
print(message.content[0].text)
with client.messages.stream(
model="<model-name>",
max_tokens=1024,
messages=[{"role": "user", "content": "Count to ten."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
```
```typescript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://llm.example.com",
apiKey: process.env.LLMGW_API_KEY,
});
const message = await client.messages.create({
model: "<model-name>",
max_tokens: 1024,
messages: [{ role: "user", content: "Say hello in Turkish." }],
});
for (const block of message.content) {
if (block.type === "text") console.log(block.text);
}
```
```go
client := anthropic.NewClient(
option.WithBaseURL("https://llm.example.com/"),
option.WithAPIKey(os.Getenv("LLMGW_API_KEY")),
)
message, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: "<model-name>",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello in Turkish.")),
},
})
if err != nil {
log.Fatal(err)
}
for _, block := range message.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
fmt.Println(text.Text)
}
}
```
(`github.com/anthropics/anthropic-sdk-go` and its `option` package.)
With curl:
```
curl https://llm.example.com/v1/messages \
-H "x-api-key: $LLMGW_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "max_tokens": 1024,
"messages": [{"role": "user", "content": "Say hello in Turkish."}]}'
```
## What llmgw adds
Each is a header, so a request stays exactly what the SDK would send anywhere
else. Set one for every request on the client, or for one request:
| | Every request | One request |
|---|---|---|
| Python (both SDKs) | `OpenAI(..., default_headers={...})` | `create(..., extra_headers={...})` |
| TypeScript (both SDKs) | `new OpenAI({ ..., defaultHeaders: {...} })` | `create({...}, { headers: {...} })` |
| Go (both SDKs) | `openai.NewClient(..., option.WithHeader(k, v))` | `New(ctx, params, option.WithHeader(k, v))` |
| curl | | `-H "k: v"` |
### Stored conversations: `x-llmgw-thread`
Send the same thread id with each request and only the new turn; llmgw puts
the stored conversation in front of it. The conversation continues on
whichever model serves the next turn, even a different one. It must be
turned on for your project by an operator (`llmgw keys threads`); a request
that names a thread on a gateway that keeps none is refused rather than
answered without its history.
```python
import uuid
thread = str(uuid.uuid4()) # anyone in your project who knows it reads it
for turn in ["My name is Deniz.", "What is my name?"]:
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": turn}],
extra_headers={"x-llmgw-thread": thread},
)
print(reply.choices[0].message.content)
```
A thread belongs to the project, not to the key, so use an id nobody can
guess: at most 128 letters, digits and `- _ . :`. When the conversation no
longer fits the model, the oldest turns are left out and the reply carries
`x-llmgw-thread-trimmed` with how many. `DELETE /v1/threads/{id}` forgets one:
```
curl -X DELETE https://llm.example.com/v1/threads/$THREAD \
-H "Authorization: Bearer $LLMGW_API_KEY"
```
### Sessions: `x-session-id`
Requests with the same session id go to the same replica while it is
healthy, so a conversation that resends its history finds it already in that
replica's cache and answers sooner. Without the header, the body's `user`
field is used. Nothing is stored; it is only a routing hint.
```python
client = OpenAI(base_url="https://llm.example.com/v1", api_key=os.environ["LLMGW_API_KEY"],
default_headers={"x-session-id": "chat-7f3a"})
```
### Tags and metadata
Requests can be labelled so the usage they record can be told apart — by
feature, by customer, by job. Send OpenAI's own `metadata`, string keys to
string values, or the same as the `x-llmgw-tags` header for a client that
cannot set `metadata`; the `user` field is recorded too. Which tags also
become metric labels is the operator's choice (`router.tag_labels`).
```python
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Classify this ticket."}],
metadata={"feature": "triage", "customer": "acme"},
user="user-1234",
)
```
```
curl https://llm.example.com/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "x-llmgw-tags: feature=triage,customer=acme" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "messages": [{"role": "user", "content": "Classify this ticket."}]}'
```
### Canaries: `x-llmgw-variant`
While an operator tries a model's newer weights on a share of its traffic, a
session stays on one side. `x-llmgw-variant: canary` sends a request to the
canary deliberately — to try it before it is promoted, or to compare the two.
```python
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "hello"}],
extra_headers={"x-llmgw-variant": "canary"},
)
```
### Memory notes: `/v1/memory`
Notes an agent keeps for itself across conversations: a key, some text, and
optional tags, private to your project and to the agent named in
`x-llmgw-agent` (`default` when it is not sent). An operator turns them on for
a project (`llmgw keys memory`). They are plain REST, so any HTTP client
will do:
```
H="Authorization: Bearer $LLMGW_API_KEY"
curl -X PUT https://llm.example.com/v1/memory/favourite-colour -H "$H" \
-H "x-llmgw-agent: helper" -H "Content-Type: application/json" \
-d '{"text": "The user likes dark green.", "tags": ["preference"]}'
curl "https://llm.example.com/v1/memory?q=green" -H "$H" -H "x-llmgw-agent: helper"
curl "https://llm.example.com/v1/memory?tag=preference" -H "$H" -H "x-llmgw-agent: helper"
curl https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper"
curl -X DELETE https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper"
```
A key is up to 200 bytes, a note up to 16 KiB, with at most 16 tags of
lowercase letters, digits, `-` and `_`, and 1000 notes per agent.
### MCP: `/mcp`
The same notes as tools an agent can be given — `remember`, `recall` and
`forget` — from an MCP server at `https://llm.example.com/mcp` (the HTTP
transport, answered as plain JSON). Add it to your agent's MCP servers with
the key as a bearer token and, if you want notes kept apart, `x-llmgw-agent`.
llmgw does not put these tools into requests by itself: an agent that is not
given the server never sees its notes.
```
curl https://llm.example.com/mcp \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```
### Headers llmgw sends back
| Header | Says |
|---|---|
| `x-request-id` | The request's id in the gateway's logs and usage records — quote it when asking an operator about a request. |
| `x-llmgw-served-model` | The model that answered, when it was not the one asked for: a spent budget can move a request to a cheaper model. |
| `x-llmgw-queue-ms` | How long the request waited for room before it was sent. |
| `x-llmgw-thread-trimmed` | How many stored turns were left out to fit the model. |
## When something is refused
Errors come back in the shape of the API that was called — OpenAI's
`{"error": {"message": …, "type": …}}` or Anthropic's — so each SDK raises
its usual exception with the gateway's message in it. The ones worth knowing:
| Status | Usually means |
|---|---|
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
| 403 | The key may not use that model. |
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. |
## Priority
With keys on, the key decides the request's priority band and routing
profile, set by an operator; the `x-llmgw-band` and `x-llmgw-profile` headers
are ignored, so a client cannot promote itself. On a gateway without keys
they are read from the request.
+22
View File
@@ -309,6 +309,28 @@ shown as text. Other schemes are never linked — configuration is not always
written by whoever runs the gateway, and a `javascript:` href on a page served to
every visitor is a short path to a stored XSS.
## The docs portal
The gateway also serves its manual at `/docs`: these pages, rendered, with
[clients.md](clients.md) — how to use the gateway from the official OpenAI
and Anthropic SDKs — first on its index. The landing page and the account
page link to it, so a user's questions about streaming, tools or the Anthropic
SDK have an answer that is not you.
It is public, like the landing page. The client guide is for the person who
has no account yet, and the operator documents describe llmgw in general —
no address, model, node or key of this installation — which is what the
project's repository publishes anyway. A site that would rather not say even
that turns sign-in on:
```yaml
landing:
docs_sign_in: true
```
A visitor is then sent to sign in first. On a gateway with API keys and no
accounts, the portal asks for a key instead, like the API.
## What the user sees
They open the link, press the button, and get the key once. The page shows them
+12
View File
@@ -89,6 +89,7 @@ rather than repeated.
| `latency_budget` | `time.Duration` | [JudgePolicy](#judgepolicy) |
| `contact` | `string` | [Landing](#landing) |
| `notice` | `string` | [Landing](#landing) |
| `docs_sign_in` | `bool` | [Landing](#landing) |
| `bin` | `string` | [LlamaCppConfig](#llamacppconfig) |
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
@@ -846,6 +847,17 @@ channel name. Rendered as a link when it is one, as text otherwise.
Notice is an optional line of your own, for whatever the reader needs to
know that this page cannot guess.
### `docs_sign_in`
`bool`
DocsSignIn serves the docs portal at /docs only to someone signed in.
Off, the portal is public like this page: its client guide is for the
person who has no account yet, and the operator documents describe
llmgw in general — no address, model or key of this installation —
which the project's repository publishes anyway. On, a visitor is sent
to sign in, and without accounts the portal needs an API key.
## LlamaCppConfig
### `bin`
+6 -50
View File
@@ -2,7 +2,6 @@ package cli
import (
"fmt"
"io/fs"
"sort"
"strings"
@@ -24,60 +23,17 @@ type docTopic struct {
src func() string
}
// docOrder is the documents in the order someone new reads them.
var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding",
"guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data", "reference", "development"}
// docTopics is the manual: the documents in llmgw.Manual's order, which the
// gateway's docs portal shares, with a page for the commands after the
// configuration reference — the one page only the command tree can write.
func docTopics() []docTopic {
read := func(path string) func() string {
return func() string {
b, err := fs.ReadFile(llmgw.Docs, path)
if err != nil {
return "# " + path + "\n\nNot in this build."
}
return string(b)
}
}
titleOf := func(path string) string {
b, _ := fs.ReadFile(llmgw.Docs, path)
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "# ") {
return strings.TrimPrefix(l, "# ")
}
}
return path
}
var out []docTopic
seen := map[string]bool{}
add := func(key, path string) {
if seen[key] {
return
}
if _, err := fs.Stat(llmgw.Docs, path); err != nil {
return
}
seen[key] = true
out = append(out, docTopic{key: key, title: titleOf(path), src: read(path)})
}
for _, k := range docOrder {
add(k, "docs/"+k+".md")
if k == "reference" {
for _, d := range llmgw.Manual() {
out = append(out, docTopic{key: d.Key, title: d.Title, src: d.Source})
if d.Key == "reference" {
out = append(out, docTopic{key: "commands", title: "Commands", src: commandsManual})
seen["commands"] = true
}
}
// Any document not placed above, so a new one is never missing.
entries, _ := fs.ReadDir(llmgw.Docs, "docs")
var rest []string
for _, e := range entries {
rest = append(rest, strings.TrimSuffix(e.Name(), ".md"))
}
sort.Strings(rest)
for _, k := range rest {
add(k, "docs/"+k+".md")
}
add("readme", "README.md")
add("changes", "CHANGELOG.md")
return out
}
+7
View File
@@ -261,6 +261,13 @@ type Landing struct {
// Notice is an optional line of your own, for whatever the reader needs to
// know that this page cannot guess.
Notice string `koanf:"notice"`
// DocsSignIn serves the docs portal at /docs only to someone signed in.
// Off, the portal is public like this page: its client guide is for the
// person who has no account yet, and the operator documents describe
// llmgw in general — no address, model or key of this installation —
// which the project's repository publishes anyway. On, a visitor is sent
// to sign in, and without accounts the portal needs an API key.
DocsSignIn bool `koanf:"docs_sign_in"`
}
// Threads configures conversation and memory storage for a gateway without
+1
View File
@@ -103,6 +103,7 @@ var Reference = []Block{
{Package: "config", Name: "Landing", Title: "Landing", Doc: "", Fields: []Field{
{Key: "contact", Type: "string", Doc: "Contact is where someone without a key should go — an address, a URL, a\nchannel name. Rendered as a link when it is one, as text otherwise.", Nested: ""},
{Key: "notice", Type: "string", Doc: "Notice is an optional line of your own, for whatever the reader needs to\nknow that this page cannot guess.", Nested: ""},
{Key: "docs_sign_in", Type: "bool", Doc: "DocsSignIn serves the docs portal at /docs only to someone signed in.\nOff, the portal is public like this page: its client guide is for the\nperson who has no account yet, and the operator documents describe\nllmgw in general — no address, model or key of this installation —\nwhich the project's repository publishes anyway. On, a visitor is sent\nto sign in, and without accounts the portal needs an API key.", Nested: ""},
}},
{Package: "engines", Name: "LlamaCppConfig", Title: "LlamaCppConfig", Doc: "", Fields: []Field{
{Key: "bin", Type: "string", Doc: "Bin is the llama-server binary. It is the server, not the CLI: the\ngateway talks OpenAI over HTTP and needs the one that serves it.\nEmpty means the agent installs llama.cpp itself, at the version the\ncontrol plane asks for. Setting it opts out of that.", Nested: ""},
+48
View File
@@ -0,0 +1,48 @@
package router
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
)
func docsRouter(t *testing.T, signIn bool) http.Handler {
t.Helper()
cfg := baseConfig(config.Endpoint{ID: "a", URL: "http://127.0.0.1:1", Models: []string{"m"}})
cfg.Auth.Enabled = true
cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db")
cfg.Landing.DocsSignIn = signIn
return newTestRouter(t, cfg).Handler()
}
// The docs are public by default on a gateway with keys: the client guide is
// for the person who has none yet, so asking for one first defeats it.
func TestTheDocsAreServedWithoutAKey(t *testing.T) {
h := docsRouter(t, false)
for _, path := range []string{"/docs", "/docs/clients"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "llmgw docs") {
t.Errorf("%s: %d %.200s", path, w.Code, w.Body.String())
}
}
}
func TestDocsSignInSendsAVisitorToSignIn(t *testing.T) {
h := docsRouter(t, true)
for _, path := range []string{"/docs", "/docs/install"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
// The session guard, not the key check: a browser has no key.
if strings.Contains(w.Body.String(), "api key") {
t.Errorf("%s asked a browser for an API key", path)
}
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/login" {
t.Errorf("%s: %d to %q, want a redirect to /login", path, w.Code, w.Header().Get("Location"))
}
}
}
+20 -4
View File
@@ -231,6 +231,11 @@ func (r *Router) Handler() http.Handler {
pages.RegisterAdminGuard(mux, r.store, r.guardLibrary)
}
pages.Register(mux)
// The docs portal, with the menu of whoever is signed in, and only to
// them when the site asked for that.
docs := pages.DocsPage(web.Docs(version.Version, true), r.landing.DocsSignIn)
mux.HandleFunc("GET /docs", docs)
mux.HandleFunc("GET /docs/{doc}", docs)
r.registerChat(mux, pages)
// An organization's own page, for the people who manage it.
pages.RegisterOrgPages(mux, r.store, r.acct)
@@ -282,18 +287,29 @@ func (r *Router) Handler() http.Handler {
Catalog: r.catalogAdmin, Fleet: r.clusterView,
})
}
} else {
// No accounts, so nobody to show a menu to or to send to sign in.
docs := web.Docs(version.Version, false)
mux.HandleFunc("GET /docs", docs)
mux.HandleFunc("GET /docs/{doc}", docs)
}
if r.auth == nil {
return mux
}
exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/claim/",
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
"/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/"}
// The docs are public unless the site said otherwise, and then guarded by
// the session check in front of them — or, on a gateway with keys and no
// accounts to sign in to, by the key like any other path.
if !r.landing.DocsSignIn || r.accounts != nil {
exempt = append(exempt, "/docs", "/docs/")
}
// Wrapping the whole mux rather than checking inside each handler: adding a
// route must not be able to add an unauthenticated one by omission. Health
// stays open because a load balancer has no key.
return auth.Middleware(r.auth, r.log,
"/health", "/ready", "/leader", "/", "/about", "/_static/", "/claim/",
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
"/admin", "/admin/", "/chat", "/chat/", "/org", "/org/", "/setup/")(mux)
return auth.Middleware(r.auth, r.log, exempt...)(mux)
}
// parsedRequest is what the router needs from a request. Everything else in the
+258
View File
@@ -0,0 +1,258 @@
package web
import (
"bytes"
"html/template"
"net/http"
"net/url"
"path"
"strings"
"sync"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
)
// The docs portal: the manual the binary carries, the pages `llmgw docs` and
// the console show, rendered as a website at /docs on the client port.
//
// Public by default, like the landing page and for the same reason: the
// person who needs the client guide is the one who does not have an account
// yet. The operator documents describe how llmgw is deployed in general, not
// this deployment — no address, no model, no key — so publishing them tells a
// stranger what the project's own repository already does. A site that would
// rather not say even that sets landing.docs_sign_in.
//
// Only the embedded documents are rendered, never anything a visitor sent, and
// in goldmark's safe mode all the same: raw HTML in a document is dropped
// rather than trusted, so a page here can never carry a script for the
// Content-Security-Policy to have to refuse.
// docLink is one entry of the portal's sidebar.
type docLink struct{ Key, Title string }
// docSection is a second-level heading, offered under the open document.
type docSection struct{ ID, Title string }
// renderedDoc is a document turned into HTML once, when first asked for.
type renderedDoc struct {
docLink
Sections []docSection
Body template.HTML
}
// docsData is what the docs template is rendered with.
type docsData struct {
Version string
// Doc is the open document; nil on the index.
Doc *renderedDoc
Docs []docLink
// Clients is the client developer's guide, which the index leads with.
Clients *docLink
// Nav is the menu of whoever is signed in, and SignedIn whether anyone is.
Nav Nav
SignedIn bool
// SignIn offers a sign-in link to someone who is not.
SignIn bool
}
// manualOnce renders every document the first time the portal is opened:
// the configuration reference alone is two thousand lines, and rendering it
// again on every request would be work for nothing — the documents change
// only when the binary does.
var (
manualOnce sync.Once
manualDocs []renderedDoc
)
func manual() []renderedDoc {
manualOnce.Do(func() {
docs := llmgw.Manual()
paths := map[string]string{}
for _, d := range docs {
paths[d.Path] = d.Key
}
for _, d := range docs {
body, sections := renderDoc(d.Source(), d.Path, paths)
manualDocs = append(manualDocs, renderedDoc{
docLink: docLink{Key: d.Key, Title: d.Title}, Sections: sections, Body: body,
})
}
})
return manualDocs
}
// renderDoc turns one embedded document into HTML. from is its path in the
// embed, which its relative links are resolved against; paths are the paths
// of every document the portal has, by key.
func renderDoc(src, from string, paths map[string]string) (template.HTML, []docSection) {
t := &docTransform{from: from, paths: paths}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
// The same ids GitHub gives headings, which is what the documents'
// own #links were written against.
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.Prioritized(t, 100))),
)
var out bytes.Buffer
if err := md.Convert([]byte(src), &out); err != nil {
return template.HTML("<p>This document could not be rendered.</p>"), nil
}
// goldmark in its default, safe mode over documents compiled into the
// binary, with links rewritten below: not something to escape again.
return template.HTML(out.String()), t.sections //nolint:gosec
}
// docTransform rewrites a document's links for the portal, gives every
// heading a link to itself, and collects the sections for the sidebar.
type docTransform struct {
from string
paths map[string]string
sections []docSection
}
func (t *docTransform) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) {
src := reader.Source()
var headings []*ast.Heading
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n := n.(type) {
case *ast.Link:
n.Destination = []byte(t.rewrite(string(n.Destination)))
case *ast.Heading:
headings = append(headings, n)
}
return ast.WalkContinue, nil
})
// After the walk, so the links added here are not themselves rewritten.
for _, h := range headings {
id, ok := h.AttributeString("id")
if !ok {
continue
}
idStr := string(id.([]byte))
if h.Level == 2 {
t.sections = append(t.sections, docSection{ID: idStr, Title: plainText(h, src)})
}
a := ast.NewLink()
a.Destination = []byte("#" + idStr)
a.Title = []byte("Link to this section")
a.SetAttributeString("class", []byte("anchor"))
a.AppendChild(a, ast.NewString([]byte("#")))
h.AppendChild(h, a)
}
}
// rewrite points a link at where its target is served from.
//
// A link to another document becomes its page under /docs, keeping the
// section it names. A link to anything else in the repository — deploy/,
// the backlog, the design plan — is not in the binary, so it goes to the
// repository itself rather than to a 404 here. Absolute links and links
// within the page are left alone.
func (t *docTransform) rewrite(dest string) string {
u, err := url.Parse(dest)
if err != nil || u.Scheme != "" || u.Host != "" || u.Path == "" || strings.HasPrefix(u.Path, "/") {
return dest
}
target := path.Clean(path.Join(path.Dir(t.from), u.Path))
frag := ""
if u.Fragment != "" {
frag = "#" + u.EscapedFragment()
}
if key, ok := t.paths[target]; ok {
return "/docs/" + key + frag
}
if strings.HasPrefix(target, "../") {
return dest // outside the repository: nothing better to point it at
}
if strings.HasSuffix(u.Path, "/") {
target += "/"
}
return repoURL + "/src/branch/main/" + target + frag
}
// plainText is a heading's words without their markup: "`name`" is "name".
func plainText(n ast.Node, src []byte) string {
var b strings.Builder
_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch c := c.(type) {
case *ast.Text:
b.Write(c.Segment.Value(src))
if c.SoftLineBreak() {
b.WriteByte(' ')
}
case *ast.String:
b.Write(c.Value)
}
return ast.WalkContinue, nil
})
return b.String()
}
// Docs serves the portal: the index at /docs and a document at
// /docs/{doc}. signIn is whether this gateway has accounts to sign in to,
// which decides whether the page offers it.
func Docs(version string, signIn bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
docs := manual()
d := docsData{Version: version, SignIn: signIn}
if n, ok := r.Context().Value(navKey{}).(Nav); ok {
n.Current = "docs"
d.Nav, d.SignedIn = n, true
}
for i := range docs {
d.Docs = append(d.Docs, docs[i].docLink)
if docs[i].Key == "clients" {
d.Clients = &docs[i].docLink
}
}
key := strings.TrimSuffix(strings.ToLower(r.PathValue("doc")), ".md")
if key == "" {
render(w, "docs", d)
return
}
for i := range docs {
if docs[i].Key == key {
d.Doc = &docs[i]
render(w, "docs", d)
return
}
}
http.NotFound(w, r)
}
}
// DocsPage serves the docs portal with the menu of whoever is signed in and,
// when required is set, to nobody else: someone not signed in is sent to sign
// in, as every other page that needs a session does.
func (p *AccountPages) DocsPage(docs http.HandlerFunc, required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, user, ok := p.session(r)
if !ok {
if required {
noStore(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
docs(w, r)
return
}
// The page carries this person's menu, so no cache may keep it for
// the next.
noStore(w)
docs(w, p.withNav(r, user))
}
}
+174
View File
@@ -0,0 +1,174 @@
package web
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// docsMux mounts the portal the way the router does.
func docsMux(h http.HandlerFunc) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /docs", h)
mux.HandleFunc("GET /docs/{doc}", h)
return mux
}
func TestTheDocsPortalRendersADocumentWithItsHeadingsAndCode(t *testing.T) {
code, body := get(t, docsMux(Docs("v1", false)), "/docs/clients", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
for _, want := range []string{
`<h1 id="using-llmgw-from-code">Using llmgw from code`,
// A heading links to itself, for someone sending a section to someone.
`<h2 id="streaming">Streaming<a href="#streaming"`,
// Fenced code keeps its language, and its text is escaped, not markup.
`<pre><code class="language-python">`,
`client.chat.completions.create(`,
// Tables are GitHub's.
`<table>`,
// The sidebar offers every document and marks the open one.
`<a href="/docs/clients" aria-current="page">Using llmgw from code</a>`,
`<a href="/docs/reference">`,
// And this document's sections beneath it.
`<a href="#the-anthropic-sdk">The Anthropic SDK</a>`,
} {
if !strings.Contains(body, want) {
t.Errorf("the page lacks %q", want)
}
}
}
func TestEveryDocumentInTheManualIsAPage(t *testing.T) {
h := docsMux(Docs("v1", false))
for _, d := range llmgw.Manual() {
if code, _ := get(t, h, "/docs/"+d.Key, nil); code != http.StatusOK {
t.Errorf("/docs/%s: %d", d.Key, code)
}
}
}
func TestTheIndexLeadsWithTheClientGuide(t *testing.T) {
code, body := get(t, docsMux(Docs("v1", true)), "/docs", nil)
if code != http.StatusOK {
t.Fatalf("status %d", code)
}
lead := strings.Index(body, `<a href="/docs/clients">Using llmgw from code</a> shows`)
every := strings.Index(body, "Every document")
if lead < 0 || every < 0 || lead > every {
t.Errorf("the client guide is not offered first:\n%s", body)
}
if !strings.Contains(body, `<a href="/login">Sign in</a>`) {
t.Error("a gateway with accounts does not offer to sign in")
}
}
func TestLinksBetweenDocumentsStayInThePortal(t *testing.T) {
paths := map[string]string{"docs/install.md": "install", "docs/guardrails.md": "guardrails",
"README.md": "readme", "CHANGELOG.md": "changes"}
src := "See [install](install.md), [a section](install.md#2-the-control-plane), " +
"[here](#local), [the readme](../README.md), [proxies](../deploy/proxy/), " +
"[the plan](llmgw-plan.md) and [elsewhere](https://example.com/x.md).\n"
body, _ := renderDoc(src, "docs/guardrails.md", paths)
for _, want := range []string{
`href="/docs/install"`,
`href="/docs/install#2-the-control-plane"`,
`href="#local"`,
`href="/docs/readme"`,
// Not in the binary: the repository has it.
`href="` + repoURL + `/src/branch/main/deploy/proxy/"`,
`href="` + repoURL + `/src/branch/main/docs/llmgw-plan.md"`,
`href="https://example.com/x.md"`,
} {
if !strings.Contains(string(body), want) {
t.Errorf("rendered without %s:\n%s", want, body)
}
}
// The README links into docs/ from the repository's root.
body, _ = renderDoc("[guardrails](docs/guardrails.md)\n", "README.md", paths)
if !strings.Contains(string(body), `href="/docs/guardrails"`) {
t.Errorf("a link from the README: %s", body)
}
}
func TestNoEmbeddedDocumentLinksToAPageThePortalLacks(t *testing.T) {
// Every link a document makes into /docs/ must land on a page, or the
// portal ships its own 404s.
keys := map[string]bool{}
for _, d := range manual() {
keys[d.Key] = true
}
for _, d := range manual() {
for _, part := range strings.Split(string(d.Body), `href="/docs/`)[1:] {
key := part[:strings.IndexAny(part, `"#`)]
if !keys[key] {
t.Errorf("%s links to /docs/%s, which is not a page", d.Key, key)
}
}
}
}
func TestAnUnknownDocumentIsNotFound(t *testing.T) {
h := docsMux(Docs("v1", false))
for _, path := range []string{"/docs/no-such-doc", "/docs/..%2fgo.mod", "/docs/BACKLOG"} {
if code, _ := get(t, h, path, nil); code != http.StatusNotFound {
t.Errorf("%s: %d, want 404", path, code)
}
}
// The file name works as well as the key: it is what the documents say.
if code, _ := get(t, h, "/docs/install.md", nil); code != http.StatusOK {
t.Errorf("/docs/install.md: %d", code)
}
}
func TestTheDocsCarryThePolicyAndNoScript(t *testing.T) {
for _, path := range []string{"/docs", "/docs/clients", "/docs/reference", "/docs/readme"} {
rec := httptest.NewRecorder()
docsMux(Docs("v1", false)).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'self';") || strings.Contains(csp, "sha256-") {
t.Errorf("%s: policy %q, want this server's scripts and no inline one", path, csp)
}
if strings.Contains(strings.ToLower(rec.Body.String()), "<script") {
t.Errorf("%s emits a script", path)
}
}
}
func TestTheDocsCanRequireSigningIn(t *testing.T) {
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com"}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
required := docsMux(p.DocsPage(Docs("v0", true), true))
rec := httptest.NewRecorder()
required.ServeHTTP(rec, httptest.NewRequest("GET", "/docs/install", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
t.Errorf("without a session: %d to %q, want a redirect to /login", rec.Code, rec.Header().Get("Location"))
}
signedIn := mirrorGet(t, required, "/docs/install")
if signedIn.Code != http.StatusOK {
t.Fatalf("signed in: %d", signedIn.Code)
}
// Signed in, the page draws the portal's menu with Docs marked.
if menu := menuRe.FindString(signedIn.Body.String()); !strings.Contains(menu, `aria-current="page">Docs<`) ||
!strings.Contains(menu, `"/logout"`) {
t.Errorf("the signed-in menu:\n%s", menu)
}
if !strings.Contains(signedIn.Header().Get("Cache-Control"), "no-store") {
t.Error("a page with someone's menu on it may be cached")
}
public := docsMux(p.DocsPage(Docs("v0", true), false))
rec = httptest.NewRecorder()
public.ServeHTTP(rec, httptest.NewRequest("GET", "/docs/install", nil))
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"/logout"`) {
t.Errorf("public, without a session: %d", rec.Code)
}
}
+50
View File
@@ -0,0 +1,50 @@
/* The docs portal, over base.css: a sidebar of the documents beside the one
being read. Its own file so the pages that have no use for it do not carry
it. */
.docs-head { margin-bottom: 1.5rem; }
.docs-brand { margin: 0; font-weight: 600; font-size: 1.05rem; }
.docs-brand a { color: var(--fg); text-decoration: none; }
.docs { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: 2.5rem; align-items: start; }
.docs-side { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto;
font-size: .88rem; padding-right: .25rem; }
.docs-side ul { list-style: none; padding: 0; margin: 0; }
.docs-side li { margin: 0; }
.docs-side a { display: block; padding: .22rem .55rem; border-radius: 5px;
color: var(--muted); text-decoration: none; }
.docs-side a:hover { color: var(--fg); }
.docs-side a[aria-current="page"] { color: var(--fg); background: var(--card);
font-weight: 600; border: 1px solid var(--line); }
.docs-side .sections { margin: .25rem 0 .6rem .7rem; padding-left: .3rem;
border-left: 1px solid var(--line); font-size: .82rem; }
.docs-side .sections a { padding: .12rem .45rem; }
.doc { line-height: 1.65; min-width: 0; }
.doc h1 { font-size: 1.7rem; margin: 0 0 1rem; }
.doc h2 { font-size: 1.2rem; margin: 2.6rem 0 .8rem; padding-top: 1rem; border-top: 1px solid var(--line); }
.doc h3 { font-size: 1rem; margin: 1.8rem 0 .5rem; }
.doc h4 { font-size: .93rem; margin: 1.4rem 0 .4rem; }
.doc :is(h1, h2, h3, h4) { scroll-margin-top: 1rem; }
.doc .anchor { margin-left: .4rem; color: var(--muted); text-decoration: none;
font-weight: 400; opacity: 0; }
.doc :is(h1, h2, h3, h4):hover .anchor, .doc .anchor:focus-visible { opacity: 1; }
.doc pre { line-height: 1.45; }
.doc :not(pre) > code { background: var(--card); border: 1px solid var(--line);
border-radius: 4px; padding: .05rem .3rem; }
.doc table { display: block; overflow-x: auto; margin: .8rem 0 1.4rem; width: auto; max-width: 100%; }
.doc th { text-transform: none; letter-spacing: 0; font-size: .82rem; }
.doc blockquote { margin: 1rem 0; padding: .2rem 1rem; border-left: 3px solid var(--accent);
background: var(--card); border-radius: 0 6px 6px 0; }
.doc img { max-width: 100%; }
.doc li > p { margin: .2rem 0; }
.docs-lead { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
padding: .2rem 1.1rem .9rem; margin: 1.5rem 0; }
.docs-lead h2 { border-top: none; padding-top: 0; margin-top: 1rem; }
.docs-index { columns: 2 16rem; }
@media (max-width: 760px) {
.docs { grid-template-columns: 1fr; gap: 1rem; }
.docs-side { position: static; max-height: none; border-bottom: 1px solid var(--line);
padding-bottom: 1rem; }
}
+2
View File
@@ -104,6 +104,8 @@ an operator to reset it.</p>
<h2>Connect your editor</h2>
<p class="note">A key of its own for the tool, and its configuration with the key, this gateway and a model
already in it. Revoke the key below to disconnect it.</p>
<p class="note">From your own code: the official OpenAI and Anthropic SDKs work as they are —
the <a href="/docs/clients">client guide</a> shows how, in Python, TypeScript, Go and curl.</p>
<form method="post" action="/profile/connect#connected" class="connect-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="field-row">
+67
View File
@@ -0,0 +1,67 @@
{{/* The docs portal: the manual the binary carries, as a website. No script
at all — a sidebar of links and a rendered document need none, and the
policy then has nothing to allow. */}}
{{define "docs"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{if .Doc}}{{.Doc.Title}} · {{end}}llmgw docs</title>
<meta name="description" content="How to use and run llmgw: the client guide, installation, operation and the configuration reference.">
<link rel="stylesheet" href="{{stylesheet}}">
<link rel="stylesheet" href="{{asset "docs.css"}}">
</head><body><div class="wrap">
<header class="docs-head">
<p class="docs-brand"><a href="/docs">llmgw docs</a></p>
{{if .SignedIn}}{{template "nav" .Nav}}{{else}}
<nav class="menu" aria-label="Main">
<a href="/">This gateway</a>
<a href="/docs"{{if not .Doc}} aria-current="page"{{end}}>Docs</a>
<a href="/about">About llmgw</a>
{{if .SignIn}}<a href="/login">Sign in</a>{{end}}
</nav>{{end}}
</header>
<div class="docs">
<aside class="docs-side" aria-label="Documents">
<ul>
{{range .Docs}}
<li><a href="/docs/{{.Key}}"{{if and $.Doc (eq $.Doc.Key .Key)}} aria-current="page"{{end}}>{{.Title}}</a>
{{- if and $.Doc (eq $.Doc.Key .Key) $.Doc.Sections}}
<ul class="sections">{{range $.Doc.Sections}}
<li><a href="#{{.ID}}">{{.Title}}</a></li>{{end}}
</ul>
{{- end}}</li>
{{end}}
</ul>
</aside>
<main class="doc">
{{if .Doc}}
{{.Doc.Body}}
{{else}}
<h1>llmgw docs</h1>
<p class="sub">The manual this gateway's software carries — the same pages
<code>llmgw docs</code> prints and the console's Manual shows.</p>
{{with .Clients}}
<section class="docs-lead">
<h2>Using the gateway from code</h2>
<p>The official OpenAI and Anthropic SDKs work as they are: only the base URL
and the key change. <a href="/docs/{{.Key}}">{{.Title}}</a> shows Python,
TypeScript, Go and curl for chat, streaming, tools, embeddings, the Responses
API, batches, and what llmgw adds — stored conversations, tags, sessions and
memory notes.</p>
</section>
{{end}}
<h2>Every document</h2>
<ul class="docs-index">
{{range .Docs}} <li><a href="/docs/{{.Key}}">{{.Title}}</a></li>
{{end}}</ul>
<p class="note">The documents describe llmgw in general, not this installation:
they name no address, model or key of it.</p>
{{end}}
</main>
</div>
<footer><span><a href="/about">llmgw {{.Version}}</a></span><span><a href="/docs">docs</a></span></footer>
</div></body></html>{{end}}
+2 -1
View File
@@ -177,7 +177,8 @@ print(reply.choices[0].message.content)</code></pre>
</div>
</div>
<p class="note">Anything that speaks the chat-completions API works, whether or not it
is listed here — there is nothing llmgw-specific to install.</p>
is listed here — there is nothing llmgw-specific to install. The
<a href="/docs/clients">client guide</a> has more: streaming, tools, embeddings and the Anthropic SDK.</p>
<h2>Once you have a key</h2>
<ul class="endpoints">
+1
View File
@@ -26,6 +26,7 @@
{{- end}}
<a href="/chat"{{if eq .Current "chat"}} aria-current="page"{{end}}>Chat</a>
<a href="/profile"{{if eq .Current "keys"}} aria-current="page"{{end}}>Account</a>
<a href="/docs"{{if eq .Current "docs"}} aria-current="page"{{end}}>Docs</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="link">Sign out</button>
+30
View File
@@ -0,0 +1,30 @@
# C24: the SDK/docs portal
## CHANGELOG
### Documentation
- **A docs portal at /docs.** The manual the binary carries — the pages
`llmgw docs` prints and the console's Manual shows, the configuration
reference among them — rendered on the client port in the portal's own
look: a sidebar of the documents in reading order, the open one's sections
under it, a link on every heading, and links between documents kept inside
the portal (links to files that are not in the binary, such as `deploy/`,
go to the repository). No script on the page, so the Content-Security-Policy
has nothing to allow. Public by default, like the landing page; set
`landing.docs_sign_in: true` to send visitors to sign in first. Signed in,
the page carries the usual menu, which now has a **Docs** link.
- **A client developer's guide, `docs/clients.md`.** How to use llmgw from
the official OpenAI and Anthropic SDKs — there is no llmgw SDK, and none is
needed — in Python, TypeScript, Go and curl: the base URL and key,
streaming, tools, embeddings, the Responses API, batches, the Anthropic SDK
against `/v1/messages`, and what llmgw adds as headers (`x-llmgw-thread`,
`x-session-id`, metadata and `x-llmgw-tags`, `x-llmgw-variant`,
`/v1/memory`, `/mcp`), with the statuses a client meets. The portal's index
leads with it, and the landing page and the account page link to it.
- The console's Manual and the portal read one list of documents in one
order (`llmgw.Manual`), so the two cannot drift.
## BACKLOG
- [x] 24 (SDK/docs portal): built — /docs renders the embedded manual (goldmark, GFM, heading anchors, links rewritten, no script; public, `landing.docs_sign_in` to require a session), and docs/clients.md shows the official OpenAI/Anthropic SDKs against llmgw. Left: the console's generated "Commands" page is not in the portal (it is built from the cobra tree, which internal/web cannot see); no search across documents; the clients.md sections on /v1/messages, batches, tags and x-llmgw-variant were written against the features-c2 commit messages and must be checked against that code when merging (exact `x-llmgw-tags` format, `canary` as the variant value, Anthropic-shaped errors on /v1/messages).