A new internal/clientip says who sent a request and how: the connection's address, scheme and Host, or through a configured proxy what it forwarded. The base URL in pages and setup scripts, isHTTPS for cookies, and the per-address limits of auth and the sign-in forms use it. /setup scripts are no-store. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
239 lines
8.1 KiB
Go
239 lines
8.1 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}$`)
|
|
|
|
// 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.
|
|
func continueYAML(base, model, key string) string {
|
|
return fmt.Sprintf(`# 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
|
|
capabilities:
|
|
- tool_use
|
|
# For completion as you type, add a small, fast model here with the role
|
|
# autocomplete; a large one makes every keystroke wait for it.
|
|
`, base, model, key)
|
|
}
|
|
|
|
// 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.
|
|
func connectFor(tool, base, model, key string) 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)}}
|
|
v.Command = fmt.Sprintf("curl -fsSL '%s' | LLMGW_KEY='%s' sh", script, key)
|
|
v.Run = `In Continue, choose the "llmgw" assistant at the top of its panel.`
|
|
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{
|
|
"The command adds the two blocks above to ~/.codex/config.toml, replacing any it added before, and keeps a copy of the file as it was.",
|
|
"Put export LLMGW_API_KEY=… in your shell's profile to leave it out of the command line.",
|
|
}
|
|
case "cline":
|
|
v.Fields = []connectField{
|
|
{"API Provider", "OpenAI Compatible"},
|
|
{"Base URL", base + "/v1"},
|
|
{"API Key", key},
|
|
{"Model ID", model},
|
|
}
|
|
v.Run = "Cline keeps these in VS Code's own secret storage, so they are set in its settings: open Cline, the gear, and fill them in."
|
|
}
|
|
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
|
|
}
|
|
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, "", "choose a tool and a model")
|
|
return
|
|
}
|
|
project := r.FormValue("context")
|
|
if project == personalValue {
|
|
if p.personalOff {
|
|
p.renderProfile(w, r, sess, user, "", "personal keys are off on this gateway; choose a project")
|
|
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, "", "something went wrong; try again")
|
|
return
|
|
}
|
|
k.CreatedAt = time.Now()
|
|
if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil {
|
|
msg := "something went wrong; try again"
|
|
if err == store.ErrNotMember {
|
|
msg = "you are not a member of that project"
|
|
}
|
|
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 := connectFor(tool, baseURL(r), model, secret)
|
|
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__"))
|
|
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))
|
|
}
|