CI / test (push) Successful in 1m30s
It is the only page a stranger sees, and until they have a key it is the whole product: a stack of headings and shell snippets said "internal tool you probably should not be here". Now it opens with what this is and a diagram of why it exists — one address, many GPUs, fabrics that cannot see each other — then the base URL as a headline rather than a line of prose with a colon in it, the route to a key as three cards, and the client examples as tabs instead of a pile. The tabs are radio buttons and a stylesheet. No JavaScript decides what is on the page, so someone who arrives with scripting off still gets every example; the only script is the copy buttons, which exist only where the clipboard API does and so cannot leave a dead control behind. The page says exactly what it said before, including that it lists no models and why. The diagram names no model, no node and no count: fabrics are drawn because llmgw keeps them apart, not because this install has two. Tests cover the parts that are easy to break later: every example lives in the page itself rather than being fetched per tab, the reflected base URL — now in five places rather than one — still cannot write markup, and the diagram carries a label for anyone not looking at it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
9.0 KiB
HTML
215 lines
9.0 KiB
HTML
{{define "landing"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>llmgw</title>
|
|
<meta name="description" content="One OpenAI-compatible endpoint in front of self-hosted models.">
|
|
<link rel="stylesheet" href="/_static/base.css">
|
|
</head><body><div class="wrap">
|
|
|
|
{{if .Notice}}<p class="banner note">{{.Notice}}</p>{{end}}
|
|
{{if not .AuthEnabled}}
|
|
<p class="banner warn-banner">This gateway has no API keys configured, so it serves anyone
|
|
who can reach it. That is a deployment choice, and only safe on a trusted network.</p>
|
|
{{end}}
|
|
|
|
<header class="hero">
|
|
<div class="hero-text">
|
|
<h1>llmgw</h1>
|
|
<p class="sub">One OpenAI-compatible endpoint in front of your own GPUs.</p>
|
|
<p class="lede">Point any OpenAI client at this address. The API is the one you
|
|
already have a client for — only the base URL changes.</p>
|
|
<p class="cta">
|
|
{{if .SignupOpen}}<a class="btn" href="/signup">Create an account</a>
|
|
<a class="btn ghost" href="/login">Sign in</a>
|
|
{{else if .AuthEnabled}}<a class="btn" href="/login">Sign in</a>{{end}}
|
|
</p>
|
|
</div>
|
|
|
|
{{/* A schematic of what this is, not of what is installed here. It names no
|
|
model, no node and no count: this page is served to anyone who can reach
|
|
the address. */}}
|
|
<figure class="schematic" aria-labelledby="schematic-cap">
|
|
<svg viewBox="0 0 420 200" role="img"
|
|
aria-label="Clients reach one endpoint, which places work on GPU nodes across separate fabrics.">
|
|
<defs>
|
|
<marker id="a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto">
|
|
<path d="M0 0 L8 4 L0 8 z" fill="var(--viz-grid)"/>
|
|
</marker>
|
|
</defs>
|
|
|
|
<!-- clients -->
|
|
<g font-size="10" fill="var(--muted)">
|
|
<rect x="2" y="42" width="84" height="26" rx="6" fill="var(--card)" stroke="var(--line)"/>
|
|
<text x="44" y="59" text-anchor="middle" fill="var(--fg)">your client</text>
|
|
<rect x="2" y="86" width="84" height="26" rx="6" fill="var(--card)" stroke="var(--line)"/>
|
|
<text x="44" y="103" text-anchor="middle" fill="var(--fg)">editor agent</text>
|
|
<rect x="2" y="130" width="84" height="26" rx="6" fill="var(--card)" stroke="var(--line)"/>
|
|
<text x="44" y="147" text-anchor="middle" fill="var(--fg)">a script</text>
|
|
</g>
|
|
<g fill="none" stroke="var(--viz-grid)" stroke-width="2" marker-end="url(#a)">
|
|
<path d="M88 55 C 118 55 118 92 146 92"/>
|
|
<path d="M88 99 h58"/>
|
|
<path d="M88 143 C 118 143 118 108 146 108"/>
|
|
</g>
|
|
|
|
<!-- the gateway -->
|
|
<rect x="150" y="70" width="72" height="60" rx="8"
|
|
fill="var(--accent)" opacity=".12" stroke="var(--accent)" stroke-width="1.5"/>
|
|
<text x="186" y="96" text-anchor="middle" font-size="12" font-weight="600"
|
|
fill="var(--fg)">llmgw</text>
|
|
<text x="186" y="112" text-anchor="middle" font-size="9"
|
|
fill="var(--muted)">one address</text>
|
|
|
|
<g fill="none" stroke="var(--viz-grid)" stroke-width="2" marker-end="url(#a)">
|
|
<path d="M224 88 C 254 88 254 46 286 46"/>
|
|
<path d="M224 112 C 254 112 254 146 286 146"/>
|
|
</g>
|
|
|
|
<!-- two fabrics that cannot see each other -->
|
|
<g font-size="9" fill="var(--muted)">
|
|
<rect x="288" y="18" width="128" height="58" rx="8"
|
|
fill="none" stroke="var(--line)" stroke-dasharray="4 3"/>
|
|
<text x="296" y="32">fabric</text>
|
|
<rect x="296" y="40" width="26" height="26" rx="4" fill="var(--viz-1)" opacity=".85"/>
|
|
<rect x="330" y="40" width="26" height="26" rx="4" fill="var(--viz-1)" opacity=".85"/>
|
|
<rect x="364" y="40" width="26" height="26" rx="4" fill="var(--viz-1)" opacity=".85"/>
|
|
|
|
<rect x="288" y="118" width="128" height="58" rx="8"
|
|
fill="none" stroke="var(--line)" stroke-dasharray="4 3"/>
|
|
<text x="296" y="132">fabric</text>
|
|
<rect x="296" y="140" width="26" height="26" rx="4" fill="var(--viz-3)" opacity=".85"/>
|
|
<rect x="330" y="140" width="26" height="26" rx="4" fill="var(--viz-3)" opacity=".85"/>
|
|
<rect x="364" y="140" width="26" height="26" rx="4" fill="var(--viz-3)" opacity=".85"/>
|
|
</g>
|
|
</svg>
|
|
<figcaption id="schematic-cap">Clients see one address. Work is placed on GPUs that
|
|
can actually run it — and a model split across machines never straddles two fabrics.</figcaption>
|
|
</figure>
|
|
</header>
|
|
|
|
<section class="url-card">
|
|
<span class="k">Base URL</span>
|
|
<code>{{.BaseURL}}/v1</code>
|
|
</section>
|
|
|
|
<h2>Three steps</h2>
|
|
<ol class="steps">
|
|
<li>
|
|
<span class="n">1</span>
|
|
<h3>Get a key</h3>
|
|
{{if .SignupOpen}}
|
|
<p><a href="/signup">Create an account</a>. Someone approves it, and then you issue
|
|
your own keys from your <a href="/profile">profile</a> — as many as you need,
|
|
revoking them yourself when a laptop is retired or a script is done.</p>
|
|
{{else if .Contact}}
|
|
<p>Ask {{if .ContactHref}}<a href="{{.ContactHref}}">{{.Contact}}</a>{{else}}<strong>{{.Contact}}</strong>{{end}}
|
|
for one.{{if .AuthEnabled}} If you already have an account,
|
|
<a href="/login">sign in</a>.{{end}}</p>
|
|
{{else}}
|
|
<p>Keys are issued by whoever operates this gateway. If you do not know who that
|
|
is, the address you reached this page at is the place to start asking.</p>
|
|
{{end}}
|
|
<p class="note">A key is shown once and never again — only its hash is kept here —
|
|
so put it in a password manager straight away.</p>
|
|
</li>
|
|
<li>
|
|
<span class="n">2</span>
|
|
<h3>Point your client at it</h3>
|
|
<p>Set the base URL above and your key. Every OpenAI SDK, and every tool built on
|
|
one, takes both as configuration.</p>
|
|
<pre><code>export OPENAI_BASE_URL={{.BaseURL}}/v1
|
|
export OPENAI_API_KEY=$LLMGW_API_KEY</code></pre>
|
|
</li>
|
|
<li>
|
|
<span class="n">3</span>
|
|
<h3>Pick a model</h3>
|
|
{{if .AuthEnabled}}
|
|
<p><code>GET /v1/models</code> lists what is available to your key. It needs the
|
|
key, which is why this page does not list them: this page is served to anyone who
|
|
can reach the address.</p>
|
|
{{else}}
|
|
<p><code>GET /v1/models</code> lists what is available.</p>
|
|
{{end}}
|
|
<p class="note">Sending the placeholder <code><model-name></code> unchanged is
|
|
answered with the list of models you may use, rather than with an error.</p>
|
|
</li>
|
|
</ol>
|
|
|
|
<h2>Connect your client</h2>
|
|
<div class="tabs">
|
|
<input type="radio" name="client" id="tab-curl" checked><label for="tab-curl">curl</label>
|
|
<input type="radio" name="client" id="tab-py"><label for="tab-py">Python</label>
|
|
<input type="radio" name="client" id="tab-oc"><label for="tab-oc">OpenCode</label>
|
|
<div class="panels">
|
|
<pre class="panel"><code>curl {{.BaseURL}}/v1/chat/completions \
|
|
-H "Authorization: Bearer $LLMGW_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"model": "<model-name>",
|
|
"messages": [{"role": "user", "content": "hello"}]}'</code></pre>
|
|
|
|
<pre class="panel"><code>import os
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url="{{.BaseURL}}/v1",
|
|
api_key=os.environ["LLMGW_API_KEY"])
|
|
|
|
reply = client.chat.completions.create(
|
|
model="<model-name>",
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
)
|
|
print(reply.choices[0].message.content)</code></pre>
|
|
|
|
<pre class="panel"><code>// ~/.config/opencode/opencode.json
|
|
{
|
|
"$schema": "https://opencode.ai/config.json",
|
|
"provider": {
|
|
"llmgw": {
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
"name": "llmgw",
|
|
"options": {
|
|
"baseURL": "{{.BaseURL}}/v1",
|
|
"apiKey": "{env:LLMGW_API_KEY}"
|
|
},
|
|
"models": { "<model-name>": { "name": "<model-name>" } }
|
|
}
|
|
}
|
|
}</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>
|
|
|
|
<h2>Once you have a key</h2>
|
|
<ul class="endpoints">
|
|
<li><code>GET {{.BaseURL}}/v1/models</code><span>what your key may use</span></li>
|
|
<li><code>GET {{.BaseURL}}/me</code><span>its limits, and what it has spent</span></li>
|
|
</ul>
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div>
|
|
|
|
<script>
|
|
// Vanilla and inline, for the same reason this page has no build step. It is an
|
|
// enhancement: the button only exists where the clipboard API does, so nothing
|
|
// here can leave a dead control on the page.
|
|
if (navigator.clipboard) {
|
|
for (const pre of document.querySelectorAll('pre')) {
|
|
// Read the text before the button joins the element, or "copy" ends up in
|
|
// everything anyone pastes.
|
|
const text = (pre.querySelector('code') || pre).textContent;
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.className = 'copy';
|
|
b.textContent = 'copy';
|
|
b.addEventListener('click', () => {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
b.textContent = 'copied';
|
|
setTimeout(() => { b.textContent = 'copy'; }, 1200);
|
|
});
|
|
});
|
|
pre.appendChild(b);
|
|
}
|
|
}
|
|
</script>
|
|
</body></html>{{end}}
|