123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 | package tui
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"net/url"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"blinker/internal/config"
"blinker/internal/llm"
)
// preset is a kind of server, with the settings it usually needs.
type preset struct {
label, url, model string
apply func(*config.Config)
}
var presets = []preset{
{"Anthropic (Claude)", "https://api.anthropic.com/v1", "claude-opus-5-5", func(c *config.Config) {
// Current Claude models reject sampling settings and think as they see fit.
c.Sampling, c.ThinkingParam, c.MaxTokensParam = false, "none", "max_tokens"
}},
{"OpenAI", "https://api.openai.com/v1", "", func(c *config.Config) {
c.Sampling, c.ThinkingParam, c.MaxTokensParam = false, "none", "max_completion_tokens"
}},
{"llama.cpp server", "http://localhost:8080/v1", "", func(c *config.Config) {
c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "chat_template_kwargs", "max_tokens", 20
}},
{"vLLM or SGLang", "http://localhost:8000/v1", "", func(c *config.Config) {
c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "chat_template_kwargs", "max_tokens", 20
}},
{"Ollama", "http://localhost:11434/v1", "", func(c *config.Config) {
c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "none", "max_tokens", 0
}},
{"Other OpenAI-compatible server", "http://localhost:8080/v1", "", func(c *config.Config) {
c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "none", "max_tokens", 0
}},
}
type setupStep int
const (
stepPreset setupStep = iota
stepURL
stepKey
stepChecking // TLS check, then listing models
stepTrust
stepModel
stepThinking
stepTesting
stepContext
stepFailed
)
type (
tlsCheckedMsg struct {
certs []*x509.Certificate
why error
}
modelsMsg struct {
models []string
err error
}
testedMsg struct {
err error
window int // the context size the server gave; 0 if it did not
}
setupCancel struct{}
)
// setupModel asks for the LLM backend settings, checks them with a test
// request, and saves them.
type setupModel struct {
cfg config.Config
path string // the agent's settings file, which holds the key
configDir string // where the settings and trusted certificates go
step setupStep
sel int
url, key textinput.Model
filter textinput.Model
window textinput.Model
models []string
modelsErr error
certs []*x509.Certificate
certWhy error
trustErr error // why trusting the anchor would not help, checked once
errText string
width int
spin spinner.Model
newClient func(config.Config) (*llm.Client, error)
}
func newSetup(cfg config.Config, configDir string, newClient func(config.Config) (*llm.Client, error)) setupModel {
urlIn := textinput.New()
urlIn.SetValue(cfg.BaseURL)
urlIn.CursorEnd()
keyIn := textinput.New()
keyIn.EchoMode = textinput.EchoPassword
keyIn.Placeholder = "empty: use $OPENAI_API_KEY ($ANTHROPIC_API_KEY for Anthropic), or no key"
keyIn.SetValue(cfg.APIKey)
keyIn.CursorEnd()
filterIn := textinput.New()
filterIn.Placeholder = "type to filter, or a model name"
windowIn := textinput.New()
windowIn.Placeholder = "empty: unknown, so compact only when asked (/compact)"
if cfg.ContextWindow > 0 {
windowIn.SetValue(strconv.Itoa(cfg.ContextWindow))
windowIn.CursorEnd()
}
for _, in := range []*textinput.Model{&urlIn, &keyIn, &filterIn, &windowIn} {
in.SetWidth(76)
}
s := setupModel{cfg: cfg, path: filepath.Join(configDir, config.AgentFile), configDir: configDir, url: urlIn, key: keyIn, filter: filterIn, window: windowIn,
width: 80, spin: spinner.New(spinner.WithSpinner(clockwiseDot)), newClient: newClient}
s.sel = s.presetIndex()
return s
}
func (s setupModel) Init() tea.Cmd { return s.spin.Tick }
func (s setupModel) checkTLS() tea.Cmd {
cfg := s.cfg
return func() tea.Msg {
certs, why := untrusted(cfg, cfg.BaseURL)
return tlsCheckedMsg{certs, why}
}
}
func (s setupModel) listModels() tea.Cmd {
c, err := s.newClient(s.cfg)
return func() tea.Msg {
if err != nil {
return modelsMsg{err: err}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
m, err := c.Models(ctx)
return modelsMsg{m, err}
}
}
// test makes the kind of request blinker will, streaming, with a tool, and
// asks the server the model's context size.
func (s setupModel) test() tea.Cmd {
cfg := s.cfg
cfg.Thinking, cfg.MaxTokens = false, 64 // quick; the switch is still sent
c, err := s.newClient(cfg)
return func() tea.Msg {
if err != nil {
return testedMsg{err: err}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
ping := llm.Tool{Name: "ping", Description: "Returns pong.", Parameters: map[string]any{"type": "object", "properties": map[string]any{}}}
_, err := c.Complete(ctx, []llm.Message{{Role: "user", Content: "Reply with just the word OK."}}, []llm.Tool{ping}, nil)
if errors.Is(err, llm.ErrTruncated) {
err = nil // the server took the request
}
if err != nil {
return testedMsg{err: err}
}
window, err := c.ContextSize(ctx)
if err != nil {
window = 0 // the server does not say; setup asks
}
return testedMsg{window: window}
}
}
func (s setupModel) filtered() []string {
f := strings.ToLower(strings.TrimSpace(s.filter.Value()))
var out []string
for _, m := range s.models {
if strings.Contains(strings.ToLower(m), f) {
out = append(out, m)
}
}
return out
}
func (s setupModel) Update(msg tea.Msg) (setupModel, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
s.spin, cmd = s.spin.Update(msg)
return s, cmd
case tlsCheckedMsg:
if len(msg.certs) > 0 {
s.certs, s.certWhy, s.step, s.sel = msg.certs, msg.why, stepTrust, 0
s.trustErr = checkAnchor(s.certs, anchor(s.certs), hostOf(s.cfg.BaseURL))
return s, nil
}
if msg.why != nil {
s.errText, s.step = msg.why.Error(), stepURL
return s, s.url.Focus()
}
return s, s.listModels()
case modelsMsg:
s.models, s.modelsErr = msg.models, msg.err
s.step, s.sel = stepModel, 0
s.filter.SetValue("")
if s.cfg.Model != "" && !slices.Contains(s.models, s.cfg.Model) {
s.filter.SetValue(s.cfg.Model)
s.filter.CursorEnd()
}
for i, m := range s.models {
if m == s.cfg.Model || (s.cfg.Model == "" && m == presets[s.presetIndex()].model) {
s.sel = i
}
}
return s, s.filter.Focus()
case testedMsg:
if msg.err != nil {
s.errText, s.step, s.sel = msg.err.Error(), stepFailed, 0
return s, nil
}
if msg.window > 0 {
s.cfg.ContextWindow = 0 // ask the server each time, in case it changes
return s.save()
}
s.step = stepContext
return s, s.window.Focus()
case tea.KeyPressMsg:
return s.keyPress(msg)
}
// Anything else, such as pasted text (from the terminal, or from the
// clipboard with ctrl+v), goes to the field being edited.
var cmd tea.Cmd
switch s.step {
case stepURL:
s.url, cmd = s.url.Update(msg)
case stepKey:
s.key, cmd = s.key.Update(msg)
case stepContext:
s.window, cmd = s.window.Update(msg)
case stepModel:
s.filter, cmd = s.filter.Update(msg)
s.sel = 0
}
return s, cmd
}
// presetIndex is the preset matching the URL, or "other".
func (s setupModel) presetIndex() int {
for i, p := range presets {
if p.url == s.cfg.BaseURL {
return i
}
}
return len(presets) - 1
}
func (s setupModel) save() (setupModel, tea.Cmd) {
if err := s.cfg.Save(s.configDir); err != nil {
s.errText, s.step = "Saving: "+err.Error(), stepFailed
return s, nil
}
cfg := s.cfg
return s, func() tea.Msg { return setupDoneMsg{cfg} }
}
func (s setupModel) keyPress(msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
k := msg.String()
if k == "ctrl+c" {
return s, func() tea.Msg { return setupCancel{} }
}
switch s.step {
case stepPreset:
return s.presetKey(k)
case stepURL:
return s.urlKey(k, msg)
case stepKey:
return s.apiKeyKey(k, msg)
case stepTrust:
return s.trustKey(k)
case stepModel:
return s.modelKey(k, msg)
case stepThinking:
return s.thinkingKey(k)
case stepContext:
return s.windowKey(k, msg)
case stepFailed:
return s.failedKey(k)
}
return s, nil
}
// choose moves the selection among n options for key k, and reports
// whether k picked the selected one.
func (s *setupModel) choose(k string, n int) bool {
switch k {
case "up", "shift+tab":
s.sel = (s.sel + n - 1) % n
case "down", "tab":
s.sel = (s.sel + 1) % n
case "enter":
return true
}
return false
}
func (s setupModel) presetKey(k string) (setupModel, tea.Cmd) {
if k == "esc" {
return s, func() tea.Msg { return setupCancel{} }
}
if !s.choose(k, len(presets)) {
return s, nil
}
p := presets[s.sel]
if p.url != s.cfg.BaseURL {
s.cfg.BaseURL, s.cfg.Model, s.cfg.APIKey = p.url, "", ""
s.key.SetValue("")
}
p.apply(&s.cfg)
s.url.SetValue(s.cfg.BaseURL)
s.url.CursorEnd()
s.step, s.errText = stepURL, ""
return s, s.url.Focus()
}
func (s setupModel) urlKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
switch k {
case "esc":
s.step = stepPreset
return s, nil
case "enter":
v := strings.TrimSuffix(strings.TrimSpace(s.url.Value()), "/")
u, err := url.Parse(v)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
s.errText = "Enter an http:// or https:// URL."
return s, nil
}
if v != s.cfg.BaseURL {
s.cfg.CACert = ""
}
s.cfg.BaseURL, s.errText, s.step = v, "", stepKey
s.url.Blur()
return s, s.key.Focus()
}
var cmd tea.Cmd
s.url, cmd = s.url.Update(msg)
return s, cmd
}
func (s setupModel) apiKeyKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
switch k {
case "esc":
s.step = stepURL
return s, s.url.Focus()
case "enter":
s.cfg.APIKey = strings.TrimSpace(s.key.Value())
s.key.Blur()
s.step = stepChecking
return s, tea.Batch(s.checkTLS(), s.spin.Tick)
}
var cmd tea.Cmd
s.key, cmd = s.key.Update(msg)
return s, cmd
}
func (s setupModel) trustKey(k string) (setupModel, tea.Cmd) {
if k == "esc" {
s.step = stepURL
return s, s.url.Focus()
}
if !s.choose(k, len(s.trustOptions())) {
return s, nil
}
if s.trustErr != nil || s.sel != 0 { // not "Trust it"
s.step = stepURL
return s, s.url.Focus()
}
path, err := trustCert(s.configDir, hostOf(s.cfg.BaseURL), anchor(s.certs))
if err != nil {
s.errText = err.Error()
return s, nil
}
s.cfg.CACert = path
s.step = stepChecking
return s, tea.Batch(s.listModels(), s.spin.Tick)
}
func (s setupModel) modelKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
list := s.filtered()
switch k {
case "esc":
s.step = stepKey
return s, s.key.Focus()
case "up", "down":
if len(list) > 0 {
s.choose(k, len(list))
}
return s, nil
case "enter":
name := strings.TrimSpace(s.filter.Value())
if len(list) > 0 && s.sel < len(list) {
name = list[s.sel]
}
if name == "" {
s.errText = "Choose or type a model."
return s, nil
}
s.cfg.Model, s.errText = name, ""
s.toThinking()
s.filter.Blur()
return s, nil
}
var cmd tea.Cmd
s.filter, cmd = s.filter.Update(msg)
if n := len(s.filtered()); s.sel >= n {
s.sel = max(n-1, 0)
}
return s, cmd
}
// toThinking goes to the question of thinking, with the answer set now
// selected.
func (s *setupModel) toThinking() {
s.step, s.sel = stepThinking, 0
if !s.cfg.Thinking {
s.sel = 1
}
}
func (s setupModel) thinkingKey(k string) (setupModel, tea.Cmd) {
if k == "esc" {
s.step = stepModel
return s, s.filter.Focus()
}
if !s.choose(k, 2) {
return s, nil
}
s.cfg.Thinking = s.sel == 0
s.step = stepTesting
return s, tea.Batch(s.test(), s.spin.Tick)
}
func (s setupModel) windowKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
switch k {
case "esc":
s.toThinking()
s.window.Blur()
return s, nil
case "enter":
v := strings.TrimSpace(s.window.Value())
n := 0
if v != "" {
var err error
n, err = strconv.Atoi(v)
if err != nil || n < 1000 {
s.errText = "Enter a number of tokens, such as 32768, or nothing."
return s, nil
}
}
s.cfg.ContextWindow, s.errText = n, ""
s.window.Blur()
return s.save()
}
var cmd tea.Cmd
s.window, cmd = s.window.Update(msg)
return s, cmd
}
func (s setupModel) failedKey(k string) (setupModel, tea.Cmd) {
if !s.choose(k, 3) {
return s, nil
}
switch s.sel {
case 0:
s.step, s.sel, s.errText = stepPreset, s.presetIndex(), ""
case 1:
return s.save()
default:
return s, func() tea.Msg { return setupCancel{} }
}
return s, nil
}
// trustOptions are the answers to whether to trust the server's
// certificate: trusting it first, if that would help, and going back.
func (s setupModel) trustOptions() []string {
back := "Change the URL"
if s.trustErr != nil {
return []string{back}
}
return []string{"Trust it (saved in " + certPath(s.configDir, hostOf(s.cfg.BaseURL)) + ")", back}
}
func (s setupModel) View() string {
var b strings.Builder
b.WriteString(userStyle.Render("blinker-agent setup") + dimStyle.Render(" · "+s.path) + "\n\n")
list := func(items []string) {
for i, it := range items {
b.WriteString(menuRow(it, i == s.sel))
}
}
switch s.step {
case stepPreset:
b.WriteString("Which LLM backend should blinker use?\n")
var labels []string
for _, p := range presets {
labels = append(labels, p.label)
}
list(labels)
case stepURL:
b.WriteString("Server URL (the OpenAI-compatible API, ending before /chat/completions):\n")
b.WriteString(s.url.View() + "\n")
case stepKey:
b.WriteString("API key (saved in " + s.path + ", readable only by you):\n")
b.WriteString(s.key.View() + "\n")
case stepChecking, stepTesting:
what := "Asking " + s.cfg.BaseURL + " what it offers…"
if s.step == stepTesting {
what = "Sending a test request to " + s.cfg.Model + "…"
}
b.WriteString(s.spin.View() + " " + Sanitize(what) + "\n")
case stepTrust:
b.WriteString(Sanitize(trustText(s.certs, s.certWhy, s.trustErr, s.cfg.BaseURL)) + "\n\n")
list(s.trustOptions())
case stepModel:
if s.modelsErr != nil {
b.WriteString(dimStyle.Render(wrap("Could not list the server's models: "+Sanitize(s.modelsErr.Error()), s.width)) + "\n")
}
b.WriteString("Model:\n" + s.filter.View() + "\n")
items := s.filtered()
start := max(0, min(s.sel-5, len(items)-10))
end := min(start+10, len(items))
for i := start; i < end; i++ {
b.WriteString(menuRow(Sanitize(items[i]), i == s.sel))
}
if len(items) > 10 {
b.WriteString(dimStyle.Render(fmt.Sprintf(" (%d models match)", len(items))) + "\n")
}
case stepThinking:
b.WriteString("Let the model reason before it answers? (Slower, usually better.)\n")
list([]string{"Yes", "No"})
case stepContext:
b.WriteString(wrap("The server does not say how much context "+Sanitize(s.cfg.Model)+" has. How many tokens can it take in? Blinker compacts the conversation when it nearly fills them.", s.width) + "\n")
b.WriteString(s.window.View() + "\n")
case stepFailed:
b.WriteString(errStyle.Render("The test request failed:") + "\n")
b.WriteString(wrap(Sanitize(s.errText), s.width) + "\n\n")
list([]string{"Change the settings", "Save them anyway", "Quit without saving"})
}
if s.errText != "" && s.step != stepFailed {
b.WriteString(errStyle.Render(wrap(Sanitize(s.errText), s.width)) + "\n")
}
b.WriteString("\n" + dimStyle.Render("enter to continue · esc to go back · ctrl+c to cancel"))
return b.String()
}
// Trusting a server's certificate on first use, for local servers with
// self-signed or private-CA certificates.
func hostOf(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return u.Hostname()
}
// untrusted returns the certificates an https server presents if the
// config's TLS settings do not trust them, with the reason; nil if they
// do, or the URL is not https, or the server cannot be reached (later
// requests report that).
func untrusted(cfg config.Config, rawURL string) ([]*x509.Certificate, error) {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" {
return nil, nil
}
conf, err := llm.TLSConfig(cfg.CACert)
if err != nil {
return nil, err
}
addr := u.Host
if u.Port() == "" {
addr = net.JoinHostPort(u.Hostname(), "443")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conf.ServerName = u.Hostname()
conn, err := (&tls.Dialer{Config: conf}).DialContext(ctx, "tcp", addr)
if err == nil {
return nil, conn.Close()
}
var verifyErr *tls.CertificateVerificationError
if !errors.As(err, &verifyErr) || len(verifyErr.UnverifiedCertificates) == 0 {
return nil, nil
}
return verifyErr.UnverifiedCertificates, verifyErr.Err
}
// anchor picks the certificate to trust: the self-signed one at the top
// of the chain the server sent (its private CA, or its own certificate if
// it signed it itself), or else the server's certificate.
func anchor(certs []*x509.Certificate) *x509.Certificate {
top := certs[len(certs)-1]
if bytes.Equal(top.RawIssuer, top.RawSubject) && top.CheckSignatureFrom(top) == nil {
return top
}
return certs[0]
}
// checkAnchor reports whether trusting the anchor makes the server's
// certificate valid for host.
func checkAnchor(certs []*x509.Certificate, root *x509.Certificate, host string) error {
roots, inter := x509.NewCertPool(), x509.NewCertPool()
roots.AddCert(root)
for _, c := range certs[1:] {
inter.AddCert(c)
}
_, err := certs[0].Verify(x509.VerifyOptions{DNSName: host, Roots: roots, Intermediates: inter})
return err
}
// fingerprint is a certificate's SHA-256 fingerprint, as openssl x509
// -fingerprint -sha256 prints it.
func fingerprint(c *x509.Certificate) string {
sum := sha256.Sum256(c.Raw)
parts := make([]string, len(sum))
for i, x := range sum {
parts[i] = fmt.Sprintf("%02X", x)
}
return strings.Join(parts, ":")
}
func certPath(configDir, host string) string {
return filepath.Join(configDir, "certs", host+".pem")
}
// trustCert saves a certificate to trust and returns its path.
func trustCert(configDir, host string, c *x509.Certificate) (string, error) {
if host == "" || strings.ContainsAny(host, `/\`) {
return "", fmt.Errorf("bad host %q", host)
}
path := certPath(configDir, host)
data := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
if err := config.WriteFile(path, data, 0o644); err != nil {
return "", err
}
return path, nil
}
func trustText(certs []*x509.Certificate, why, trustErr error, rawURL string) string {
root, leaf := anchor(certs), certs[0]
var names []string
names = append(names, leaf.DNSNames...)
for _, ip := range leaf.IPAddresses {
names = append(names, ip.String())
}
kind := "the server's own certificate"
if root != leaf {
kind = "the CA that issued it, " + root.Subject.String()
}
text := fmt.Sprintf("%s presents a certificate blinker does not trust (%v).\n\nCertificate: %s, for %s, issued by %s, valid until %s.\n\nTrusting it saves %s, with SHA-256 fingerprint\n%s\nCompare that with `openssl x509 -noout -fingerprint -sha256 -in <the certificate>` on the server.",
hostOf(rawURL), why, leaf.Subject, strings.Join(names, ", "), leaf.Issuer, leaf.NotAfter.Format("2 Jan 2006"), kind, fingerprint(root))
if trustErr != nil {
text += "\n\nTrusting it would not help: " + trustErr.Error()
}
return text
}
|