The website was a file built by hand outside the repository. It is now a template in internal/web: every gateway serves it at /about, without a key, linked from the landing page's footer, and the published site is rendered from the same template. The fonts are vendored (Latin subsets, OFL) so the page sends nobody's browser to Google. The proxy configurations it shows are copies of deploy/proxy, kept equal by a test. A development build says so instead of linking to release files that do not exist. Co-Authored-By: Claude Opus 5.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="{{stylesheet}}">
|
|
</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><a href="/about">llmgw {{.Version}}</a></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}}
|