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=", 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= 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 }