Files
llmgw/internal/web/connect.go
T
Ilker ManapandClaude Opus 5.5 481523ca46 serve: tool calls declared, slots that hold the context, longer answers
A --context-length 32768 --ctx-pool 32768 model got llama.cpp's default four
slots of 8192, and refused what the gateway admitted; --parallel is now
what the pool holds of the context. max_output was 1024 for every model,
which cut an editor's agent off mid-file; it is now a quarter of the
context, from 1024 to 8192. --tools writes capabilities.tools, which the
Continue configuration now reads, as it reads max_output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:30:40 +02:00

293 lines
9.9 KiB
Go

package web
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Connecting an editor: a key made for it, and the configuration it needs
// with that key, this gateway's address and a served model already in it.
//
// A page cannot write files on somebody's machine, so what it can do is hand
// over the file, ready — to copy, to download, or to have a one-line command
// write. The command's script is served without authentication and holds no
// secret: the key travels in an environment variable, never in a URL, where
// it would land in shell history and proxy logs.
// editorTools are the tools the page knows how to configure.
var editorTools = []struct{ Key, Name string }{
{"continue", "Continue"}, {"codex", "Codex CLI"}, {"cline", "Cline"},
}
// connectView is what the page shows after a key was made for a tool.
type connectView struct {
Tool, ToolName, Model string
Key string
// Files are what the tool reads, with the key in them.
Files []connectFile
// Fields are for a tool that keeps its settings where no file reaches:
// Cline's live in VS Code's secret storage, set through its own screen.
Fields []connectField
// Command writes the files; Run is how to start the tool on the gateway.
Command, Run string
Notes []string
}
type connectFile struct{ Path, Name, Body string }
type connectField struct{ Label, Value string }
// safeModel is what a model name may be to go into a script and a file
// unquoted: the names this gateway serves, and nothing a shell reads twice.
var safeModel = regexp.MustCompile(`^[A-Za-z0-9._:/@+-]{1,200}$`)
// ModelFacts is what an editor's configuration needs to know of a model: how
// much one request may hold, and whether its tool calls come back as calls.
type ModelFacts struct {
ContextLength int
// MaxOutput is the longest answer the gateway allows; 0 is no limit.
MaxOutput int
Tools bool
}
// continueYAML is a Continue assistant pointing at the gateway: a file of its
// own under ~/.continue/assistants, so the person's config.yaml is not touched.
//
// What it says of the model is what the catalog says. Continue assumed a
// context far larger than a CPU replica's, and every request over it was
// refused; and told a model uses tools when its calls come back as text,
// Continue's agent stalls on them rather than acting.
func continueYAML(base, model, key string, f ModelFacts) string {
var b strings.Builder
fmt.Fprintf(&b, `# Written by llmgw for Continue: the gateway at %[1]s.
name: llmgw
version: 1.0.0
schema: v1
models:
- name: %[2]s (llmgw)
provider: openai
model: %[2]s
apiBase: %[1]s/v1
apiKey: %[3]s
roles:
- chat
- edit
- apply
`, base, model, key)
if f.Tools {
b.WriteString(" capabilities:\n - tool_use\n")
}
if f.ContextLength > 0 {
fmt.Fprintf(&b, ` # What the gateway lets one request hold; Continue trims to fit.
defaultCompletionOptions:
contextLength: %d
maxTokens: %d
`, f.ContextLength, maxTokensFor(f))
}
if !f.Tools {
b.WriteString("# This model's catalog entry does not say it makes tool calls, so Continue\n" +
"# chats and edits with it but its agent cannot act: choose one that does.\n")
}
b.WriteString(`# For completion as you type, add a small, fast model here with the role
# autocomplete; a large one makes every keystroke wait for it.
`)
return b.String()
}
// codexTOML is the provider and profile Codex needs, and nothing else of its
// configuration: codex --profile llmgw uses the gateway, plain codex what it
// used before. The key is read from LLMGW_API_KEY rather than written down.
func codexTOML(base, model string) string {
return fmt.Sprintf(`# Written by llmgw for Codex: the gateway at %[1]s.
[model_providers.llmgw]
name = "llmgw"
base_url = "%[1]s/v1"
env_key = "LLMGW_API_KEY"
wire_api = "responses"
[profiles.llmgw]
model_provider = "llmgw"
model = "%[2]s"
`, base, model)
}
// connectFor builds what the page shows for a tool, in English.
func connectFor(tool, base, model, key string, f ModelFacts) connectView {
return connectIn(english, tool, base, model, key, f)
}
// connectIn builds what the page shows for a tool, its sentences in l. The
// files, commands and paths are the same in every language.
func connectIn(l *Lang, tool, base, model, key string, f ModelFacts) connectView {
v := connectView{Tool: tool, Model: model, Key: key}
for _, t := range editorTools {
if t.Key == tool {
v.ToolName = t.Name
}
}
script := base + "/setup/" + tool + "?model=" + model
switch tool {
case "continue":
v.Files = []connectFile{{Path: "~/.continue/assistants/llmgw.yaml", Name: "llmgw.yaml",
Body: continueYAML(base, model, key, f)}}
v.Command = fmt.Sprintf("curl -fsSL '%s' | LLMGW_KEY='%s' sh", script, key)
v.Run = l.T("connect.run.continue")
case "codex":
v.Files = []connectFile{{Path: "~/.codex/config.toml", Name: "config.toml", Body: codexTOML(base, model)}}
v.Command = fmt.Sprintf("curl -fsSL '%s' | sh", script)
v.Run = fmt.Sprintf("LLMGW_API_KEY='%s' codex --profile llmgw", key)
v.Notes = []string{l.T("connect.note.codex_blocks"), l.T("connect.note.codex_env")}
case "cline":
// The values are what Cline's own screen offers and takes, which is
// English whatever language this page is in.
v.Fields = []connectField{
{l.T("connect.field.provider"), "OpenAI Compatible"},
{l.T("connect.field.base_url"), base + "/v1"},
{l.T("connect.field.key"), key},
{l.T("connect.field.model"), model},
}
v.Run = l.T("connect.run.cline")
}
return v
}
// connect makes a key for an editor and shows its configuration.
func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
l := langOf(r)
tool := r.FormValue("tool")
known := false
for _, t := range editorTools {
known = known || t.Key == tool
}
model := strings.TrimSpace(r.FormValue("model"))
if !known || !safeModel.MatchString(model) {
p.renderProfile(w, r, sess, user, "", l.T("connect.err.choose"))
return
}
project := r.FormValue("context")
if project == personalValue {
if p.personalOff {
p.renderProfile(w, r, sess, user, "", l.T("profile.err.personal_off"))
return
}
project = ""
}
name := tool + " " + time.Now().Format("2006-01-02")
secret, k, err := store.NewKey(randomKeyID(), name, project)
if err != nil {
p.renderProfile(w, r, sess, user, "", l.T("profile.err.try_again"))
return
}
k.CreatedAt = time.Now()
if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil {
msg := l.T("profile.err.try_again")
if err == store.ErrNotMember {
msg = l.T("profile.err.not_member")
}
p.renderProfile(w, r, sess, user, "", msg)
return
}
p.log.Info("a user connected an editor", "user", user.ID, "tool", tool, "key", k.ID)
c := connectIn(l, tool, baseURL(r), model, secret, p.facts(model))
p.render(w, r, sess, user, profileData{Connect: &c})
}
// setupScript is the script the one-line command runs: it writes the tool's
// configuration with the key from the environment, keeping a copy of what
// was there. Served without a session — it holds nothing a stranger could
// use: this gateway's address, which they already typed, and a model name.
func (p *AccountPages) setupScript(w http.ResponseWriter, r *http.Request) {
// Never from a cache: the script names this gateway's address, and one
// cached for another request's Host would send a client's key elsewhere.
w.Header().Set("Cache-Control", "no-store")
tool := r.PathValue("tool")
model := r.URL.Query().Get("model")
if !safeModel.MatchString(model) {
http.Error(w, "which model? add ?model=<name>", http.StatusBadRequest)
return
}
base := baseURL(r)
var body string
switch tool {
case "continue":
body = fmt.Sprintf(`#!/bin/sh
# Connects Continue to the llmgw gateway at %[1]s.
set -eu
: "${LLMGW_KEY:?set LLMGW_KEY to the key the gateway gave you}"
dir="$HOME/.continue/assistants"
file="$dir/llmgw.yaml"
mkdir -p "$dir"
[ -f "$file" ] && cp "$file" "$file.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
umask 077
cat > "$file" <<'LLMGW'
%[2]sLLMGW
# The key goes in last, from the environment: never through a URL.
tmp="$file.tmp"
sed "s|__LLMGW_KEY__|$LLMGW_KEY|" "$file" > "$tmp" && mv "$tmp" "$file"
echo "Wrote $file. In Continue, choose the llmgw assistant."
`, base, continueYAML(base, model, "__LLMGW_KEY__", p.facts(model)))
case "codex":
body = fmt.Sprintf(`#!/bin/sh
# Connects Codex to the llmgw gateway at %[1]s.
set -eu
dir="$HOME/.codex"
file="$dir/config.toml"
mkdir -p "$dir"
touch "$file"
cp "$file" "$file.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
# Out with the blocks this wrote before, in with the new: every other line
# of the file stays as it was.
awk '
/^\[/ { skip = ($0 == "[model_providers.llmgw]" || $0 == "[profiles.llmgw]" || $0 ~ /^# Written by llmgw/) }
/^# Written by llmgw/ { skip = 1; next }
!skip { print }
' "$file" > "$file.tmp"
cat >> "$file.tmp" <<'LLMGW'
%[2]sLLMGW
mv "$file.tmp" "$file"
echo "Added the llmgw provider to $file."
echo "Run: LLMGW_API_KEY=<your key> codex --profile llmgw"
`, base, codexTOML(base, model))
default:
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(body))
}
// facts is what the catalog says of a model, when there is one to ask.
func (p *AccountPages) facts(model string) ModelFacts {
if p.modelFacts == nil {
return ModelFacts{}
}
return p.modelFacts(model)
}
// maxTokensFor is the answer length Continue asks for: a quarter of the
// context, at most 4096, and never over what the gateway allows — asked for
// more, it refuses the request.
func maxTokensFor(f ModelFacts) int {
n := min(4096, max(256, f.ContextLength/4))
if f.MaxOutput > 0 {
n = min(n, f.MaxOutput)
}
return n
}