diff --git a/README.md b/README.md index b763868..a01ad4e 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,22 @@ memory layer for my homelab; basically just a place to chuck data into from a bu The canonical repo for this is hosted on tangled over at [`dunkirk.sh/lard`](https://tangled.org/dunkirk.sh/lard) -## Run it +## Running it ```sh -# server; consolidation needs a hyper API key -echo 'HYPER_API_KEY=sk-hyper-...' > .env +# server: copy the example config, add your API key +cp config.example.toml ~/.config/lard/config.toml +# edit ~/.config/lard/config.toml, set llm.api_key go run ./cmd/lard # listens on :7477 # client: point it at the server, then load everything you have -lard-client login # asks for the url, opens your browser +lard-client login # asks for the url, runs the device grant lard-client backfill --root ~/code # keep it fed in the background (macOS) lard-client service install ``` -`login` asks where the server lives, then runs a browser flow against whatever -auth server lard names, so there is no token to copy. It always prints the raw -authorization URL, so you can paste it into a browser on another machine. On a -headless box pass `--url` and `--token` and it never prompts. - -when the server brokers the login (the default once `LARD_COLLECTOR_CLIENT_ID` -is set) the collector opens no ports at all. you get a url on the server and it -polls until you finish, so ssh, containers, and headless boxes all work the same -way with nothing to forward. - -after that nothing needs poking: the agent syncs on an interval, and the server -consolidates itself once uploads go quiet. - ## client ``` @@ -45,25 +33,17 @@ lard-client service install|uninstall|status lard-client consolidate # force a pass now ``` -config lives at `~/.config/lard/client.json` (mode 0600, holds the token). -`LARD_URL` and `LARD_TOKEN` override it. - -`service install` writes a launchd agent that syncs on an interval and survives -reboots. It refuses to install if it cannot reach the server, since a silent -background failure is the worst outcome. Logs go to -`~/Library/Logs/lard-client.log`. Linux is not wired up yet: run -`lard-client daemon` under systemd. - ## Interfaces -MCP (for agents) at `POST /mcp`: `get_context`, `memory_list`, `memory_read`, `memory_write`, `memory_append`, `memory_delete`. add to crush: +MCP at `POST /mcp`: `get_context`, `memory_list`, `memory_read`, `memory_write`, `memory_append`, `memory_delete`. add to crush: ```json { "mcp": { "lard": { "type": "http", - "url": "https://lard.your.domain/mcp" + "url": "https://lard.your.domain/mcp", + "oauth": true } } } @@ -72,6 +52,8 @@ MCP (for agents) at `POST /mcp`: `get_context`, `memory_list`, `memory_read`, `m HTTP ``` +GET /healthz liveness check (no auth) +GET /whoami verify credentials; returns the caller's identity GET /context?project= profile + subject listing + this project's area GET /memory the subject listing GET /memory/{path} a subject's markdown body @@ -88,65 +70,43 @@ paths are `profile`, `areas/`, `topics/`, `people/`. ## Configuration -| var | default | desc | -| --- | --- | --- | -| `LARD_ADDR` | `:7477` | listen address | -| `LARD_DB` | `~/.config/lard/lard.db` | sqlite path (sessions, facts, registry) | -| `LARD_MEMORY_DIR` | `~/.config/lard/memory` | subject files | -| `LARD_AUTH` | `none` | `none` \| `token` \| `bearer` | -| `LARD_TOKEN` | | shared secret for `token` mode | -| `LARD_INDIKO_URL` | `https://indiko.dunkirk.sh` | auth server for `bearer` mode | -| `LARD_PUBLIC_URL` | | lard's external url; goes in the oauth metadata | -| `LARD_OAUTH_CLIENT_IDS` | | comma list of client ids allowed to call lard | -| `LARD_OAUTH_USERS` | | comma list of indiko `me` urls allowed to call lard | -| `LARD_OAUTH_SCOPES` | | comma list of scopes every token must carry | -| `LARD_COLLECTOR_CLIENT_ID` | | oauth client id collectors should use | -| `LARD_COLLECTOR_SCOPES` | `profile` | scopes the collector should request | -| `LARD_CONSOLIDATE_AFTER` | `5m` | quiet period before a pass; `off` to disable | -| `LARD_CONSOLIDATE_MAX_WAIT` | `30m` | cap on that wait during constant uploads | -| `HYPER_API_KEY` | | hyper API key for consolidation | -| `LARD_MODEL` | `deepseek-v4-flash` | consolidation model | - -client env: `LARD_URL`, `LARD_TOKEN` (both override `~/.config/lard/client.json`). - -consolidation is automatic: an ingest starts a quiet timer, and the pass runs -once uploads stop. bursts collapse into one pass, and a machine uploading -continuously still gets consolidated at `LARD_CONSOLIDATE_MAX_WAIT`. +The server reads `~/.config/lard/config.toml` (override with `LARD_CONFIG`). +Every option can also be set as an environment variable; env always wins. +See `config.example.toml` for a ready-to-edit starting point. + +| TOML key | env var | default | desc | +| --- | --- | --- | --- | +| `addr` | `LARD_ADDR` | `:7477` | listen address | +| `db` | `LARD_DB` | `~/.config/lard/lard.db` | sqlite path (sessions, facts, registry) | +| `memory_dir` | `LARD_MEMORY_DIR` | `~/.config/lard/memory` | subject files | +| `llm.base_url` | `LARD_HYPER_BASE_URL` | `https://hyper.charm.land` | OpenAI-compatible endpoint for consolidation | +| `llm.model` | `LARD_MODEL` | `deepseek-v4-flash` | consolidation model | +| `llm.api_key` | `LARD_HYPER_API_KEY` | | API key for consolidation (falls back to `HYPER_API_KEY`) | +| `auth.mode` | `LARD_AUTH` | `none` | `none` \| `token` \| `oauth` | +| `auth.token` | `LARD_TOKEN` | | shared secret for `token` mode | +| `auth.auth_server` | `LARD_AUTH_SERVER` | | authorization server URL for `oauth` mode | +| `auth.public_url` | `LARD_PUBLIC_URL` | | lard's external url; goes in the OAuth metadata | +| `auth.allowed_client_ids` | `LARD_OAUTH_CLIENT_IDS` | | comma list of client ids allowed to call lard | +| `auth.allowed_users` | `LARD_OAUTH_USERS` | | comma list of `me` urls allowed to call lard | +| `auth.required_scopes` | `LARD_OAUTH_SCOPES` | | comma list of scopes every token must carry | +| `collector.client_id` | `LARD_COLLECTOR_CLIENT_ID` | | OAuth client id collectors should use | +| `collector.scopes` | `LARD_COLLECTOR_SCOPES` | `profile` | scopes the collector should request | +| `consolidate.after` | `LARD_CONSOLIDATE_AFTER` | `5m` | quiet period before a pass; `off` to disable | +| `consolidate.max_wait` | `LARD_CONSOLIDATE_MAX_WAIT` | `30m` | cap on that wait during constant uploads | ## auth -`token` mode is a shared secret; good enough for the collector cron. - -`bearer` mode makes lard an oauth 2.1 protected resource in front of -[indiko](https://indiko.dunkirk.sh). it serves - -- `/.well-known/oauth-protected-resource` (rfc 9728) naming indiko as the - authorization server -- `/.well-known/oauth-authorization-server` as a redirect to indiko, so older - mcp clients still discover it. a redirect rather than a proxy because clients - check that the issuer matches where they fetched the document - -a `401` carries `WWW-Authenticate` with `resource_metadata`, which is enough for -an mcp client to find indiko and start the pkce flow on its own. - -set `LARD_OAUTH_CLIENT_IDS` or `LARD_OAUTH_USERS`. indiko mints tokens for every -app you sign into, so without an allowlist any one of them can read all your -memory. lard warns at boot if you skip it. - -### collector registration - -a collector cannot invent its own client id: the auth server decides which -clients exist, and lard decides which it trusts, so a guessed id gets rejected. -set `LARD_COLLECTOR_CLIENT_ID` to a client id registered with your auth server -and lard publishes it at `/auth/collector`; `lard-client login` adopts it. that -id is then trusted automatically, so it does not also need to be in -`LARD_OAUTH_CLIENT_IDS`. +`token` mode is a shared secret,`oauth` mode makes lard an OAuth 2.1 protected resource in front of any authorization server that supports introspection ([rfc 7662]) and serves OAuth metadata ([rfc 8414]). lard serves +- `/.well-known/oauth-protected-resource` (rfc 9728) naming the authorization + server +- `/.well-known/oauth-authorization-server` as a redirect to the authorization + server, so older mcp clients still discover it. a redirect rather than a + proxy because clients check that the issuer matches where they fetched the + document ### login (device grant) -the only login flow is the oauth device authorization grant ([rfc 8628]), run -against the authorization server directly — lard is not involved beyond handing -the collector its client id: +the only login flow is the OAuth device authorization grant ([rfc 8628]): ``` POST {as}/auth/device client gets a device code + user code + url @@ -154,45 +114,11 @@ GET {as}/device?code=XXXX-XXXX user approves, from any browser anywhere POST {as}/auth/token client polls until the token appears ``` -no listener, no port forward, and no browser on the collector's machine, so -ssh, containers, and headless boxes all work the same way. no client secret -either: the device code itself is the proof of possession, so the collector is -an ordinary public client and there is nothing sensitive to ship. - -requirements on the provider: it must serve rfc 8414 metadata advertising -`device_authorization_endpoint` and support the device grant (indiko does). -login fails with a clear message otherwise. - -with no collector registration configured (`LARD_COLLECTOR_CLIENT_ID` unset), -`lard-client login` fails and tells you to set it. +requirements on the provider: it must serve rfc 8414 metadata advertising `device_authorization_endpoint` and support the device grant. [rfc 8628]: https://datatracker.ietf.org/doc/html/rfc8628 - -### indiko notes - -indiko has no dynamic client registration, so mcp clients that insist on -`POST /register` will not connect. use a client that accepts a configured -client id. indiko also rejects a client id whose host differs from the redirect -uri host unless that url publishes `redirect_uris` metadata, so the simplest -working setup is a localhost client id matching a pinned callback port. - -for crush, in `crush.json`: - -```json -{ - "mcp": { - "lard": { - "type": "http", - "url": "http://127.0.0.1:7477/mcp", - "oauth_client_id": "http://localhost:40704/", - "oauth_callback_port": 40704 - } - } -} -``` - -then run lard with `LARD_AUTH=bearer`, `LARD_PUBLIC_URL` set to the same origin -crush dials, and `LARD_OAUTH_CLIENT_IDS=http://localhost:40704/`. +[rfc 7662]: https://datatracker.ietf.org/doc/html/rfc7662 +[rfc 8414]: https://datatracker.ietf.org/doc/html/rfc8414

diff --git a/cmd/lard-client/main.go b/cmd/lard-client/main.go index 1dfc7ee..7f27a3e 100644 --- a/cmd/lard-client/main.go +++ b/cmd/lard-client/main.go @@ -342,7 +342,7 @@ func uploader(ctx context.Context) (*client.Uploader, error) { if err != nil { return nil, err } - tok, err := cfg.Bearer(ctx, path) + tok, err := cfg.BearerToken(ctx, path) if err != nil { return nil, err } diff --git a/cmd/lard/main.go b/cmd/lard/main.go index be47e3c..94a992a 100644 --- a/cmd/lard/main.go +++ b/cmd/lard/main.go @@ -70,7 +70,7 @@ func run() error { // The LLM client is optional at boot: the API works without it, only // /consolidate refuses. var llmClient *llm.Client - if c, err := llm.NewFromEnv(ctx); err != nil { + if c, err := llm.New(ctx, cfg.LLM); err != nil { slog.Warn("no LLM client; /consolidate disabled", "reason", err) } else { llmClient = c @@ -88,12 +88,12 @@ func run() error { authCfg := auth.Config{ Mode: auth.Mode(cfg.Auth.Mode), Token: cfg.Auth.Token, - IndikoURL: cfg.Auth.IndikoURL, + AuthServerURL: cfg.Auth.AuthServerURL, PublicURL: cfg.Auth.PublicURL, AllowedClientIDs: cfg.Auth.AllowedClientIDs, AllowedUsers: cfg.Auth.AllowedUsers, RequiredScopes: cfg.Auth.RequiredScopes, - CollectorClientID: cfg.Auth.CollectorClientID, + CollectorClientID: cfg.Collector.ClientID, } // The collector registration: which OAuth client edge collectors adopt. diff --git a/config.example.toml b/config.example.toml index ad5f536..5806b0e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,35 +1,35 @@ # Lard server configuration -# Copy this to ~/.config/lard/config.toml and edit as needed +# Copy this to ~/.config/lard/config.toml and edit as needed. +# Env vars always win over this file. # Server settings addr = ":7477" -db = "" # defaults to ~/.config/lard/lard.db +db = "" # defaults to ~/.config/lard/lard.db memory_dir = "" # defaults to ~/.config/lard/memory -# LLM client (for /consolidate endpoint) +# Consolidation model (any OpenAI-compatible endpoint; Hyper is the default) [llm] -base_url = "https://api.openai.com/v1" -model = "gpt-4o-mini" -api_key = "" # or set OPENAI_API_KEY env var +base_url = "" # defaults to https://hyper.charm.land +model = "" # defaults to deepseek-v4-flash +api_key = "" # or set LARD_HYPER_API_KEY / HYPER_API_KEY api_version = "" # Authentication [auth] -mode = "none" # none, token, or bearer -token = "" # static token for token mode -indiko_url = "https://indiko.dunkirk.sh" -public_url = "" # your public URL for OAuth redirects +mode = "none" # none, token, or oauth +token = "" # shared secret for token mode +auth_server = "" # authorization server URL for oauth mode +public_url = "" # lard's external URL; goes in the OAuth metadata allowed_client_ids = [] # OAuth client IDs allowed to access -allowed_users = [] # user emails/IDs allowed to access -required_scopes = [] # required OAuth scopes -collector_client_id = "" # collector's OAuth client ID +allowed_users = [] # user identity URLs allowed to access +required_scopes = [] # scopes every token must carry # Collector registration (published to edge collectors) [collector] -client_id = "" # the OAuth client ID collectors should use -scopes = ["read", "write"] # scopes granted to collectors +client_id = "" # the OAuth client ID collectors should use +scopes = ["profile"] # scopes the collector should request # Auto-consolidation (runs when uploads go quiet) [consolidate] -after = "5m" # wait this long after last upload before consolidating -max_wait = "30m" # but consolidate at least this often +after = "5m" # wait this long after last upload before consolidating +max_wait = "30m" # but consolidate at least this often diff --git a/go.mod b/go.mod index c64c9e9..085e52b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/x/exp/charmtone v0.0.0-20260726004341-482a56510f1b github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 golang.org/x/oauth2 v0.36.0 @@ -52,7 +53,6 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/openai/openai-go/v3 v3.43.0 // indirect - github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a159aae..9334b80 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,16 +1,17 @@ // Package auth gates lard's HTTP and MCP surfaces. Two modes: // -// - bearer: lard is an OAuth 2.1 protected resource. Access tokens are -// validated against indiko's introspection endpoint (RFC 7662), and lard -// publishes protected-resource metadata (RFC 9728) so MCP clients can -// discover indiko as the authorization server. +// - oauth: lard is an OAuth 2.1 protected resource. Access tokens are +// validated against the authorization server's introspection endpoint +// (RFC 7662), and lard publishes protected-resource metadata (RFC 9728) +// so MCP clients can discover the authorization server. // - token: a single shared secret (LARD_TOKEN) for the collector path when // the full OAuth dance is overkill (e.g. a homelab cron). // -// Indiko issues tokens for every app the user authorizes, so lard must check -// that a token was actually minted for lard. Without that check any app the -// user has ever signed into could read their whole memory (the "confused -// deputy" problem). Set an audience or user allowlist to close it. +// The authorization server issues tokens for every app the user authorizes, +// so lard must check that a token was actually minted for lard. Without that +// check any app the user has ever signed into could read their whole memory +// (the "confused deputy" problem). Set an audience or user allowlist to +// close it. package auth import ( @@ -33,13 +34,13 @@ import ( type Mode string const ( - ModeNone Mode = "none" - ModeBearer Mode = "bearer" // indiko OAuth - ModeToken Mode = "token" // shared secret + ModeNone Mode = "none" + ModeOAuth Mode = "oauth" // OAuth 2.1 protected resource + ModeToken Mode = "token" // shared secret ) // Discovery paths lard serves. The authorization-server path exists only to -// point 2025-03-26-era MCP clients at indiko; newer clients use the +// point 2025-03-26-era MCP clients at the authorization server; newer clients // protected-resource document. const ( PathProtectedResource = "/.well-known/oauth-protected-resource" @@ -54,18 +55,18 @@ type Config struct { Mode Mode // Token is the shared secret for ModeToken. Token string - // IndikoURL is the indiko base URL (https://indiko.dunkirk.sh) for ModeBearer. - IndikoURL string + // AuthServerURL is the authorization server's base URL for ModeOAuth. + AuthServerURL string // PublicURL is lard's own externally reachable base URL. It is the // resource identifier in the protected-resource metadata, so clients know - // which resource they are asking indiko for a token for. + // which resource they are asking the authorization server for a token for. PublicURL string // AllowedClientIDs limits which OAuth clients may call lard, matched // against the client_id on the introspected token. Empty means any client // the user has authorized is accepted. AllowedClientIDs []string - // AllowedUsers limits which indiko identities may call lard, matched - // against the token's "me" URL. Empty means any user is accepted. + // AllowedUsers limits which identities may call lard, matched against + // the token's "me" URL. Empty means any user is accepted. AllowedUsers []string // RequiredScopes are scopes every token must carry. Empty means none. RequiredScopes []string @@ -90,21 +91,21 @@ func (c Config) clientAllowlist() []string { return append(append([]string{}, c.AllowedClientIDs...), c.CollectorClientID) } -// Validate reports configuration problems worth logging at boot. Bearer mode +// Validate reports configuration problems worth logging at boot. OAuth mode // with no allowlist works, but it trusts every app the user has authorized. func (c Config) Validate() []string { var warns []string - if c.Mode != ModeBearer { + if c.Mode != ModeOAuth { return nil } - if c.IndikoURL == "" { - warns = append(warns, "bearer auth has no indiko URL; every request will be rejected") + if c.AuthServerURL == "" { + warns = append(warns, "oauth auth has no authorization server URL; every request will be rejected") } if c.PublicURL == "" { - warns = append(warns, "bearer auth has no LARD_PUBLIC_URL; OAuth discovery metadata will be incomplete") + warns = append(warns, "oauth auth has no public_url; OAuth discovery metadata will be incomplete") } if len(c.clientAllowlist()) == 0 && len(c.AllowedUsers) == 0 { - warns = append(warns, "bearer auth has no audience restriction: any app the user authorized with indiko can read all memory (set LARD_OAUTH_CLIENT_IDS or LARD_OAUTH_USERS)") + warns = append(warns, "oauth auth has no audience restriction: any app the user authorized with the same provider can read all memory (set allowed_client_ids or allowed_users)") } return warns } @@ -112,7 +113,7 @@ func (c Config) Validate() []string { // Identity is who the caller turned out to be. It rides on the request // context so handlers can attribute writes later. type Identity struct { - Subject string // indiko "me" URL + Subject string // authorization server "me" URL ClientID string Scopes []string } @@ -147,7 +148,7 @@ func Middleware(cfg Config, next http.Handler) http.Handler { return } next.ServeHTTP(w, r) - case ModeBearer: + case ModeOAuth: id, status, code, desc := v.authorize(r) if status != 0 { writeChallenge(w, r, cfg, status, code, desc) @@ -162,7 +163,7 @@ func Middleware(cfg Config, next http.Handler) http.Handler { // writeChallenge emits an RFC 6750 challenge. The resource_metadata parameter // (RFC 9728) is what lets an MCP client bootstrap the OAuth flow from a bare -// 401, without knowing anything about indiko up front. It names the metadata +// 401, without knowing anything about the authorization server up front. It names the metadata // document for the exact path being refused, so a client hitting /mcp is sent // to the /mcp-suffixed document. func writeChallenge(w http.ResponseWriter, r *http.Request, cfg Config, status int, code, desc string) { @@ -236,14 +237,14 @@ func (i introspection) subject() string { return i.Subject } -// authorize validates the bearer token and the claims on it. A zero status +// authorize validates the access token and the claims on it. A zero status // means the request may proceed; otherwise status/code/desc describe the // refusal (401 for a bad token, 403 for a token that is valid but not for us). func (v *verifier) authorize(r *http.Request) (id Identity, status int, code, desc string) { header := r.Header.Get("Authorization") tok := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) if tok == "" || tok == header { - return id, http.StatusUnauthorized, "invalid_request", "missing bearer token" + return id, http.StatusUnauthorized, "invalid_request", "missing access token" } res, ok := v.introspectCached(r.Context(), tok) if !ok || !res.Active { @@ -274,7 +275,7 @@ func (v *verifier) authorize(r *http.Request) (id Identity, status int, code, de // allowed reports whether v is in the list, treating an empty list as // unrestricted. Client IDs and identity URLs are compared with trailing -// slashes normalized away, since indiko and clients disagree about them. +// slashes normalized away, since providers and clients disagree about them. func allowed(list []string, v string) bool { if len(list) == 0 { return true @@ -294,7 +295,7 @@ func hasScope(scopes []string, want string) bool { // introspectCached memoizes introspection results, keyed by a token digest so // the raw secret is not a map key. Negative results are cached briefly too, so -// a client retrying with a dead token cannot hammer indiko. +// a client retrying with a dead token cannot hammer the provider. func (v *verifier) introspectCached(ctx context.Context, tok string) (introspection, bool) { sum := sha256.Sum256([]byte(tok)) key := hex.EncodeToString(sum[:]) @@ -326,16 +327,17 @@ func (v *verifier) introspectCached(ctx context.Context, tok string) (introspect return res, true } -// introspect asks indiko whether the token is live. Fail closed. +// introspect asks the authorization server whether the token is live. Fail +// closed. func (v *verifier) introspect(ctx context.Context, tok string) (introspection, bool) { - if v.cfg.IndikoURL == "" { + if v.cfg.AuthServerURL == "" { return introspection{}, false } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() form := url.Values{"token": {tok}} req, err := http.NewRequestWithContext(ctx, http.MethodPost, - strings.TrimRight(v.cfg.IndikoURL, "/")+"/auth/token/introspect", + strings.TrimRight(v.cfg.AuthServerURL, "/")+"/auth/token/introspect", strings.NewReader(form.Encode())) if err != nil { return introspection{}, false @@ -361,7 +363,7 @@ func (v *verifier) introspect(ctx context.Context, tok string) (introspection, b } // ProtectedResourceMetadata serves the RFC 9728 document describing lard as a -// protected resource and indiko as its authorization server. This is the +// protected resource and naming its authorization server. This is the // discovery entry point for MCP clients: they read it after a 401 and know // where to send the user. // @@ -373,7 +375,7 @@ func (v *verifier) introspect(ctx context.Context, tok string) (introspection, b // whichever resource was requested. func ProtectedResourceMetadata(cfg Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if cfg.Mode != ModeBearer || cfg.IndikoURL == "" { + if cfg.Mode != ModeOAuth || cfg.AuthServerURL == "" { http.NotFound(w, r) return } @@ -384,7 +386,7 @@ func ProtectedResourceMetadata(cfg Config) http.HandlerFunc { suffix := strings.TrimPrefix(r.URL.Path, PathProtectedResource) doc := map[string]any{ "resource": base + suffix, - "authorization_servers": []string{strings.TrimRight(cfg.IndikoURL, "/")}, + "authorization_servers": []string{strings.TrimRight(cfg.AuthServerURL, "/")}, "bearer_methods_supported": []string{"header"}, "resource_documentation": "https://github.com/taciturnaxolotl/lard", } @@ -397,17 +399,18 @@ func ProtectedResourceMetadata(cfg Config) http.HandlerFunc { } } -// AuthServerMetadata redirects to indiko's authorization-server metadata. +// AuthServerMetadata redirects to the authorization server's metadata. // Proxying the body would be simpler but breaks clients: RFC 8414 makes them // check that the issuer in the document matches where they fetched it from, -// and indiko's issuer is indiko, not lard. A redirect keeps that invariant. +// and the AS's issuer is its own origin, not lard. A redirect keeps that +// invariant. func AuthServerMetadata(cfg Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if cfg.Mode != ModeBearer || cfg.IndikoURL == "" { + if cfg.Mode != ModeOAuth || cfg.AuthServerURL == "" { http.NotFound(w, r) return } - http.Redirect(w, r, strings.TrimRight(cfg.IndikoURL, "/")+PathAuthServer, http.StatusFound) + http.Redirect(w, r, strings.TrimRight(cfg.AuthServerURL, "/")+PathAuthServer, http.StatusFound) } } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 0cca1d4..7561237 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -8,8 +8,8 @@ import ( "testing" ) -// fakeIndiko stands in for indiko's introspection endpoint. -func fakeIndiko(t *testing.T, res map[string]any) *httptest.Server { +// fakeAuthServer stands in for the authorization server's introspection endpoint. +func fakeAuthServer(t *testing.T, res map[string]any) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/auth/token/introspect" { @@ -30,11 +30,11 @@ func fakeIndiko(t *testing.T, res map[string]any) *httptest.Server { return srv } -func bearerConfig(indiko string) Config { +func oauthConfig(authServer string) Config { return Config{ - Mode: ModeBearer, - IndikoURL: indiko, - PublicURL: "https://lard.example.com", + Mode: ModeOAuth, + AuthServerURL: authServer, + PublicURL: "https://lard.example.com", } } @@ -58,24 +58,24 @@ func okHandler() http.Handler { }) } -func TestBearerAcceptsActiveToken(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", +func TestOAuthAcceptsActiveToken(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "https://app.example.com", "scope": "profile email", }) - h := Middleware(bearerConfig(srv.URL), okHandler()) + h := Middleware(oauthConfig(srv.URL), okHandler()) w := do(h, "Bearer good") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } - if got := w.Header().Get("X-Subject"); got != "https://indiko.example.com/u/kieran" { + if got := w.Header().Get("X-Subject"); got != "https://auth.example.com/u/kieran" { t.Errorf("identity not propagated: %q", got) } } -func TestBearerRejectsInactiveToken(t *testing.T) { - srv := fakeIndiko(t, map[string]any{"active": true}) - h := Middleware(bearerConfig(srv.URL), okHandler()) +func TestOAuthRejectsInactiveToken(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{"active": true}) + h := Middleware(oauthConfig(srv.URL), okHandler()) w := do(h, "Bearer nope") if w.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d", w.Code) @@ -87,22 +87,23 @@ func TestBearerRejectsInactiveToken(t *testing.T) { } } -func TestBearerRejectsMissingHeader(t *testing.T) { - srv := fakeIndiko(t, map[string]any{"active": true}) - h := Middleware(bearerConfig(srv.URL), okHandler()) +func TestOAuthRejectsMissingHeader(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{"active": true}) + h := Middleware(oauthConfig(srv.URL), okHandler()) if w := do(h, ""); w.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d", w.Code) } } // A token minted for a different app must not open lard. This is the confused -// deputy case: indiko happily issues tokens for every app the user authorizes. -func TestBearerRejectsForeignClient(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", +// deputy case: the provider happily issues tokens for every app the user +// authorizes. +func TestOAuthRejectsForeignClient(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "https://someone-elses-app.example.com", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.AllowedClientIDs = []string{"https://lard.example.com/"} h := Middleware(cfg, okHandler()) w := do(h, "Bearer good") @@ -111,12 +112,12 @@ func TestBearerRejectsForeignClient(t *testing.T) { } } -func TestBearerAllowsListedClientIgnoringTrailingSlash(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", +func TestOAuthAllowsListedClientIgnoringTrailingSlash(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "https://lard.example.com", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.AllowedClientIDs = []string{"https://lard.example.com/"} h := Middleware(cfg, okHandler()) if w := do(h, "Bearer good"); w.Code != http.StatusOK { @@ -124,25 +125,25 @@ func TestBearerAllowsListedClientIgnoringTrailingSlash(t *testing.T) { } } -func TestBearerRejectsUnlistedUser(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/stranger", +func TestOAuthRejectsUnlistedUser(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/stranger", "client_id": "https://lard.example.com", "scope": "profile", }) - cfg := bearerConfig(srv.URL) - cfg.AllowedUsers = []string{"https://indiko.example.com/u/kieran"} + cfg := oauthConfig(srv.URL) + cfg.AllowedUsers = []string{"https://auth.example.com/u/kieran"} h := Middleware(cfg, okHandler()) if w := do(h, "Bearer good"); w.Code != http.StatusForbidden { t.Fatalf("want 403, got %d", w.Code) } } -func TestBearerRejectsInsufficientScope(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", +func TestOAuthRejectsInsufficientScope(t *testing.T) { + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "https://lard.example.com", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.RequiredScopes = []string{"email"} h := Middleware(cfg, okHandler()) w := do(h, "Bearer good") @@ -156,8 +157,8 @@ func TestBearerRejectsInsufficientScope(t *testing.T) { // Discovery must work unauthenticated, otherwise a client cannot bootstrap. func TestDiscoveryIsUnauthenticated(t *testing.T) { - srv := fakeIndiko(t, map[string]any{"active": true}) - cfg := bearerConfig(srv.URL) + srv := fakeAuthServer(t, map[string]any{"active": true}) + cfg := oauthConfig(srv.URL) mux := http.NewServeMux() mux.Handle(PathProtectedResource, ProtectedResourceMetadata(cfg)) mux.Handle(PathProtectedResource+"/", ProtectedResourceMetadata(cfg)) @@ -199,10 +200,10 @@ func TestDiscoveryIsUnauthenticated(t *testing.T) { } // A 401 on /mcp must name the /mcp metadata document, not the base one, or the -// client will ask indiko for a token scoped to the wrong resource. +// client will ask the provider for a token scoped to the wrong resource. func TestChallengeOnMCPNamesMCPResource(t *testing.T) { - srv := fakeIndiko(t, map[string]any{"active": true}) - h := Middleware(bearerConfig(srv.URL), okHandler()) + srv := fakeAuthServer(t, map[string]any{"active": true}) + h := Middleware(oauthConfig(srv.URL), okHandler()) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/mcp", nil)) if w.Code != http.StatusUnauthorized { @@ -217,19 +218,19 @@ func TestChallengeOnMCPNamesMCPResource(t *testing.T) { // Redirecting instead of proxying keeps the issuer consistent with the URL the // client fetched, which RFC 8414 requires clients to verify. func TestAuthServerMetadataRedirects(t *testing.T) { - cfg := bearerConfig("https://indiko.example.com") + cfg := oauthConfig("https://auth.example.com") w := httptest.NewRecorder() AuthServerMetadata(cfg)(w, httptest.NewRequest(http.MethodGet, PathAuthServer, nil)) if w.Code != http.StatusFound { t.Fatalf("want 302, got %d", w.Code) } - want := "https://indiko.example.com" + PathAuthServer + want := "https://auth.example.com" + PathAuthServer if got := w.Header().Get("Location"); got != want { t.Errorf("Location = %q, want %q", got, want) } } -func TestDiscoveryHiddenWhenNotBearerMode(t *testing.T) { +func TestDiscoveryHiddenWhenNotOAuthMode(t *testing.T) { cfg := Config{Mode: ModeNone} w := httptest.NewRecorder() ProtectedResourceMetadata(cfg)(w, httptest.NewRequest(http.MethodGet, PathProtectedResource, nil)) @@ -260,8 +261,8 @@ func TestSharedSecretModeRejectsEmptyConfig(t *testing.T) { } // Introspection failures must fail closed, not open. -func TestBearerFailsClosedWhenIndikoUnreachable(t *testing.T) { - cfg := bearerConfig("http://127.0.0.1:1") +func TestOAuthFailsClosedWhenProviderUnreachable(t *testing.T) { + cfg := oauthConfig("http://127.0.0.1:1") h := Middleware(cfg, okHandler()) if w := do(h, "Bearer good"); w.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d", w.Code) @@ -269,11 +270,11 @@ func TestBearerFailsClosedWhenIndikoUnreachable(t *testing.T) { } func TestValidateWarnsAboutMissingAudience(t *testing.T) { - warns := Config{Mode: ModeBearer, IndikoURL: "https://i", PublicURL: "https://l"}.Validate() + warns := Config{Mode: ModeOAuth, AuthServerURL: "https://i", PublicURL: "https://l"}.Validate() if len(warns) != 1 || !strings.Contains(warns[0], "audience") { t.Fatalf("want audience warning, got %v", warns) } - cfg := Config{Mode: ModeBearer, IndikoURL: "https://i", PublicURL: "https://l", AllowedUsers: []string{"me"}} + cfg := Config{Mode: ModeOAuth, AuthServerURL: "https://i", PublicURL: "https://l", AllowedUsers: []string{"me"}} if warns := cfg.Validate(); len(warns) != 0 { t.Fatalf("want no warnings, got %v", warns) } @@ -292,11 +293,11 @@ func TestHealthzBypassesAuth(t *testing.T) { // operator also repeating it in the allowlist. Publishing an identity and then // rejecting it is the failure this guards. func TestCollectorClientIDIsTrustedImplicitly(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "ikc_collector", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.CollectorClientID = "ikc_collector" h := Middleware(cfg, okHandler()) if w := do(h, "Bearer good"); w.Code != http.StatusOK { @@ -307,11 +308,11 @@ func TestCollectorClientIDIsTrustedImplicitly(t *testing.T) { // An explicit collector id is itself a restriction, so it must not widen access // to every client the user has ever authorized. func TestCollectorClientIDNarrowsWhenNoOtherAllowlist(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "https://some-other-app.example.com/", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.CollectorClientID = "ikc_collector" h := Middleware(cfg, okHandler()) if w := do(h, "Bearer good"); w.Code != http.StatusForbidden { @@ -320,11 +321,11 @@ func TestCollectorClientIDNarrowsWhenNoOtherAllowlist(t *testing.T) { } func TestCollectorClientIDAddsToExistingAllowlist(t *testing.T) { - srv := fakeIndiko(t, map[string]any{ - "active": true, "me": "https://indiko.example.com/u/kieran", + srv := fakeAuthServer(t, map[string]any{ + "active": true, "me": "https://auth.example.com/u/kieran", "client_id": "ikc_mcp", "scope": "profile", }) - cfg := bearerConfig(srv.URL) + cfg := oauthConfig(srv.URL) cfg.AllowedClientIDs = []string{"ikc_mcp"} cfg.CollectorClientID = "ikc_collector" h := Middleware(cfg, okHandler()) @@ -336,7 +337,7 @@ func TestCollectorClientIDAddsToExistingAllowlist(t *testing.T) { // Setting only a collector id counts as an audience restriction, so the // "anyone can read your memory" warning must not fire. func TestValidateAcceptsCollectorIDAsAudience(t *testing.T) { - cfg := Config{Mode: ModeBearer, IndikoURL: "https://i", PublicURL: "https://l", CollectorClientID: "ikc_x"} + cfg := Config{Mode: ModeOAuth, AuthServerURL: "https://i", PublicURL: "https://l", CollectorClientID: "ikc_x"} if warns := cfg.Validate(); len(warns) != 0 { t.Fatalf("want no warnings, got %v", warns) } diff --git a/internal/client/config.go b/internal/client/config.go index 24f0309..8747778 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -27,7 +27,7 @@ type Config struct { // or a headless box where no browser is available. Token string `json:"token,omitempty"` // OAuth holds the browser-login credentials, used when the server runs - // LARD_AUTH=bearer. Preferred over Token: nothing to copy by hand, and it + // LARD_AUTH=oauth. Preferred over Token: nothing to copy by hand, and it // carries the same identity as the rest of the user's tooling. OAuth *OAuthToken `json:"oauth,omitempty"` } @@ -113,7 +113,7 @@ func (c *Config) Verify(ctx context.Context) (string, error) { if err != nil { return "", err } - if tok, err := c.Bearer(ctx, DefaultConfigPath()); err == nil && tok != "" { + if tok, err := c.BearerToken(ctx, DefaultConfigPath()); err == nil && tok != "" { req.Header.Set("authorization", "Bearer "+tok) } resp, err := http.DefaultClient.Do(req) @@ -143,14 +143,14 @@ func (c *Config) Verify(ctx context.Context) (string, error) { return body.ClientID, nil } -// Bearer returns the token to send, refreshing an expired OAuth access token -// and persisting the new one. It is the single place that decides between -// OAuth and a static secret, so callers never branch on auth mode. +// BearerToken returns the token to send, refreshing an expired OAuth access +// token and persisting the new one. It is the single place that decides +// between OAuth and a static secret, so callers never branch on auth mode. // // A refresh failure is fatal by design: silently falling back to no // credentials would turn an expired login into a stream of 401s in a log file // nobody reads. -func (c *Config) Bearer(ctx context.Context, path string) (string, error) { +func (c *Config) BearerToken(ctx context.Context, path string) (string, error) { if c.OAuth != nil && c.OAuth.AccessToken != "" { if !c.OAuth.expired() { return c.OAuth.AccessToken, nil @@ -161,7 +161,8 @@ func (c *Config) Bearer(ctx context.Context, path string) (string, error) { } c.OAuth.AccessToken = tok.AccessToken c.OAuth.Expiry = tok.Expiry - // Indiko does not rotate refresh tokens, but honor one if it appears. + // Providers do not always rotate refresh tokens, but honor one if it + // appears. if tok.RefreshToken != "" { c.OAuth.RefreshToken = tok.RefreshToken } diff --git a/internal/config/config.go b/internal/config/config.go index be44680..d537c7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,24 +22,24 @@ type Server struct { Consolidate Consolidate `toml:"consolidate"` } -// LLM holds OpenAI-compatible client settings. +// LLM holds the consolidation model's settings. Any OpenAI-compatible +// endpoint works; Hyper (hyper.charm.land) is the default. type LLM struct { - BaseURL string `toml:"base_url" env:"OPENAI_BASE_URL"` - Model string `toml:"model" env:"OPENAI_MODEL"` - APIKey string `toml:"api_key" env:"OPENAI_API_KEY"` + BaseURL string `toml:"base_url" env:"LARD_HYPER_BASE_URL"` + Model string `toml:"model" env:"LARD_MODEL"` + APIKey string `toml:"api_key" env:"LARD_HYPER_API_KEY"` APIVersion string `toml:"api_version" env:"OPENAI_API_VERSION"` } // Auth holds authentication configuration. type Auth struct { - Mode string `toml:"mode" env:"LARD_AUTH" default:"none"` - Token string `toml:"token" env:"LARD_TOKEN"` - IndikoURL string `toml:"indiko_url" env:"LARD_INDIKO_URL" default:"https://indiko.dunkirk.sh"` - PublicURL string `toml:"public_url" env:"LARD_PUBLIC_URL"` - AllowedClientIDs []string `toml:"allowed_client_ids" env:"LARD_OAUTH_CLIENT_IDS"` - AllowedUsers []string `toml:"allowed_users" env:"LARD_OAUTH_USERS"` - RequiredScopes []string `toml:"required_scopes" env:"LARD_OAUTH_SCOPES"` - CollectorClientID string `toml:"collector_client_id" env:"LARD_COLLECTOR_CLIENT_ID"` + Mode string `toml:"mode" env:"LARD_AUTH" default:"none"` + Token string `toml:"token" env:"LARD_TOKEN"` + AuthServerURL string `toml:"auth_server" env:"LARD_AUTH_SERVER"` + PublicURL string `toml:"public_url" env:"LARD_PUBLIC_URL"` + AllowedClientIDs []string `toml:"allowed_client_ids" env:"LARD_OAUTH_CLIENT_IDS"` + AllowedUsers []string `toml:"allowed_users" env:"LARD_OAUTH_USERS"` + RequiredScopes []string `toml:"required_scopes" env:"LARD_OAUTH_SCOPES"` } // Collector holds the collector OAuth registration. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cfd750c..d64fea5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,13 +22,12 @@ model = "gpt-4" api_key = "sk-test123" [auth] -mode = "bearer" -indiko_url = "https://indiko.example.com" +mode = "oauth" +auth_server = "https://auth.example.com" public_url = "https://lard.example.com" allowed_client_ids = ["client1", "client2"] allowed_users = ["user@example.com"] required_scopes = ["profile", "email"] -collector_client_id = "collector-abc" [collector] client_id = "collector-abc" @@ -56,8 +55,8 @@ max_wait = "1h" if cfg.LLM.Model != "gpt-4" { t.Errorf("expected model gpt-4, got %s", cfg.LLM.Model) } - if cfg.Auth.Mode != "bearer" { - t.Errorf("expected mode bearer, got %s", cfg.Auth.Mode) + if cfg.Auth.Mode != "oauth" { + t.Errorf("expected mode oauth, got %s", cfg.Auth.Mode) } if len(cfg.Auth.AllowedClientIDs) != 2 { t.Errorf("expected 2 allowed client IDs, got %d", len(cfg.Auth.AllowedClientIDs)) @@ -95,8 +94,8 @@ func TestDefaults(t *testing.T) { if cfg.Auth.Mode != "none" { t.Errorf("expected default auth mode none, got %s", cfg.Auth.Mode) } - if cfg.Auth.IndikoURL != "https://indiko.dunkirk.sh" { - t.Errorf("expected default indiko URL, got %s", cfg.Auth.IndikoURL) + if cfg.Auth.AuthServerURL != "" { + t.Errorf("expected empty auth server URL default, got %s", cfg.Auth.AuthServerURL) } } @@ -134,8 +133,8 @@ func TestSplitList(t *testing.T) { func clearLARDEnv() { envVars := []string{ "LARD_ADDR", "LARD_DB", "LARD_MEMORY_DIR", - "OPENAI_BASE_URL", "OPENAI_MODEL", "OPENAI_API_KEY", "OPENAI_API_VERSION", - "LARD_AUTH", "LARD_TOKEN", "LARD_INDIKO_URL", "LARD_PUBLIC_URL", + "LARD_HYPER_BASE_URL", "LARD_MODEL", "LARD_HYPER_API_KEY", "OPENAI_API_VERSION", + "LARD_AUTH", "LARD_TOKEN", "LARD_AUTH_SERVER", "LARD_PUBLIC_URL", "LARD_OAUTH_CLIENT_IDS", "LARD_OAUTH_USERS", "LARD_OAUTH_SCOPES", "LARD_COLLECTOR_CLIENT_ID", "LARD_COLLECTOR_SCOPES", "LARD_CONSOLIDATE_AFTER", "LARD_CONSOLIDATE_MAX_WAIT", diff --git a/internal/llm/llm.go b/internal/llm/llm.go index aac4c31..f2231f6 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -1,5 +1,5 @@ -// Package llm is the consolidator's model client, built on fantasy with -// Hyper (hyper.charm.land) as the provider. This is a cheap-but-capable- +// Package llm is the consolidator's model client, built on fantasy with an +// OpenAI-compatible endpoint (Hyper by default). This is a cheap-but-capable- // model job (deepseek-v4-flash by default), not a frontier one. package llm @@ -15,9 +15,11 @@ import ( "charm.land/fantasy" "charm.land/fantasy/providers/openaicompat" + + "github.com/taciturnaxolotl/lard/internal/config" ) -const defaultHyperBaseURL = "https://hyper.charm.land" +const defaultBaseURL = "https://hyper.charm.land" const defaultModel = "deepseek-v4-flash" // Rate-limit retry budget. Hyper's throttle asks for "a few minutes", so the @@ -33,32 +35,31 @@ type Client struct { model fantasy.LanguageModel } -// NewFromEnv builds a client from the environment: -// -// LARD_HYPER_API_KEY (or HYPER_API_KEY) — required; a static hyper API key. -// LARD_MODEL — default deepseek-v4-flash -// LARD_HYPER_BASE_URL — default https://hyper.charm.land -// -// Hyper's OpenAI-compatible endpoint drives the model, same as crush. -func NewFromEnv(ctx context.Context) (*Client, error) { - key := os.Getenv("LARD_HYPER_API_KEY") +// New builds a client from the server config. The API key falls back to the +// HYPER_API_KEY environment variable for convenience. +func New(ctx context.Context, cfg config.LLM) (*Client, error) { + key := cfg.APIKey if key == "" { key = os.Getenv("HYPER_API_KEY") } if key == "" { - return nil, fmt.Errorf("no hyper API key: set LARD_HYPER_API_KEY or HYPER_API_KEY") + return nil, fmt.Errorf("no API key: set llm.api_key in config or LARD_HYPER_API_KEY / HYPER_API_KEY") } - modelID := os.Getenv("LARD_MODEL") + modelID := cfg.Model if modelID == "" { modelID = defaultModel } + base := strings.TrimRight(cfg.BaseURL, "/") + if base == "" { + base = defaultBaseURL + } provider, err := openaicompat.New( openaicompat.WithName("hyper"), openaicompat.WithAPIKey(key), - openaicompat.WithBaseURL(hyperBaseURL()+"/v1"), + openaicompat.WithBaseURL(base+"/v1"), ) if err != nil { - return nil, fmt.Errorf("hyper provider: %w", err) + return nil, fmt.Errorf("llm provider: %w", err) } model, err := provider.LanguageModel(ctx, modelID) if err != nil { @@ -67,14 +68,6 @@ func NewFromEnv(ctx context.Context) (*Client, error) { return &Client{model: model}, nil } -func hyperBaseURL() string { - base := os.Getenv("LARD_HYPER_BASE_URL") - if base == "" { - base = defaultHyperBaseURL - } - return strings.TrimRight(base, "/") -} - // Complete runs a single-turn completion and returns the text. Rate-limit // responses are retried with exponential backoff and jitter; without that a // large backfill silently loses every subject the moment Hyper throttles.