From de2b2f0f285790f84c0519a6ee25bccc03d77f74 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 17:58:59 -0400 Subject: [PATCH] feat(llamaswap): add llama-swaps (TLS) DSN scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llama-swap was http-only by DSN, pushing TLS-fronted instances onto the openai:// scheme (which loses the management/image methods). Add a "llama-swaps" scheme that builds an https base URL, alongside "llama-swap" (http, local-first) — mirroring redis/rediss. Both share one factory; llama-swaps is scheme-only (no default built-in). The choice stays explicit because a DSN has no reliable http-vs-https signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 13 +++++------ README.md | 16 ++++++++------ builtin.go | 32 ++++++++++++++++++---------- builtin_llamaswap_test.go | 22 +++++++++++++++++++ docs/adr/0015-llama-swap-provider.md | 12 ++++++----- progress.md | 7 ++++++ 6 files changed, 73 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ad3157..c203d8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,12 +79,13 @@ alias := bare token (no slash), expands INLINE, recursively, cycle-checked `LLM_=scheme://[token@]host[/path]` — e.g. `LLM_M5=foreman://token@foreman-m5.example` defines provider `m5`; then `m5/qwen3:30b` works in Parse, chains, and aliases. Scheme ∈ {foreman, -ollama, ollama-cloud, openai, anthropic, google, gemini, llama-swap} ∪ -RegisterScheme. Token = credential; base URL = `https://host` always — -**except `llama-swap`, which builds `http://host` (local-first; ADR-0015).** -`New()` scans the process env eagerly; unknown names also resolve lazily at -Parse time (`my-prov` → `LLM_MY_PROV`). Malformed entries fail on use, not at -startup. +ollama, ollama-cloud, openai, anthropic, google, gemini, llama-swap, +llama-swaps} ∪ RegisterScheme. Token = credential; base URL = `https://host` +always — **except `llama-swap`, which builds `http://host` (local-first); +`llama-swaps` is its TLS twin (`https://host`), mirroring redis/rediss +(ADR-0015).** `New()` scans the process env eagerly; unknown names also resolve +lazily at Parse time (`my-prov` → `LLM_MY_PROV`). Malformed entries fail on use, +not at startup. ## Health & failover (ADR-0006, ADR-0008) diff --git a/README.md b/README.md index f815fc7..b267098 100644 --- a/README.md +++ b/README.md @@ -159,15 +159,17 @@ m, _ := reg.Parse("m5/qwen3:30b,m1/qwen3:30b,thinking") ``` DSN format: `scheme://[token@]host[/path]`, scheme ∈ `foreman`, `ollama`, -`ollama-cloud`, `openai`, `anthropic`, `google`/`gemini`, `llama-swap`, or any -scheme you add with `RegisterScheme`. The token is the credential (bearer token -/ API key); the base URL is always `https://host[/path]` — except `llama-swap`, -which builds `http://host[:port]` since it's local-first. `New()` loads `LLM_*` -vars eagerly; unknown provider names also resolve lazily at Parse time -(`my-prov/x` → `LLM_MY_PROV`). +`ollama-cloud`, `openai`, `anthropic`, `google`/`gemini`, `llama-swap`, +`llama-swaps`, or any scheme you add with `RegisterScheme`. The token is the +credential (bearer token / API key); the base URL is always `https://host[/path]` +— except `llama-swap`, which builds `http://host[:port]` since it's local-first +(`llama-swaps` is the TLS twin → `https://host`, mirroring redis/rediss). `New()` +loads `LLM_*` vars eagerly; unknown provider names also resolve lazily at Parse +time (`my-prov/x` → `LLM_MY_PROV`). ``` -LLM_LS=llama-swap://token@box.local:8080 # then "ls/qwen3:14b" parses +LLM_LS=llama-swap://token@box.local:8080 # http → "ls/qwen3:14b" parses +LLM_LS=llama-swaps://token@swap.example.com # https → TLS-fronted instance ``` [llama-swap](https://github.com/mostlygeek/llama-swap) is a model-swapping proxy diff --git a/builtin.go b/builtin.go index aae9051..91434f3 100644 --- a/builtin.go +++ b/builtin.go @@ -20,6 +20,12 @@ const ( ProviderOllamaCloud = "ollama-cloud" ProviderForeman = "foreman" ProviderLlamaSwap = "llama-swap" + // ProviderLlamaSwapTLS is the DSN scheme for a TLS-fronted llama-swap + // (https base URL). It is a scheme only, not a default built-in provider + // name. Why a separate scheme rather than auto-detecting: a DSN carries no + // reliable signal for http vs https, so the choice is explicit + // (llama-swap = http local-first, llama-swaps = https), mirroring rediss. + ProviderLlamaSwapTLS = "llama-swaps" ) // registerBuiltins installs the built-in providers and env-DSN scheme @@ -70,24 +76,28 @@ func registerBuiltins(r *Registry, httpClient *http.Client) { // llama-swap: OpenAI-compatible chat + image generation + management // endpoints over a model-swapping proxy. Chat reuses the openai client - // (provider/llamaswap delegates); the DSN builds an http:// base URL - // because llama-swap is local-first (TLS-fronted instances can use the - // openai:// scheme for chat). The no-DSN built-in errors on use with a - // clear message, mirroring foreman. + // (provider/llamaswap delegates). Two schemes: "llama-swap" builds an + // http:// base URL (local-first default), "llama-swaps" builds https:// + // for a TLS-fronted instance (mirrors redis/rediss). The no-DSN built-in + // errors on use with a clear message, mirroring foreman. llamaSwapOpts := func(extra ...llamaswap.Option) []llamaswap.Option { if httpClient != nil { extra = append(extra, llamaswap.WithHTTPClient(httpClient)) } return extra } - r.providers[ProviderLlamaSwap] = llamaswap.New(llamaSwapOpts(llamaswap.WithName(ProviderLlamaSwap))...) - r.schemes[ProviderLlamaSwap] = func(name string, dsn DSN) (llm.Provider, error) { - return llamaswap.New(llamaSwapOpts( - llamaswap.WithName(name), - llamaswap.WithBaseURL("http://"+dsn.Host), - llamaswap.WithToken(dsn.Token), - )...), nil + llamaSwapScheme := func(urlScheme string) SchemeFactory { + return func(name string, dsn DSN) (llm.Provider, error) { + return llamaswap.New(llamaSwapOpts( + llamaswap.WithName(name), + llamaswap.WithBaseURL(urlScheme+"://"+dsn.Host), + llamaswap.WithToken(dsn.Token), + )...), nil + } } + r.providers[ProviderLlamaSwap] = llamaswap.New(llamaSwapOpts(llamaswap.WithName(ProviderLlamaSwap))...) + r.schemes[ProviderLlamaSwap] = llamaSwapScheme("http") + r.schemes[ProviderLlamaSwapTLS] = llamaSwapScheme("https") // Anthropic and Anthropic-compatible endpoints. anthropicOpts := func(extra ...anthropic.Option) []anthropic.Option { diff --git a/builtin_llamaswap_test.go b/builtin_llamaswap_test.go index bd3204b..febf772 100644 --- a/builtin_llamaswap_test.go +++ b/builtin_llamaswap_test.go @@ -51,6 +51,28 @@ func TestLlamaSwapScheme(t *testing.T) { } } +// TestLlamaSwapsScheme: the "llama-swaps" scheme builds an https base URL for a +// TLS-fronted instance (vs "llama-swap" which is http local-first). +func TestLlamaSwapsScheme(t *testing.T) { + r := newTestRegistry(t) + if err := r.LoadEnv(map[string]string{ + "LLM_LST": "llama-swaps://tok@swap.example.com", + }); err != nil { + t.Fatalf("LoadEnv: %v", err) + } + p, ok := r.Provider("lst") + if !ok { + t.Fatal("provider \"lst\" not registered") + } + lp, ok := p.(*llamaswap.Provider) + if !ok { + t.Fatalf("provider is %T, want *llamaswap.Provider", p) + } + if want := "https://swap.example.com"; lp.BaseURL() != want { + t.Errorf("baseURL = %q, want %q", lp.BaseURL(), want) + } +} + // TestLlamaSwapBuiltinNoURL: the no-DSN built-in resolves but errors clearly on // use (mirrors foreman), rather than silently hitting a wrong host. func TestLlamaSwapBuiltinNoURL(t *testing.T) { diff --git a/docs/adr/0015-llama-swap-provider.md b/docs/adr/0015-llama-swap-provider.md index ed40248..0f7cd58 100644 --- a/docs/adr/0015-llama-swap-provider.md +++ b/docs/adr/0015-llama-swap-provider.md @@ -38,11 +38,13 @@ features leak into the canonical API). `Unload` (POST `/api/models/unload[/:model]`). A small `doJSON` helper shares bearer auth + error mapping; non-2xx → `*llm.APIError` (so `llm.Classify` applies), transport errors wrapped raw. -- DSN: the `llama-swap` scheme builds an **http://** base URL from the host - (llama-swap is local-first), deliberately *not* the DSN's https-always - `BaseURL()`. A TLS-fronted instance can use the `openai://` scheme for chat. - A no-DSN built-in `llama-swap` provider registers but errors on use (mirrors - foreman). +- DSN: two schemes share one factory. `llama-swap` builds an **http://** base + URL from the host (llama-swap is local-first), deliberately *not* the DSN's + https-always `BaseURL()`; `llama-swaps` builds **https://** for a TLS-fronted + instance (mirrors redis/rediss). Why a second scheme rather than auto-detect: + a DSN carries no reliable http-vs-https signal, so the choice stays explicit. + Only `llama-swap` registers a no-DSN built-in provider (errors on use, mirrors + foreman); `llama-swaps` is a scheme only. - Image generation is implemented here too, against the new `imagegen` interface (see ADR-0016). diff --git a/progress.md b/progress.md index 1d52414..d441216 100644 --- a/progress.md +++ b/progress.md @@ -1,5 +1,12 @@ # progress +## 2026-06-27 — llama-swaps (TLS) DSN scheme + +Follow-up to the llama-swap provider: added the `llama-swaps` DSN scheme (https +base URL) alongside `llama-swap` (http, local-first), mirroring redis/rediss, so +a TLS-fronted instance is first-class instead of being pushed to the `openai://` +scheme. Scheme-only (no default built-in); shares one factory in builtin.go. + ## 2026-06-27 — llama-swap provider + canonical image-gen interface **Landed (ADR-0015, ADR-0016).** New `provider/llamaswap`: chat **delegates to -- 2.52.0