The platform now runs on Groq by default, through the OpenAI-compatible chat-completions shape. That shape is not one vendor — Gemini, OpenRouter, Together, vLLM and a local Ollama serve it too — so moving again stays configuration rather than code. Two things in the deleted file were not Anthropic's and would have gone with it silently: withRetry / MaxAttempts / retryBackoff were defined in anthropic.go and CALLED BY openai.go. Deleting the file wholesale would have removed the retry policy of the provider that survived, and nothing in openai.go mentions it, so the loss would have been invisible until the next 429. The policy is a property of this platform's runs, not of a vendor's API; it now lives in retry.go where no provider can carry it off. StreamComplete had the same problem and moves to gateway.go, beside the Streamer interface whose comment already referenced it. Three stale-configuration failures are now refused at startup instead of being ignored. Each was verified firing through the real config.Load(): MODEL_PROVIDER=anthropic — named separately from every other wrong value because it used to be correct. Ignoring it gives a stack that believes it is on Claude while every run goes to Groq and is billed there. ANTHROPIC_API_KEY set while MODEL_API_KEY is empty. Ignoring a key an operator did set is the worst version of this: they fail every run on a missing credential they are looking straight at. A leftover claude-* model id, naming the tier that carries it. This is the check the previous commit's error-detail work was diagnosing: such an id is accepted by this process, rejected by the provider, and 400s on EVERY run. "A model is wrong" does not say which of three lines to edit. Defaults ship as a matched pair. defaultBaseURL and the three tier ids are one decision, not four: an id is only meaningful against the service that serves it, and a Groq id on an OpenAI base URL is the same failure from the other side. The tiers also stop being one model — a tier whose cost does not differ is a distinction that buys nothing. Verified end to end against a stub of the wire, driving the real wiring (config.Load in production mode, gateway.New, StreamComplete): streamed deltas, tool-call decoding, the loopback credential exemption, and usage totalling 150 rather than 190 — the cached-prefix subtraction still holds. gofmt clean, go vet clean, 14/14 non-DB packages pass. httpserver still needs a reachable database. NOT verified: the I7 planted-injection eval. Removing this path removed the only model whose refusal behaviour had been measured against it, so the new default is unproven there until `make eval-live` runs with a real key. The Groq model ids should also be confirmed against Groq's current lineup. Flagged in CLAUDE.md §12 and docs/handover.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
735 lines
29 KiB
Go
735 lines
29 KiB
Go
// Package config loads and validates the backend's runtime configuration.
|
|
//
|
|
// Configuration comes from the process environment. A .env file in the
|
|
// repository root is read first as a convenience for local development, and
|
|
// never overrides a variable that is already set — so an explicit
|
|
// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file.
|
|
//
|
|
// Nothing here has a credential baked in. Load fails loudly rather than
|
|
// falling back to a default host, database or user, because a silent default
|
|
// is how a development process ends up pointed at the wrong database.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The default model per tier, and the endpoint they are valid on.
|
|
//
|
|
// THESE THREE AND defaultBaseURL ARE ONE DECISION, not four. A model id is only
|
|
// meaningful against the service that serves it, so a Groq id with an OpenAI
|
|
// base URL is not a partial configuration — it is a broken one that starts
|
|
// cleanly and fails every run at request time. They changed together when the
|
|
// Anthropic path was removed and they have to keep changing together.
|
|
//
|
|
// Unlike the old single default, the tiers are no longer the same model: the
|
|
// point of a tier is that `fast` costs less than `deep`, and one id for all
|
|
// three made the distinction free and therefore meaningless.
|
|
const (
|
|
defaultBaseURL = "https://api.groq.com/openai/v1"
|
|
defaultFastModel = "llama-3.1-8b-instant"
|
|
defaultBalancedModel = "llama-3.3-70b-versatile"
|
|
defaultDeepModel = "llama-3.3-70b-versatile"
|
|
)
|
|
|
|
// Config is the whole of the Phase 1 configuration surface.
|
|
type Config struct {
|
|
AppEnv string
|
|
Log LogConfig
|
|
HTTP HTTPConfig
|
|
DB DBConfig
|
|
Seed SeedConfig
|
|
Model ModelConfig
|
|
Knowledge KnowledgeConfig
|
|
}
|
|
|
|
// KnowledgeConfig routes the retrieval layer's embedding provider.
|
|
//
|
|
// The chat provider does not serve embeddings, so the dense half of hybrid
|
|
// retrieval needs its own provider and credential — this is a separate choice
|
|
// from MODEL_*, and pointing one of them somewhere new does not move the other.
|
|
//
|
|
// An empty key is legitimate: this service boots and serves without one, and
|
|
// retrieval degrades to keyword-only rather than failing — reported on every
|
|
// result, never silently. What is NOT legitimate is production running on the
|
|
// lexical stand-in, which is why that is a separate, deliberate opt-in rather
|
|
// than something an empty key falls back to.
|
|
type KnowledgeConfig struct {
|
|
// EmbedProvider names which embedder to use: "voyage", "ollama",
|
|
// "lexical", or "" to pick from what is configured.
|
|
//
|
|
// Explicit beats inferred here. The three differ in a way that is invisible
|
|
// from the outside — all of them return vectors and retrieval works with
|
|
// any of them — so a deployment silently running the stand-in would look
|
|
// exactly like one running a real model, right up until somebody phrased a
|
|
// question differently. Naming the provider makes the choice reviewable.
|
|
EmbedProvider string
|
|
|
|
// EmbedAPIKey is the hosted provider's credential (Voyage).
|
|
EmbedAPIKey string
|
|
|
|
// EmbedBaseURL is where a local model answers. Ollama's default is
|
|
// http://localhost:11434.
|
|
EmbedBaseURL string
|
|
|
|
EmbedModel string
|
|
EmbedDims int
|
|
|
|
// UseLexicalEmbedder swaps in the deterministic stand-in. Development only:
|
|
// it is not semantic, and a corpus indexed with it retrieves on word overlap
|
|
// alone. Load() refuses it outside development rather than trusting the
|
|
// operator to have read the comment.
|
|
//
|
|
// Kept alongside EmbedProvider for the deployments that already set it.
|
|
UseLexicalEmbedder bool
|
|
}
|
|
|
|
// ModelConfig routes an agent spec's reasoning tier to a model.
|
|
//
|
|
// A spec declares `reasoning: fast | balanced | deep`, never a model id, so the
|
|
// mapping is a deployment decision and changes without editing a definition.
|
|
// All three default to the same model: the tiers differ by *effort*, which the
|
|
// gateway owns, and a deployment that wants a cheaper model on the fast tier
|
|
// says so explicitly rather than inheriting a downgrade nobody chose.
|
|
//
|
|
// The API key may legitimately be empty outside production. This service has to
|
|
// boot without model credentials — migrations, seeding and every endpoint that
|
|
// is not an agent run work fine without one — so the failure belongs at the
|
|
// first model call, as a structured gateway.not_configured a run can end with,
|
|
// not at startup as a refusal to boot.
|
|
type ModelConfig struct {
|
|
// Provider names the wire protocol. "openai" is the only one, and empty
|
|
// means it; "anthropic" is refused at startup rather than ignored, because
|
|
// a deployment still carrying it has not been told the path was removed.
|
|
//
|
|
// "openai" is not only OpenAI. Groq, Gemini's compatibility endpoint,
|
|
// OpenRouter, Together, vLLM and a local Ollama all serve that same shape,
|
|
// and BaseURL is what chooses between them — which is why one wire protocol
|
|
// is not the same thing as one vendor.
|
|
Provider string
|
|
|
|
APIKey string
|
|
|
|
// BaseURL points the provider at a specific service. Empty means the
|
|
// default in defaultBaseURL, which the default model ids belong to.
|
|
BaseURL string
|
|
|
|
Fast string
|
|
Balanced string
|
|
Deep string
|
|
MaxOutputTokens int
|
|
|
|
// ReasoningEffort opts into sending the tier's effort level on the
|
|
// OpenAI-compatible wire. Off by default: reasoning models accept the
|
|
// field and most others reject the entire request rather than ignoring it.
|
|
ReasoningEffort bool
|
|
}
|
|
|
|
// SeedConfig locates the demo fixture. The file is generated from the frontend
|
|
// repository, so it lives beside the migrations rather than inside the Go
|
|
// module: regenerating it must not require rebuilding the binary.
|
|
type SeedConfig struct {
|
|
FixturePath string
|
|
}
|
|
|
|
type LogConfig struct {
|
|
Level string
|
|
}
|
|
|
|
type HTTPConfig struct {
|
|
Host string
|
|
Port int
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
|
|
// CookieSameSite is the SameSite attribute on the session cookie:
|
|
// "lax" (default), "none" or "strict".
|
|
//
|
|
// This exists because CORS is only half of what a cross-origin browser call
|
|
// needs, and the other half is easy to miss. SameSite is judged on SITE
|
|
// (registrable domain), not origin:
|
|
//
|
|
// app.krow.com → api.krow.com SAME site. Lax sends the cookie. ✓
|
|
// krow.vercel.app → api.krow.com CROSS site. Lax does NOT send it. ✗
|
|
// localhost:5173 → 127.0.0.1:8080 CROSS site — different hosts. ✗
|
|
//
|
|
// So a deployment whose frontend is on an unrelated domain gets a perfect
|
|
// set of CORS headers and still no session, because the browser never
|
|
// attaches the cookie. "none" is the only value that survives that, and it
|
|
// requires Secure, which means HTTPS.
|
|
//
|
|
// Default stays "lax": it is the safe value, it is correct for the
|
|
// same-site and same-origin deployments this is normally run as, and it
|
|
// gives CSRF protection that "none" gives up.
|
|
CookieSameSite string
|
|
|
|
// CORSOrigins is the exact set of browser origins allowed to call the API.
|
|
//
|
|
// It exists for one reason: in local development the Vite dev server is an
|
|
// origin of its own (http://localhost:5173) and the API is another
|
|
// (http://127.0.0.1:8080), so every fetch from the frontend is
|
|
// cross-origin. Empty means CORS is off and the API answers only
|
|
// same-origin callers, which is the correct posture everywhere the
|
|
// frontend is served from the same host as the API.
|
|
//
|
|
// Origins are matched exactly and echoed back one at a time. There is no
|
|
// wildcard and no pattern: "*" would let any page on the internet read
|
|
// this API, and once authentication exists that becomes a real hole rather
|
|
// than a theoretical one.
|
|
CORSOrigins []string
|
|
}
|
|
|
|
type DBConfig struct {
|
|
Host string
|
|
Port int
|
|
Name string
|
|
User string
|
|
Password string
|
|
Schema string
|
|
SSLMode string
|
|
MaxOpenConns int32
|
|
MinIdleConns int32
|
|
ConnMaxLifetime time.Duration
|
|
ConnectTimeout time.Duration
|
|
StatementTimeout time.Duration
|
|
}
|
|
|
|
// DSN builds a libpq-style connection URL.
|
|
//
|
|
// Every component is URL-escaped: the local database is called "Krow-force",
|
|
// which is both mixed-case and hyphenated, and a password may contain anything
|
|
// at all. Escaping is not optional here.
|
|
func (d DBConfig) DSN() string {
|
|
u := &url.URL{
|
|
Scheme: "postgres",
|
|
User: url.UserPassword(d.User, d.Password),
|
|
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
|
Path: "/" + d.Name,
|
|
}
|
|
q := u.Query()
|
|
q.Set("sslmode", d.SSLMode)
|
|
// Pin the schema on every connection so no query can accidentally resolve
|
|
// against a different one, and so nothing reaches for a system schema.
|
|
q.Set("search_path", d.Schema)
|
|
q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds())))
|
|
q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds())))
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// Redacted returns the DSN with the password replaced, for logs.
|
|
func (d DBConfig) Redacted() string {
|
|
u, err := url.Parse(d.DSN())
|
|
if err != nil {
|
|
return "postgres://<unparseable>"
|
|
}
|
|
if _, hasPassword := u.User.Password(); hasPassword {
|
|
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// Load reads the environment, applies defaults and validates the result.
|
|
func Load() (*Config, error) {
|
|
loadDotEnv(".env")
|
|
|
|
var missing []string
|
|
required := func(key string) string {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
missing = append(missing, key)
|
|
}
|
|
return v
|
|
}
|
|
|
|
cfg := &Config{
|
|
AppEnv: withDefault("APP_ENV", "development"),
|
|
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
|
HTTP: HTTPConfig{
|
|
Host: withDefault("HTTP_HOST", "127.0.0.1"),
|
|
Port: intDefault("HTTP_PORT", 8080),
|
|
ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second),
|
|
WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", DeepestAgentDeadline+30*time.Second),
|
|
IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
|
ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
|
|
CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")),
|
|
// Empty when unset, which is NOT the same as "lax": unset means "let
|
|
// the server derive it from the CORS posture", and an explicit value
|
|
// overrides that derivation. See Server.sessionSameSite.
|
|
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
|
|
},
|
|
Seed: SeedConfig{
|
|
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
|
|
},
|
|
Knowledge: KnowledgeConfig{
|
|
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
|
|
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
|
|
EmbedBaseURL: strings.TrimSpace(os.Getenv("EMBED_BASE_URL")),
|
|
// No default model or width here: they differ per provider, and one
|
|
// shared default would silently hand Ollama's dimensions to Voyage.
|
|
// Resolved where the provider is chosen — see runtime.NewEmbedder.
|
|
EmbedModel: strings.TrimSpace(os.Getenv("EMBED_MODEL")),
|
|
EmbedDims: intDefault("EMBED_DIMENSIONS", 0),
|
|
UseLexicalEmbedder: boolDefault("EMBED_USE_LEXICAL", false),
|
|
},
|
|
Model: ModelConfig{
|
|
Provider: strings.ToLower(strings.TrimSpace(os.Getenv("MODEL_PROVIDER"))),
|
|
// One spelling. ANTHROPIC_API_KEY used to be accepted as a
|
|
// fallback and is now deliberately NOT read: with the Anthropic
|
|
// path gone it would name a vendor this service cannot call, and
|
|
// silently authenticating to Groq with a variable called
|
|
// ANTHROPIC_API_KEY is the kind of lie an operator has to keep
|
|
// re-reading. A stale one is caught at startup, not ignored.
|
|
APIKey: strings.TrimSpace(os.Getenv("MODEL_API_KEY")),
|
|
BaseURL: withDefault("MODEL_BASE_URL", defaultBaseURL),
|
|
Fast: withDefault("MODEL_FAST", defaultFastModel),
|
|
Balanced: withDefault("MODEL_BALANCED", defaultBalancedModel),
|
|
Deep: withDefault("MODEL_DEEP", defaultDeepModel),
|
|
// 16k keeps a non-streaming response inside the SDK's HTTP
|
|
// timeout. The loop raises it and switches to streaming when it
|
|
// needs a long answer; this is the ceiling for a single
|
|
// unstreamed call, not the run's budget.
|
|
MaxOutputTokens: intDefault("MODEL_MAX_OUTPUT_TOKENS", 16000),
|
|
ReasoningEffort: boolDefault("MODEL_REASONING_EFFORT", false),
|
|
},
|
|
DB: DBConfig{
|
|
Host: required("DATABASE_HOST"),
|
|
Port: intDefault("DATABASE_PORT", 5432),
|
|
Name: required("DATABASE_NAME"),
|
|
User: required("DATABASE_USER"),
|
|
Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth)
|
|
Schema: withDefault("DATABASE_SCHEMA", "public"),
|
|
SSLMode: withDefault("DATABASE_SSLMODE", "disable"),
|
|
MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)),
|
|
MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)),
|
|
ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute),
|
|
ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second),
|
|
StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second),
|
|
},
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("missing required environment variables: %s "+
|
|
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
|
|
}
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// DeepestAgentDeadline is the longest a single agent run may take — the
|
|
// `deep` tier's deadline in runtime.LimitsForTier.
|
|
//
|
|
// Duplicated rather than imported because internal/runtime already imports
|
|
// this package, and a cycle to share one number is a bad trade. A test in
|
|
// internal/runtime asserts the two agree, so this drifting is a build failure
|
|
// rather than a discovery.
|
|
const DeepestAgentDeadline = 120 * time.Second
|
|
|
|
// validateWriteTimeout refuses a server that would cut off a run the runtime
|
|
// considers legal.
|
|
//
|
|
// HTTP_WRITE_TIMEOUT was 30s in production while every shipped agent runs at
|
|
// the `balanced` tier, whose deadline is 60s. The server therefore aborted the
|
|
// response on any run over half its allowed time, and the caller saw 502 Bad
|
|
// Gateway from the proxy in front — a gateway error for something no gateway
|
|
// did, which is why it read as an infrastructure fault for so long.
|
|
//
|
|
// Delegation made it routine rather than causing it: a parent that asks two
|
|
// subagents spends longer than one that answers alone. The misconfiguration
|
|
// predates it.
|
|
//
|
|
// Streaming hides it, and that is the trap. The chat panel uses SSE and
|
|
// survives, so the product looks healthy while every non-streaming caller — a
|
|
// webhook, a script, an integration — gets 502 on a slow question.
|
|
// validateModel refuses a model configuration that cannot work.
|
|
//
|
|
// Its own method for the same reason validateWriteTimeout is: these are the
|
|
// mistakes that produce a *runtime* symptom far from their cause — a deployment
|
|
// that believes it switched providers and is still being billed by the old one,
|
|
// or a production install with no credential that fails one run at a time
|
|
// instead of once at startup.
|
|
func (c *Config) validateModel() error {
|
|
// "anthropic" is named separately from every other wrong value because it
|
|
// is the one that used to be correct. A deployment still carrying it is not
|
|
// a typo, it is a stack that has not been told the path was removed — and
|
|
// the silent alternative is a service that believes it is on Claude while
|
|
// every run goes to Groq and is billed there.
|
|
switch c.Model.Provider {
|
|
case "", "openai":
|
|
case "anthropic":
|
|
return fmt.Errorf("MODEL_PROVIDER=anthropic is no longer supported: the Anthropic " +
|
|
"path was removed and this service speaks only the openai chat-completions " +
|
|
"shape. Unset MODEL_PROVIDER (or set it to openai) and point MODEL_BASE_URL " +
|
|
"at your provider")
|
|
default:
|
|
return fmt.Errorf("MODEL_PROVIDER must be openai (or empty, which means openai), got %q", c.Model.Provider)
|
|
}
|
|
// A credential under the old name is refused rather than ignored. Ignoring
|
|
// it produces the worst version of this failure: a deployment that set a
|
|
// key, sees no error, and fails every run on a missing credential it is
|
|
// looking straight at.
|
|
if os.Getenv("ANTHROPIC_API_KEY") != "" && c.Model.APIKey == "" {
|
|
return fmt.Errorf("ANTHROPIC_API_KEY is set but is no longer read, and MODEL_API_KEY is " +
|
|
"empty: the Anthropic path was removed. Rename the variable to MODEL_API_KEY " +
|
|
"— and if that value is an Anthropic key, replace it, because nothing here can " +
|
|
"call Anthropic any more")
|
|
}
|
|
// A local model needs no credential, and demanding one would make the
|
|
// zero-cost development path impossible to configure. Everything else does:
|
|
// a production deployment without a key fails every run at the gateway,
|
|
// which is a misconfiguration wearing a runtime error's clothes.
|
|
if c.AppEnv == "production" && c.Model.APIKey == "" && !isLoopback(c.Model.BaseURL) {
|
|
return fmt.Errorf("MODEL_API_KEY is required when APP_ENV=production; " +
|
|
"without it every agent run fails at the model gateway")
|
|
}
|
|
// A model id left over from the Anthropic path. THIS IS THE CHECK THAT
|
|
// REPLACED the old "base URL set against the wrong provider" one, and it
|
|
// guards the same failure from the other side.
|
|
//
|
|
// It is not hypothetical. A `claude-*` id sent to an OpenAI-compatible
|
|
// endpoint is accepted by this process, rejected by the provider, and
|
|
// surfaces as a 400 on EVERY run — which is exactly the incident that made
|
|
// the gateway start carrying upstream error text in the first place. One
|
|
// loud failure at startup is worth more than one per run.
|
|
for _, m := range []struct{ key, id string }{
|
|
{"MODEL_FAST", c.Model.Fast},
|
|
{"MODEL_BALANCED", c.Model.Balanced},
|
|
{"MODEL_DEEP", c.Model.Deep},
|
|
} {
|
|
if strings.HasPrefix(strings.ToLower(m.id), "claude") {
|
|
return fmt.Errorf("%s is %q, but the Anthropic path was removed: no configured "+
|
|
"provider serves a claude model, so every run on this tier would fail at "+
|
|
"the gateway. Set it to a model id your MODEL_BASE_URL (%s) serves",
|
|
m.key, m.id, c.Model.BaseURL)
|
|
}
|
|
}
|
|
if c.Model.BaseURL != "" {
|
|
u, err := url.Parse(c.Model.BaseURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return fmt.Errorf("MODEL_BASE_URL must be an http or https URL, got %q", c.Model.BaseURL)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) validateWriteTimeout() error {
|
|
if c.HTTP.WriteTimeout <= 0 {
|
|
return nil // no deadline set; the server will not cut anything off
|
|
}
|
|
if c.HTTP.WriteTimeout < DeepestAgentDeadline {
|
|
return fmt.Errorf(
|
|
"HTTP_WRITE_TIMEOUT is %s but an agent run may take %s (the deep tier's "+
|
|
"deadline); the server would abort the response while the run is still "+
|
|
"legal, and the caller would see 502 from the proxy. Set it above %s",
|
|
c.HTTP.WriteTimeout, DeepestAgentDeadline, DeepestAgentDeadline)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if err := c.validateWriteTimeout(); err != nil {
|
|
return err
|
|
}
|
|
switch c.AppEnv {
|
|
case "development", "staging", "production":
|
|
default:
|
|
return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv)
|
|
}
|
|
if c.HTTP.Port < 1 || c.HTTP.Port > 65535 {
|
|
return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port)
|
|
}
|
|
if c.DB.Port < 1 || c.DB.Port > 65535 {
|
|
return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port)
|
|
}
|
|
// The application owns exactly one schema and it is never a system schema.
|
|
switch c.DB.Schema {
|
|
case "pg_catalog", "pg_toast", "information_schema":
|
|
return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema)
|
|
}
|
|
if strings.HasPrefix(c.DB.Schema, "pg_") {
|
|
return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema)
|
|
}
|
|
if c.DB.MinIdleConns > c.DB.MaxOpenConns {
|
|
return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)",
|
|
c.DB.MinIdleConns, c.DB.MaxOpenConns)
|
|
}
|
|
if c.AppEnv == "production" && c.DB.SSLMode == "disable" {
|
|
return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production")
|
|
}
|
|
// A production deployment with no model credentials would accept agent runs
|
|
// and fail every one of them at the gateway. That is a boot-time
|
|
// misconfiguration wearing a runtime error's clothes, so it is caught here.
|
|
// Development is left alone deliberately: working on migrations or the
|
|
// definitions API must not require a key.
|
|
if err := c.validateModel(); err != nil {
|
|
return err
|
|
}
|
|
if c.Model.MaxOutputTokens < 1 {
|
|
return fmt.Errorf("MODEL_MAX_OUTPUT_TOKENS must be at least 1, got %d", c.Model.MaxOutputTokens)
|
|
}
|
|
// The lexical embedder is a development stand-in that hashes words into a
|
|
// vector. It is not semantic, so a production corpus indexed with it would
|
|
// retrieve on word overlap alone — which looks like working retrieval and is
|
|
// not. Refused here rather than trusted to an operator's reading of a
|
|
// comment, because the failure is invisible from the outside: results come
|
|
// back, they are just the wrong ones.
|
|
switch c.Knowledge.EmbedProvider {
|
|
case "", "voyage", "ollama", "lexical":
|
|
default:
|
|
return fmt.Errorf("EMBED_PROVIDER must be voyage, ollama or lexical, got %q",
|
|
c.Knowledge.EmbedProvider)
|
|
}
|
|
if c.AppEnv == "production" &&
|
|
(c.Knowledge.UseLexicalEmbedder || c.Knowledge.EmbedProvider == "lexical") {
|
|
return fmt.Errorf("EMBED_USE_LEXICAL is a development stand-in and is not allowed when " +
|
|
"APP_ENV=production; it is not a semantic embedder and a corpus indexed with it " +
|
|
"retrieves on word overlap alone")
|
|
}
|
|
// Zero means "the provider's own default", resolved where the provider is
|
|
// chosen. Only a negative value is a mistake.
|
|
if c.Knowledge.EmbedDims < 0 {
|
|
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
|
|
}
|
|
for name, model := range map[string]string{
|
|
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
|
|
} {
|
|
if strings.TrimSpace(model) == "" {
|
|
return fmt.Errorf("%s must name a model", name)
|
|
}
|
|
}
|
|
switch c.HTTP.CookieSameSite {
|
|
// Unset. The server derives the mode from whether a CORS allowlist is
|
|
// configured; there is nothing to validate.
|
|
case "":
|
|
case "lax", "strict":
|
|
case "none":
|
|
// SameSite=None without Secure is ignored — and in current browsers,
|
|
// rejected outright — so the cookie would simply never be stored. The
|
|
// Secure flag is set for every APP_ENV except development, so this is
|
|
// the one combination that produces a silently sessionless deployment.
|
|
if c.AppEnv == "development" {
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE=none requires the Secure cookie flag, " +
|
|
"which is not set when APP_ENV=development; SameSite=None over plain HTTP " +
|
|
"is rejected by browsers")
|
|
}
|
|
default:
|
|
return fmt.Errorf("HTTP_COOKIE_SAMESITE must be lax, none or strict, got %q",
|
|
c.HTTP.CookieSameSite)
|
|
}
|
|
for _, origin := range c.HTTP.CORSOrigins {
|
|
// "*" is rejected rather than quietly honoured. The middleware echoes a
|
|
// single matched origin, so a wildcard could only ever be a
|
|
// misunderstanding of what this setting does.
|
|
// "*" is not a stricter-than-necessary policy choice — it cannot work
|
|
// here at all. Authentication is a cookie, so the API must answer
|
|
// Access-Control-Allow-Credentials: true, and every browser REFUSES
|
|
// the pairing of that header with Allow-Origin: "*". A deployment
|
|
// configured this way would send correct-looking headers and have
|
|
// every authenticated call blocked client-side.
|
|
if origin == "*" {
|
|
return fmt.Errorf(`HTTP_CORS_ORIGINS must list explicit origins; "*" cannot be used ` +
|
|
`because this API authenticates with a cookie, and browsers reject ` +
|
|
`Access-Control-Allow-Origin: "*" together with credentials. ` +
|
|
`List each frontend origin, or serve the frontend from the API's own origin ` +
|
|
`(then leave this unset and CORS is not involved at all)`)
|
|
}
|
|
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
|
return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
|
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
|
// as different origins, so both are listed. 4173 is `vite preview`.
|
|
// 5174 is where Vite lands when 5173 is already taken, which happens whenever a
|
|
// second dev server is started; an origin missing from this list is refused at
|
|
// the preflight with a bare 403 and no CORS headers, which reads as a server
|
|
// fault rather than a misconfigured port.
|
|
var devCORSOrigins = []string{
|
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
"http://localhost:5174", "http://127.0.0.1:5174",
|
|
"http://localhost:4173", "http://127.0.0.1:4173",
|
|
}
|
|
|
|
// corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist.
|
|
//
|
|
// The development default is the Vite dev server, because that is the whole
|
|
// point of the setting in Phase 2D. Outside development the default is empty:
|
|
// a staging or production deployment that genuinely serves its frontend from
|
|
// another origin has to say so explicitly, rather than inheriting a list of
|
|
// localhost origins nobody reviewed.
|
|
func corsOrigins(appEnv string) []string {
|
|
raw, set := os.LookupEnv("HTTP_CORS_ORIGINS")
|
|
if !set {
|
|
if appEnv == "development" {
|
|
return devCORSOrigins
|
|
}
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(raw, ",") {
|
|
// A trailing slash makes the string unequal to the Origin header the
|
|
// browser actually sends, which fails in a way that looks like a
|
|
// server bug rather than a typo.
|
|
if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func withDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// firstSet returns the first of several environment variables that has a value.
|
|
//
|
|
// For settings that have more than one legitimate spelling — a generic name and
|
|
// a provider-specific one — where the order expresses which wins rather than
|
|
// leaving it to whichever happens to be read last.
|
|
func firstSet(keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isLoopback reports whether a base URL points at this machine.
|
|
//
|
|
// A model served from localhost needs no credential, and requiring one would
|
|
// make the zero-cost local path impossible to configure. Host-only, so a
|
|
// remote service that merely mentions "localhost" in a path does not qualify.
|
|
func isLoopback(raw string) bool {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
host := u.Hostname()
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
}
|
|
|
|
// providerName renders the provider for an error message, naming the default
|
|
// rather than showing an empty string an operator then has to interpret.
|
|
func providerName(p string) string {
|
|
if p == "" {
|
|
return "openai (the default)"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func intDefault(key string, fallback int) int {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
// boolDefault reads a boolean flag.
|
|
//
|
|
// An unparseable value falls back rather than erroring, matching intDefault.
|
|
// The one asymmetry worth knowing: only the explicit true spellings turn a flag
|
|
// on, so a typo'd "yes" leaves a feature off rather than on — the safe
|
|
// direction for every flag this file currently carries.
|
|
func boolDefault(key string, fallback bool) bool {
|
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
|
case "":
|
|
return fallback
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func durationDefault(key string, fallback time.Duration) time.Duration {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return d
|
|
}
|
|
|
|
// loadDotEnv reads KEY=VALUE lines, walking up from the working directory so
|
|
// `go run ./cmd/api` finds the repository-root .env. Existing environment
|
|
// variables always win. Absence of the file is not an error.
|
|
func loadDotEnv(name string) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for i := 0; i < 5; i++ {
|
|
path := dir + string(os.PathSeparator) + name
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
applyDotEnv(string(data))
|
|
return
|
|
}
|
|
parent := parentDir(dir)
|
|
if parent == dir {
|
|
return
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func parentDir(dir string) string {
|
|
i := strings.LastIndex(dir, string(os.PathSeparator))
|
|
if i <= 0 {
|
|
return dir
|
|
}
|
|
return dir[:i]
|
|
}
|
|
|
|
func applyDotEnv(content string) {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(strings.TrimPrefix(key, "export "))
|
|
value = strings.TrimSpace(value)
|
|
if len(value) >= 2 {
|
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
if _, present := os.LookupEnv(key); !present {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
}
|
|
}
|