diff --git a/gateway/DESIGN.md b/gateway/DESIGN.md index 07386eb..7f0207f 100644 --- a/gateway/DESIGN.md +++ b/gateway/DESIGN.md @@ -318,6 +318,48 @@ flash without being told would draw wrong conclusions and blame the model. --- +## The status page + +`freeseek.1lm.io/` is the dashboard, served by the gateway itself from +`go:embed` — one binary, and the page can never be a different version +from the JSON it reads. It polls `GET /v1/status`, which is public, +unauthenticated and cached for three seconds. + +What that document says and what it refuses to say are both deliberate: + +| published | withheld | why | +|---|---|---| +| credit as a **percentage** | dollars, account balance | "$0.19 of $0.25" is a progress bar for whoever wants to trip the breaker | +| subject ids **truncated to 6 chars** | whole subject ids | a whole id can be matched against the one in someone's `free.json` | +| **per-country** request counts | anything per-IP, ever | an aggregate is a fact about the service, not about a person | +| token counts, tok/s, live subjects | prompts, completions | the promise in `deepseek free` is the promise here | + +Exact money, per-key health and the full subject table live behind +`GET /admin/status` with the operator token. + +Metrics live in package `stats`: a ring of per-second buckets, bounded +maps, everything in memory and lost on restart. That is the right trade — +losing it costs a graph, and it keeps observability from ever becoming a +second, weaker copy of the money. + +## The key pool + +One key was a single point of failure with a hard floor. Package +`keyring` holds several, rotates per request, and retires a key the +moment DeepSeek answers 401 or 402 on it — the authority on "this key is +done" is upstream, not our own ledger, because a donated key may be +funding something else as well. + +A secret never leaves the package. Every accessor returns a fingerprint +(last four characters plus a truncated hash), which tells two keys apart +in a dashboard and is useless to anyone who steals the output. + +Donations are added by an operator through `POST /admin/keys` and +persisted, so a gift survives a restart and lands without a deploy. +**There is deliberately no public form.** A service that collects other +people's API keys over the open internet is a phishing lesson with a nice +stylesheet; the donation path is a private message to a human. + ## What we deliberately did not build - **A user table.** Stateless tokens plus daily counters. Nothing to diff --git a/gateway/cmd/dsgate/main.go b/gateway/cmd/dsgate/main.go index 58af5b8..4656e32 100644 --- a/gateway/cmd/dsgate/main.go +++ b/gateway/cmd/dsgate/main.go @@ -61,6 +61,7 @@ const usage = `dsgate — the free tier for the deepseek CLI Configuration is entirely environment variables: DSGATE_UPSTREAM_KEY DeepSeek API key to spend (required) + DSGATE_UPSTREAM_KEYS more keys, comma separated; the pool rotates DSGATE_UPSTREAM_BASE_URL upstream root (https://api.deepseek.com) DSGATE_ADDR listen address (:8787) DSGATE_STATE_DIR journal, secret, revocations (./state) @@ -102,8 +103,14 @@ SIGHUP re-reads /revoked.txt without dropping connections. ` func run() error { - key := env("DSGATE_UPSTREAM_KEY", os.Getenv("DEEPSEEK_API_KEY")) - if key == "" { + // One key or many. The pool is what lets a donated key extend the + // service without a restart, and what lets an emptied one retire + // itself instead of taking the whole free tier down with it. + keys := envList("DSGATE_UPSTREAM_KEYS") + if k := env("DSGATE_UPSTREAM_KEY", os.Getenv("DEEPSEEK_API_KEY")); k != "" { + keys = append([]string{k}, keys...) + } + if len(keys) == 0 { return errors.New("DSGATE_UPSTREAM_KEY is not set; there is nothing to spend") } @@ -150,8 +157,10 @@ func run() error { cfg := server.Config{ UpstreamBaseURL: env("DSGATE_UPSTREAM_BASE_URL", "https://api.deepseek.com"), - UpstreamKey: key, + UpstreamKeys: keys, + KeyStatePath: filepath.Join(stateDir, "donated-keys.json"), Model: env("DSGATE_MODEL", "deepseek-v4-flash"), + Version: version, MaxBodyBytes: int64(envInt("DSGATE_MAX_BODY_BYTES", 131072)), MaxTokens: envInt("DSGATE_ANON_MAX_TOKENS", 4096), MaxInflight: envInt("DSGATE_MAX_INFLIGHT", 8), diff --git a/gateway/internal/keyring/keyring.go b/gateway/internal/keyring/keyring.go new file mode 100644 index 0000000..2d3d4d8 --- /dev/null +++ b/gateway/internal/keyring/keyring.go @@ -0,0 +1,408 @@ +// Package keyring holds the upstream API keys this service spends and +// decides which one pays for the next request. +// +// One key was always a single point of failure with a hard floor: when it +// empties, the free tier is over until someone is at a keyboard. A pool +// changes that into a queue — a donated key is added at runtime, an +// emptied one steps aside on the first refusal upstream, and the service +// keeps answering across the seam. +// +// Three rules shape everything here: +// +// A key never leaves this package in full. Every accessor returns a +// fingerprint — the last four characters and a truncated SHA-256 — which +// is enough to tell two keys apart in a log or a dashboard and useless to +// anyone who steals the output. +// +// "Out of credit" is upstream's verdict, not ours. Our ledger only knows +// what this gateway spent; the key may be funding something else as well, +// so the authority on "this key is done" is DeepSeek answering 402 or +// reporting no balance. +// +// And that verdict is reversible. A dry key is re-checked on every +// balance cycle and returns to rotation the moment it has credit again — +// a donor who tops their key up should not have to tell anyone. Only an +// operator's Retire is permanent, because only a human knows a key is +// compromised rather than merely empty. +package keyring + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strings" + "sync" + "time" +) + +// ErrNoKeys means the pool has nothing left to spend. +var ErrNoKeys = errors.New("no upstream key is currently usable") + +// Source records where a key came from, so an operator can tell the +// service's own key from something a stranger sent in. +type Source string + +const ( + SourceConfig Source = "config" + SourceDonor Source = "donor" +) + +// Key is one credential in the pool. +type Key struct { + secret string + + // Fingerprint identifies the key in output without revealing it. + Fingerprint string `json:"fingerprint"` + Label string `json:"label,omitempty"` + Source Source `json:"source"` + AddedAt time.Time `json:"added_at"` + + // Retired is the operator's decision: this key is out, and stays out + // until someone says otherwise. Nothing automatic sets it. + Retired bool `json:"retired"` + RetiredAt time.Time `json:"retired_at,omitempty"` + Reason string `json:"retired_reason,omitempty"` + + // Dry is upstream's verdict: DeepSeek refused this key for money or + // validity. It is deliberately *not* permanent — a donor who tops the + // key up should not need an operator to notice. The balance watcher + // re-checks dry keys every cycle and clears this when they answer for + // themselves again. + Dry bool `json:"dry"` + DryAt time.Time `json:"dry_at,omitempty"` + DryReason string `json:"dry_reason,omitempty"` + + Requests int64 `json:"requests"` +} + +// Available reports whether this key may pay for a request. +func (k Key) Available() bool { return !k.Retired && !k.Dry } + +// Ring is the pool. +type Ring struct { + mu sync.Mutex + keys []*Key + next int + path string + + now func() time.Time +} + +// Fingerprint renders a key as something safe to print. Two keys collide +// only if they share both their tail and a 6-byte hash prefix, which does +// not happen by accident. +func Fingerprint(secret string) string { + sum := sha256.Sum256([]byte(secret)) + tail := secret + if len(tail) > 4 { + tail = tail[len(tail)-4:] + } + return "…" + tail + "/" + hex.EncodeToString(sum[:6]) +} + +// New builds a ring from the configured keys. Duplicates collapse: the +// same key added twice is one key, not two turns in the rotation. +func New(secrets []string, statePath string) *Ring { + r := &Ring{path: statePath, now: time.Now} + for _, s := range secrets { + r.add(strings.TrimSpace(s), SourceConfig, "") + } + r.loadDonors() + return r +} + +// SetClock replaces the time source, for tests. +func (r *Ring) SetClock(now func() time.Time) { + r.mu.Lock() + r.now = now + r.mu.Unlock() +} + +func (r *Ring) add(secret string, src Source, label string) bool { + if secret == "" { + return false + } + fp := Fingerprint(secret) + for _, k := range r.keys { + if k.Fingerprint == fp { + return false + } + } + r.keys = append(r.keys, &Key{ + secret: secret, + Fingerprint: fp, + Label: label, + Source: src, + AddedAt: r.now(), + }) + return true +} + +// Next hands out the key that should pay for the next request, round +// robin over everything not retired. +// +// The rotation is per request rather than per key-until-empty on purpose: +// DeepSeek's rate limits and KV cache are per account, so spreading the +// load spreads the scheduling too. It also means a key that is about to +// be retired takes one request to discover that, not a whole burst. +func (r *Ring) Next() (secret string, fingerprint string, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + for i := 0; i < len(r.keys); i++ { + k := r.keys[(r.next+i)%len(r.keys)] + if !k.Available() { + continue + } + r.next = (r.next + i + 1) % len(r.keys) + k.Requests++ + return k.secret, k.Fingerprint, nil + } + return "", "", ErrNoKeys +} + +// Retire takes a key out of rotation permanently. This is an operator +// action; upstream refusals call MarkDry instead, because "no balance" +// is a condition a donor can fix and should not need a human to undo. +func (r *Ring) Retire(fingerprint, reason string) { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Fingerprint == fingerprint && !k.Retired { + k.Retired = true + k.RetiredAt = r.now() + k.Reason = reason + r.saveDonorsLocked() + return + } + } +} + +// MarkDry records that upstream refused this key. Reversible by design: +// see Key.Dry. +func (r *Ring) MarkDry(fingerprint, reason string) { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Fingerprint == fingerprint && !k.Dry { + k.Dry = true + k.DryAt = r.now() + k.DryReason = reason + r.saveDonorsLocked() + return + } + } +} + +// MarkFunded clears the dry flag after upstream answers for the key +// again. Reports whether anything changed, so the caller can log a +// recovery rather than a steady state. +func (r *Ring) MarkFunded(fingerprint string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Fingerprint == fingerprint && k.Dry { + k.Dry, k.DryAt, k.DryReason = false, time.Time{}, "" + r.saveDonorsLocked() + return true + } + } + return false +} + +// Revive undoes an operator retirement and any dry flag with it. +func (r *Ring) Revive(fingerprint string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Fingerprint == fingerprint && (k.Retired || k.Dry) { + k.Retired, k.RetiredAt, k.Reason = false, time.Time{}, "" + k.Dry, k.DryAt, k.DryReason = false, time.Time{}, "" + r.saveDonorsLocked() + return true + } + } + return false +} + +// Fingerprints lists every key, so a caller that must visit each one — +// the balance watcher — can do so without holding the lock while it +// makes network calls. +func (r *Ring) Fingerprints() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, 0, len(r.keys)) + for _, k := range r.keys { + if !k.Retired { + out = append(out, k.Fingerprint) + } + } + return out +} + +// Donate adds a key at runtime and persists it, so a restart does not +// throw away a gift. Reports whether it was new. +func (r *Ring) Donate(secret, label string) (string, bool) { + r.mu.Lock() + defer r.mu.Unlock() + secret = strings.TrimSpace(secret) + if !r.add(secret, SourceDonor, label) { + return Fingerprint(secret), false + } + r.saveDonorsLocked() + return Fingerprint(secret), true +} + +// Remove drops a key entirely, for a donor who wants theirs back out. +func (r *Ring) Remove(fingerprint string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for i, k := range r.keys { + if k.Fingerprint == fingerprint { + r.keys = append(r.keys[:i], r.keys[i+1:]...) + if r.next > len(r.keys) { + r.next = 0 + } + r.saveDonorsLocked() + return true + } + } + return false +} + +// Status is the pool as the dashboard and the operator see it. No secret +// is reachable from this type. +type Status struct { + Active int `json:"active"` + Dry int `json:"dry"` + Retired int `json:"retired"` + Total int `json:"total"` + Keys []Key `json:"keys,omitempty"` +} + +// Status reports the pool. Keys carry fingerprints only. +func (r *Ring) Status(includeKeys bool) Status { + r.mu.Lock() + defer r.mu.Unlock() + + var st Status + for _, k := range r.keys { + st.Total++ + switch { + case k.Available(): + st.Active++ + case k.Retired: + st.Retired++ + default: + st.Dry++ + } + if includeKeys { + st.Keys = append(st.Keys, *k) + } + } + return st +} + +// Secret returns one key's value by fingerprint, for the balance check +// that has to authenticate as that key specifically. It is the only way +// a secret leaves this package, and it is not reachable from any type +// that gets serialised. +func (r *Ring) Secret(fingerprint string) (string, bool) { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Fingerprint == fingerprint { + return k.secret, true + } + } + return "", false +} + +// Usable reports whether anything is left to spend. +func (r *Ring) Usable() bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, k := range r.keys { + if k.Available() { + return true + } + } + return false +} + +// --- donor persistence -------------------------------------------------- + +// donorFile is what survives a restart. Configured keys are not written +// here — they come from the environment every boot, and copying them into +// a second file would be one more place a secret lives for no gain. +type donorFile struct { + Keys []donorEntry `json:"keys"` +} + +type donorEntry struct { + Secret string `json:"secret"` + Label string `json:"label,omitempty"` + AddedAt time.Time `json:"added_at"` + Retired bool `json:"retired,omitempty"` + RetiredAt time.Time `json:"retired_at,omitempty"` + Reason string `json:"retired_reason,omitempty"` + Dry bool `json:"dry,omitempty"` + DryAt time.Time `json:"dry_at,omitempty"` + DryReason string `json:"dry_reason,omitempty"` +} + +func (r *Ring) loadDonors() { + if r.path == "" { + return + } + b, err := os.ReadFile(r.path) + if err != nil { + return + } + var f donorFile + if json.Unmarshal(b, &f) != nil { + return + } + for _, e := range f.Keys { + if !r.add(e.Secret, SourceDonor, e.Label) { + continue + } + k := r.keys[len(r.keys)-1] + if !e.AddedAt.IsZero() { + k.AddedAt = e.AddedAt + } + k.Retired, k.RetiredAt, k.Reason = e.Retired, e.RetiredAt, e.Reason + k.Dry, k.DryAt, k.DryReason = e.Dry, e.DryAt, e.DryReason + } +} + +// saveDonorsLocked writes the donated keys back. 0600 and an atomic +// rename: this file is a list of other people's credentials, and it is +// the one place in the service where such a thing is at rest. +func (r *Ring) saveDonorsLocked() { + if r.path == "" { + return + } + var f donorFile + for _, k := range r.keys { + if k.Source != SourceDonor { + continue + } + f.Keys = append(f.Keys, donorEntry{ + Secret: k.secret, Label: k.Label, AddedAt: k.AddedAt, + Retired: k.Retired, RetiredAt: k.RetiredAt, Reason: k.Reason, + Dry: k.Dry, DryAt: k.DryAt, DryReason: k.DryReason, + }) + } + b, err := json.Marshal(f) + if err != nil { + return + } + tmp := r.path + ".tmp" + if os.WriteFile(tmp, b, 0o600) != nil { + return + } + os.Rename(tmp, r.path) +} diff --git a/gateway/internal/keyring/keyring_test.go b/gateway/internal/keyring/keyring_test.go new file mode 100644 index 0000000..8ce08a8 --- /dev/null +++ b/gateway/internal/keyring/keyring_test.go @@ -0,0 +1,258 @@ +package keyring + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRotationSpreadsAcrossKeys(t *testing.T) { + r := New([]string{"sk-aaa", "sk-bbb", "sk-ccc"}, "") + + seen := map[string]int{} + for i := 0; i < 9; i++ { + secret, fp, err := r.Next() + if err != nil { + t.Fatal(err) + } + if fp == "" { + t.Fatal("Next returned an empty fingerprint") + } + seen[secret]++ + } + if len(seen) != 3 { + t.Fatalf("nine requests touched %d keys, want 3", len(seen)) + } + for k, n := range seen { + if n != 3 { + t.Errorf("key %q served %d of 9 requests, want an even 3", k, n) + } + } +} + +// A key upstream has refused must leave the rotation immediately, and the +// rest must keep serving. +func TestDryKeysAreSkipped(t *testing.T) { + r := New([]string{"sk-aaa", "sk-bbb"}, "") + _, fp, _ := r.Next() + r.MarkDry(fp, "402") + + for i := 0; i < 5; i++ { + _, got, err := r.Next() + if err != nil { + t.Fatal(err) + } + if got == fp { + t.Fatal("a dry key was handed out again") + } + } + st := r.Status(false) + if st.Active != 1 || st.Dry != 1 || st.Total != 2 { + t.Errorf("status = %+v, want 1 active / 1 dry / 2 total", st) + } +} + +// "Out of credit" is a condition a donor can fix. It must not need an +// operator to undo, or every topped-up key silently stays out forever. +func TestDryKeysRecoverWhenFundedAgain(t *testing.T) { + r := New([]string{"sk-aaa"}, "") + _, fp, _ := r.Next() + r.MarkDry(fp, "402") + if r.Usable() { + t.Fatal("a dry key was still usable") + } + + if !r.MarkFunded(fp) { + t.Fatal("MarkFunded did not find the dry key") + } + if !r.Usable() { + t.Fatal("the key did not come back after upstream reported credit") + } + if got := r.Status(false); got.Active != 1 || got.Dry != 0 { + t.Errorf("status = %+v, want 1 active / 0 dry", got) + } + // A second call is a no-op, so a steady state does not read as a + // recovery event every poll. + if r.MarkFunded(fp) { + t.Error("MarkFunded reported a change for an already-funded key") + } +} + +// An operator retirement is the permanent one, and the balance watcher +// must not undo it. +func TestOperatorRetirementIsNotUndoneByAHealthyBalance(t *testing.T) { + r := New([]string{"sk-aaa"}, "") + _, fp, _ := r.Next() + r.Retire(fp, "operator: suspected compromised") + + if r.MarkFunded(fp) { + t.Error("MarkFunded revived an operator-retired key") + } + if r.Usable() { + t.Fatal("an operator-retired key came back into rotation") + } + // Fingerprints is what the balance watcher iterates; a retired key + // must not even be checked. + for _, got := range r.Fingerprints() { + if got == fp { + t.Error("the balance watcher would still poll a retired key") + } + } + if !r.Revive(fp) { + t.Fatal("an operator could not revive their own retirement") + } + if !r.Usable() { + t.Error("the revived key is not usable") + } +} + +func TestEmptyPoolIsAnError(t *testing.T) { + r := New([]string{"sk-only"}, "") + _, fp, _ := r.Next() + r.MarkDry(fp, "402") + + if _, _, err := r.Next(); err != ErrNoKeys { + t.Fatalf("err = %v, want ErrNoKeys", err) + } + if r.Usable() { + t.Error("Usable reported true with every key retired") + } +} + +func TestDuplicateKeysCollapse(t *testing.T) { + r := New([]string{"sk-same", "sk-same"}, "") + if got := r.Status(false).Total; got != 1 { + t.Errorf("total = %d, want 1 — the same key twice is one key", got) + } + if _, added := r.Donate("sk-same", "donor"); added { + t.Error("donating an already-present key counted as new") + } +} + +// A secret must never appear in anything that gets serialised. +func TestStatusNeverCarriesTheSecret(t *testing.T) { + const secret = "sk-super-secret-value-1234" + r := New([]string{secret}, "") + st := r.Status(true) + + if len(st.Keys) != 1 { + t.Fatalf("got %d keys, want 1", len(st.Keys)) + } + k := st.Keys[0] + if strings.Contains(k.Fingerprint, secret) { + t.Fatal("the fingerprint contains the whole key") + } + // The tail is deliberately present; the body of the key must not be. + if strings.Contains(k.Fingerprint, "super-secret") { + t.Fatal("the fingerprint leaks the body of the key") + } + if k.Source != SourceConfig { + t.Errorf("source = %q, want %q", k.Source, SourceConfig) + } +} + +func TestDonationsSurviveRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "donated.json") + + r := New([]string{"sk-house"}, path) + fp, added := r.Donate("sk-donated", "a kind stranger") + if !added { + t.Fatal("the donation was not accepted") + } + + r2 := New([]string{"sk-house"}, path) + st := r2.Status(true) + if st.Total != 2 { + t.Fatalf("after restart the pool has %d keys, want 2", st.Total) + } + var found bool + for _, k := range st.Keys { + if k.Fingerprint == fp { + found = true + if k.Source != SourceDonor { + t.Errorf("restored source = %q, want %q", k.Source, SourceDonor) + } + if k.Label != "a kind stranger" { + t.Errorf("restored label = %q", k.Label) + } + } + } + if !found { + t.Error("the donated key did not come back") + } +} + +// Dryness has to persist too, or a restart puts an empty key straight +// back into rotation and every request through it fails until the next +// balance check. +func TestDrynessSurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "donated.json") + r := New(nil, path) + fp, _ := r.Donate("sk-donated", "") + r.MarkDry(fp, "402") + + r2 := New(nil, path) + if got := r2.Status(false).Dry; got != 1 { + t.Errorf("dry = %d after restart, want 1", got) + } + if r2.Usable() { + t.Error("a dry donated key came back usable") + } +} + +func TestReviveAndRemove(t *testing.T) { + r := New(nil, "") + fp, _ := r.Donate("sk-donated", "") + r.MarkDry(fp, "402") + + if !r.Revive(fp) { + t.Fatal("Revive did not find the retired key") + } + if !r.Usable() { + t.Error("the revived key is not usable") + } + if !r.Remove(fp) { + t.Fatal("Remove did not find the key") + } + if got := r.Status(false).Total; got != 0 { + t.Errorf("total = %d after removal, want 0", got) + } +} + +// Removing the key the cursor points past must not leave Next indexing +// off the end of the slice. +func TestRemoveKeepsTheCursorInRange(t *testing.T) { + r := New([]string{"sk-a", "sk-b", "sk-c"}, "") + r.Next() + r.Next() + r.Next() + + for _, k := range r.Status(true).Keys[:2] { + r.Remove(k.Fingerprint) + } + if _, _, err := r.Next(); err != nil { + t.Fatalf("Next after removals: %v", err) + } +} + +func TestFingerprintIsStableAndDistinct(t *testing.T) { + a := Fingerprint("sk-one") + if a != Fingerprint("sk-one") { + t.Error("the same key fingerprinted differently twice") + } + if a == Fingerprint("sk-two") { + t.Error("two different keys share a fingerprint") + } +} + +func TestRequestCountsAreTracked(t *testing.T) { + r := New([]string{"sk-a"}, "") + r.SetClock(func() time.Time { return time.Unix(1_700_000_000, 0) }) + for i := 0; i < 4; i++ { + r.Next() + } + if got := r.Status(true).Keys[0].Requests; got != 4 { + t.Errorf("requests = %d, want 4", got) + } +} diff --git a/gateway/internal/quota/quota.go b/gateway/internal/quota/quota.go index de69795..dfe41e3 100644 --- a/gateway/internal/quota/quota.go +++ b/gateway/internal/quota/quota.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "sync" "time" ) @@ -142,6 +143,12 @@ type Ledger struct { // persisted alongside priorSpend so a restart can tell which journals // have already been folded in and which still have to be. through string + // priorTotals is lifetime token and request counts for every day + // before today, scanned from the journals at boot. Spend is tracked + // separately in priorSpend, which is persisted; these are not, because + // re-deriving them is cheap and a counter that can drift from its own + // journal is worse than one that cannot. + priorTotals Totals // reserved is the projected worst-case cost of every admitted request // that has not yet been charged or refunded. It counts against both // budgets at admission, which is what makes them ceilings rather than @@ -204,6 +211,9 @@ func Open(dir string, limits Limits) (*Ledger, error) { if err := l.replay(l.day); err != nil { return nil, err } + if err := l.scanPastTotals(l.day); err != nil { + return nil, err + } if err := l.saveStateLocked(nil); err != nil { return nil, err } @@ -295,6 +305,33 @@ func (l *Ledger) foldPastLocked(today string) error { return nil } +// scanPastTotals sums token and request counts from every journal before +// today, so the dashboard's lifetime figures survive a restart. +func (l *Ledger) scanPastTotals(today string) error { + names, err := os.ReadDir(l.dir) + if err != nil { + return err + } + l.priorTotals = Totals{} + for _, e := range names { + day, ok := journalDay(e.Name()) + if !ok || day >= today { + continue + } + f, err := os.Open(l.journalPath(day)) + if err != nil { + continue + } + scanJournal(f, func(en entry) { + l.priorTotals.Requests++ + l.priorTotals.InputTokens += en.InputTokens + l.priorTotals.OutputTokens += en.OutputTokens + }) + f.Close() + } + return nil +} + // journalDay pulls the date out of a journal filename, reporting whether // the name was one of ours at all. func journalDay(name string) (string, bool) { @@ -398,6 +435,11 @@ func (l *Ledger) rollLocked() { } l.priorSpend += l.daySpend l.daySpend = 0 + for _, a := range l.accounts { + l.priorTotals.Requests += a.Requests + l.priorTotals.InputTokens += a.InputTokens + l.priorTotals.OutputTokens += a.OutputTokens + } l.accounts = map[string]*Account{} l.day = day l.through = day @@ -593,6 +635,88 @@ func (l *Ledger) Status(subject, tier string) Status { } } +// Totals is aggregate usage over some period. +type Totals struct { + Requests int `json:"requests"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + SpentUSD float64 `json:"spent_usd"` +} + +// Today totals everything charged since midnight UTC. +func (l *Ledger) Today() Totals { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + + var t Totals + for _, a := range l.accounts { + t.Requests += a.Requests + t.InputTokens += a.InputTokens + t.OutputTokens += a.OutputTokens + t.SpentUSD += a.SpentUSD + } + return t +} + +// Lifetime totals every journal this ledger has ever written. +// +// Unlike spend — which is folded into priorSpend precisely so lifetime +// money never needs a full replay — token counts are read by scanning +// the journals once at boot and kept live from there. The scan is a few +// hundred kilobytes at this service's volume, and the alternative was +// another persisted counter that could silently drift from the journals +// it claims to summarise. +func (l *Ledger) Lifetime() Totals { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + + t := l.priorTotals + for _, a := range l.accounts { + t.Requests += a.Requests + t.InputTokens += a.InputTokens + t.OutputTokens += a.OutputTokens + } + t.SpentUSD = l.priorSpend + l.daySpend + return t +} + +// SubjectUsage is one anonymous account's day, for the leaderboard. +type SubjectUsage struct { + Subject string `json:"subject"` + Account +} + +// TopSubjects returns today's busiest subjects, most requests first. +// +// A subject is 16 random bytes with no person attached, but the caller +// still truncates it before publishing: an id that is whole is an id that +// can be matched against the one in someone's free.json. +func (l *Ledger) TopSubjects(n int) []SubjectUsage { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + + out := make([]SubjectUsage, 0, len(l.accounts)) + for sub, a := range l.accounts { + out = append(out, SubjectUsage{Subject: sub, Account: *a}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Requests != out[j].Requests { + return out[i].Requests > out[j].Requests + } + if out[i].OutputTokens != out[j].OutputTokens { + return out[i].OutputTokens > out[j].OutputTokens + } + return out[i].Subject < out[j].Subject + }) + if len(out) > n { + out = out[:n] + } + return out +} + // Health is the operator's view: the figures Status deliberately hides. type Health struct { Day string `json:"day"` diff --git a/gateway/internal/server/proxy.go b/gateway/internal/server/proxy.go index 2017ca6..a715f5f 100644 --- a/gateway/internal/server/proxy.go +++ b/gateway/internal/server/proxy.go @@ -73,6 +73,11 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { } defer s.releaseSubject(subject) + // Counted here, once the caller is known to be real and admitted. The + // country arrives already reduced to two letters by the edge; no + // address reaches the collector. See package stats. + s.stats.Seen(subject, edgeCountry(r), route.Name) + // The model list barely changes and is deliberately uncharged, so it // is answered from a short cache when possible — otherwise the one // free endpoint would burn in-flight slots and upstream round trips. @@ -145,9 +150,33 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { } defer func() { <-s.inflight }() + s.stats.InFlight(1) + defer s.stats.InFlight(-1) + s.forward(w, r, route, decision, subject, billable, reserve) } +// edgeCountry reads the two-letter country the CDN attached to this +// request. Cloudflare sets CF-IPCountry; other edges use the same idea +// under a different name. +// +// It is only meaningful behind a proxy we control — the same condition +// that makes X-Forwarded-For trustworthy — because a client can otherwise +// set it to anything. A wrong country on a histogram is harmless, but +// counting one we did not derive ourselves would be a lie about where the +// number came from, so it follows TrustProxy. +func edgeCountry(r *http.Request) string { + c := r.Header.Get("CF-IPCountry") + if c == "" { + c = r.Header.Get("X-Country-Code") + } + c = strings.ToUpper(strings.TrimSpace(c)) + if len(c) != 2 || c == "XX" || c == "T1" { + return "" + } + return c +} + // acquire takes an in-flight slot, or gives up. // // The cap exists twice over: it keeps a 1 GiB box from being asked to @@ -191,9 +220,17 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request, route policy.Ro if accept := r.Header.Get("Accept"); accept != "" { up.Header.Set("Accept", accept) } - up.Header.Set("Authorization", "Bearer "+s.cfg.UpstreamKey) + secret, fingerprint, err := s.keys.Next() + if err != nil { + if billable { + s.ledger.Refund(subject, reserve) + } + s.writeLimit(w, "a.LimitError{Reason: quota.ReasonCredits}) + return + } + up.Header.Set("Authorization", "Bearer "+secret) if route.AnthropicAuth { - up.Header.Set("x-api-key", s.cfg.UpstreamKey) + up.Header.Set("x-api-key", secret) version := r.Header.Get("anthropic-version") if version == "" { version = "2023-06-01" @@ -214,6 +251,7 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request, route policy.Ro if billable { cost := meter.Cost(d.Model, meter.Usage{InputTokens: len(d.Body) + 1}) s.ledger.Charge(subject, route.Name, d.Model, len(d.Body)/4+1, 0, 0, cost, reserve, true) + s.stats.Charged(len(d.Body)/4+1, 0) } return // there is nobody to tell } @@ -227,6 +265,18 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request, route policy.Ro } defer resp.Body.Close() + // A key that upstream refuses for money or validity is done, and + // leaves the rotation now rather than after it has failed everyone + // else's request too. Other 4xx are about the request, not the key. + switch resp.StatusCode { + case http.StatusPaymentRequired: + s.keys.MarkDry(fingerprint, "DeepSeek answered 402: out of credit") + s.invalidateStatus() + case http.StatusUnauthorized, http.StatusForbidden: + s.keys.MarkDry(fingerprint, "DeepSeek rejected this key: "+resp.Status) + s.invalidateStatus() + } + if route.Name == "models" && resp.StatusCode == http.StatusOK { s.relayModels(w, resp) return @@ -288,12 +338,14 @@ func (s *Server) forward(w http.ResponseWriter, r *http.Request, route policy.Ro // it could have cost. Unbillable must never mean free, or it // becomes the way in. s.ledger.Charge(subject, route.Name, model, len(d.Body)/4+1, 0, d.MaxTokens, reserve, reserve, true) + s.stats.Charged(len(d.Body)/4+1, d.MaxTokens) return } s.ledger.Charge(subject, route.Name, model, usage.InputTokens, usage.CacheHitTokens, usage.OutputTokens, meter.Cost(model, usage), reserve, false) + s.stats.Charged(usage.InputTokens, usage.OutputTokens) } // relayModels forwards the model list, minus the models this gateway diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go index e07c99d..4a0be67 100644 --- a/gateway/internal/server/server.go +++ b/gateway/internal/server/server.go @@ -8,14 +8,17 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "strings" "sync" "sync/atomic" "time" + "github.com/thevibeworks/deepseek-cli/gateway/internal/keyring" "github.com/thevibeworks/deepseek-cli/gateway/internal/mint" "github.com/thevibeworks/deepseek-cli/gateway/internal/quota" + "github.com/thevibeworks/deepseek-cli/gateway/internal/stats" "github.com/thevibeworks/deepseek-cli/gateway/internal/token" ) @@ -25,10 +28,17 @@ import ( type Config struct { // UpstreamBaseURL is DeepSeek's API root. UpstreamBaseURL string - // UpstreamKey is our real API key. It never leaves this process. - UpstreamKey string + // UpstreamKeys are the real API keys this service spends. They never + // leave this process. More than one is a pool: requests rotate across + // them and an emptied key retires itself, so a donation extends the + // service without a restart. + UpstreamKeys []string + // KeyStatePath persists donated keys across restarts. + KeyStatePath string // Model is the only model the free tier serves. Model string + // Version is the build, shown on the status page. + Version string MaxBodyBytes int64 MaxTokens int @@ -79,6 +89,13 @@ type Server struct { mint *mint.Mint signer *token.Signer ledger *quota.Ledger + keys *keyring.Ring + stats *stats.Collector + + // statusDoc caches the public status document; see publicStatusTTL. + statusMu sync.Mutex + statusDoc *PublicStatus + statusAt time.Time http *http.Client inflight chan struct{} @@ -139,6 +156,8 @@ func New(cfg Config, signer *token.Signer, m *mint.Mint, ledger *quota.Ledger) * return http.ErrUseLastResponse }, }, + keys: keyring.New(cfg.UpstreamKeys, cfg.KeyStatePath), + stats: stats.New(), inflight: make(chan struct{}, cfg.MaxInflight), limiter: newLimiter(cfg.RequestsPerMinute, time.Minute), subjLimiter: newLimiter(cfg.SubjectRequestsPerMinute, time.Minute), @@ -148,6 +167,9 @@ func New(cfg Config, signer *token.Signer, m *mint.Mint, ledger *quota.Ledger) * } } +// Keys exposes the pool so an operator command can seed it at boot. +func (s *Server) Keys() *keyring.Ring { return s.keys } + // acquireSubject takes one of a token's concurrency slots, or reports // that they are all in use. func (s *Server) acquireSubject(subject string) bool { @@ -183,6 +205,12 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /admin/health", s.handleAdminHealth) + // The dashboard and what feeds it. + mux.HandleFunc("GET /v1/status", s.handleStatus) + mux.HandleFunc("GET /admin/status", s.handleAdminStatus) + mux.HandleFunc("/admin/keys", s.handleAdminKeys) + s.routeWeb(mux) + // The balance endpoint is answered locally rather than proxied: the // upstream figure is our account's, and it is nobody else's business. mux.HandleFunc("GET /user/balance", s.handleBalance) @@ -330,34 +358,79 @@ func (s *Server) StartBalanceWatch(ctx context.Context, interval time.Duration) }() } +// checkBalance asks DeepSeek about every key in the pool and retires the +// ones it says are done. Checking each key rather than a representative +// one is the point: with a pool, "are we out of money" is a question per +// key, and a single dry donation should not condemn the rest. func (s *Server) checkBalance(ctx context.Context) { + any := false + // Dry keys are checked too, not skipped — that is how a donor who + // topped their key up gets back into rotation without anyone noticing + // by hand. Only an operator retirement is permanent. + for _, fp := range s.keys.Fingerprints() { + switch s.keyAvailable(ctx, fp) { + case availYes: + any = true + if s.keys.MarkFunded(fp) { + log.Printf("key %s has credit again and is back in rotation", fp) + } + case availNo: + s.keys.MarkDry(fp, "DeepSeek reports no balance on this key") + case availUnknown: + // Network trouble is not "out of money". A key already in + // rotation stays; one already dry stays dry until upstream + // actually answers for it. + any = true + } + } + s.upstreamDry.Store(!any) + s.invalidateStatus() +} + +type availability int + +const ( + availUnknown availability = iota + availYes + availNo +) + +func (s *Server) keyAvailable(ctx context.Context, fingerprint string) availability { + secret, ok := s.keys.Secret(fingerprint) + if !ok { + return availUnknown + } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(s.cfg.UpstreamBaseURL, "/")+"/user/balance", nil) if err != nil { - return + return availUnknown } - req.Header.Set("Authorization", "Bearer "+s.cfg.UpstreamKey) + req.Header.Set("Authorization", "Bearer "+secret) req.Header.Set("User-Agent", "dsgate") resp, err := s.http.Do(req) if err != nil { - // Network trouble is not "out of money". The last known state - // stands until DeepSeek says otherwise. - return + return availUnknown } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return availNo // the key is not valid at all + } if resp.StatusCode != http.StatusOK { - return + return availUnknown } var b struct { IsAvailable bool `json:"is_available"` } if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b) != nil { - return + return availUnknown + } + if b.IsAvailable { + return availYes } - s.upstreamDry.Store(!b.IsAvailable) + return availNo } // --- simple endpoints --------------------------------------------------- @@ -370,14 +443,15 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleAdminHealth(w http.ResponseWriter, r *http.Request) { - if s.cfg.AdminToken == "" || r.Header.Get("X-Admin-Token") != s.cfg.AdminToken { + if !s.adminOK(r) { writeError(w, http.StatusNotFound, typeRejected, "not found") return } writeJSON(w, http.StatusOK, struct { quota.Health UpstreamAvailable bool `json:"upstream_available"` - }{s.ledger.Health(), !s.upstreamDry.Load()}) + Keys int `json:"keys_active"` + }{s.ledger.Health(), !s.upstreamDry.Load(), s.keys.Status(false).Active}) } // Info is the unauthenticated description of the service, so a client can diff --git a/gateway/internal/server/server_test.go b/gateway/internal/server/server_test.go index 7c737d1..35e4790 100644 --- a/gateway/internal/server/server_test.go +++ b/gateway/internal/server/server_test.go @@ -94,7 +94,7 @@ func newHarness(t *testing.T, up *upstream, tune func(*Config, *quota.Limits)) * } cfg := Config{ UpstreamBaseURL: up.server.URL, - UpstreamKey: upstreamKey, + UpstreamKeys: []string{upstreamKey}, Model: "deepseek-v4-flash", MaxBodyBytes: 4096, MaxTokens: 256, diff --git a/gateway/internal/server/status.go b/gateway/internal/server/status.go new file mode 100644 index 0000000..48828f7 --- /dev/null +++ b/gateway/internal/server/status.go @@ -0,0 +1,352 @@ +package server + +import ( + "net/http" + "strings" + "time" + + "github.com/thevibeworks/deepseek-cli/gateway/internal/keyring" + "github.com/thevibeworks/deepseek-cli/gateway/internal/quota" + "github.com/thevibeworks/deepseek-cli/gateway/internal/stats" +) + +// The public status document. +// +// What it says and what it refuses to say are both deliberate. It carries +// enough for a stranger to decide whether to bother enrolling — is it up, +// is there credit, how busy is it — and enough for the dashboard to be +// worth looking at. It carries no dollar figures, no account balance, and +// no whole subject id. +// +// Dollars stay operator-only because publishing them turns the budget +// breaker into a progress bar for whoever wants to trip it: "$0.19 of +// $0.25 spent" tells an attacker exactly how much more to send. The same +// facts as percentages answer the honest question — how much is left — +// without handing over the target. Exact figures live behind +// /admin/status. +type PublicStatus struct { + Service string `json:"service"` + Version string `json:"version"` + Model string `json:"model"` + Announce string `json:"announce,omitempty"` + + // State is the one word a status page leads with. + State string `json:"state"` + Detail string `json:"detail"` + + Credit CreditStatus `json:"credit"` + Usage UsageStatus `json:"usage"` + Live stats.Live `json:"live"` + + Endpoints []stats.Count `json:"endpoints"` + Countries []stats.Count `json:"countries"` + Top []TopSubject `json:"top_subjects"` + + Keys PoolStatus `json:"key_pool"` + Limits quota.UserCaps `json:"daily_limits_per_user"` + System stats.System `json:"system"` + + ResetsAt time.Time `json:"resets_at"` + Now time.Time `json:"now"` +} + +// Service states, coarsest first. A status page that only ever says "up" +// or "down" is useless on the day it matters; these are the four +// distinctions a user can actually act on. +const ( + StateOperational = "operational" // everything works + StateBusy = "busy" // at the concurrency cap, requests queue + StateDayspent = "day_exhausted" // today's budget is gone; back at 00:00 UTC + StateDry = "credit_exhausted" + StateDegraded = "degraded" // the ledger cannot record spend +) + +// CreditStatus is the pool, in proportions rather than dollars. +type CreditStatus struct { + DayRemainingPct float64 `json:"day_remaining_pct"` + PoolRemainingPct float64 `json:"pool_remaining_pct"` + // Donated is how many keys strangers have added. It is the one number + // that makes the donation ask concrete. + Donated int `json:"donated_keys"` +} + +// UsageStatus is what the service has served. +type UsageStatus struct { + Today quota.Totals `json:"today"` + Lifetime quota.Totals `json:"lifetime"` + // Subjects seen today, which is the closest honest thing to a user + // count: no account exists, so "users" can only ever mean "distinct + // anonymous identities that sent something". + SubjectsToday int `json:"subjects_today"` +} + +// TopSubject is one row of the leaderboard, with the id cut short. +type TopSubject struct { + Subject string `json:"subject"` + Requests int `json:"requests"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// PoolStatus is the key pool without the keys. +type PoolStatus struct { + Active int `json:"active"` + Dry int `json:"dry"` + Retired int `json:"retired"` + Total int `json:"total"` +} + +// withoutMoney blanks the cost field before a total is published. +// +// The token counts are the interesting half and they stay; the dollars +// are what turn this document into a countdown for whoever wants to empty +// the pool. The field itself is kept rather than dropped so the shape of +// the JSON does not change between the public and operator views. +func withoutMoney(t quota.Totals) quota.Totals { + t.SpentUSD = 0 + return t +} + +// publicStatusTTL caches the document. The dashboard polls, several +// people may have it open, and every field is a five-minute rolling +// figure — recomputing per request would spend more CPU on watching the +// service than on running it. +const publicStatusTTL = 3 * time.Second + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if !s.limitMeta(w, r) { + return + } + s.statusMu.Lock() + if s.statusDoc != nil && time.Since(s.statusAt) < publicStatusTTL { + doc := s.statusDoc + s.statusMu.Unlock() + writeJSON(w, http.StatusOK, doc) + return + } + s.statusMu.Unlock() + + doc := s.buildStatus() + + s.statusMu.Lock() + s.statusDoc, s.statusAt = doc, time.Now() + s.statusMu.Unlock() + + writeJSON(w, http.StatusOK, doc) +} + +func (s *Server) buildStatus() *PublicStatus { + h := s.ledger.Health() + snap := s.stats.Snapshot() + pool := s.keys.Status(false) + + dayLeft := pct(h.DailyBudgetUSD-h.DaySpendUSD-h.ReservedUSD, h.DailyBudgetUSD) + poolLeft := pct(h.TotalBudgetUSD-h.TotalSpendUSD-h.ReservedUSD, h.TotalBudgetUSD) + + state, detail := s.state(h, pool, snap) + + top := s.ledger.TopSubjects(10) + rows := make([]TopSubject, 0, len(top)) + for _, u := range top { + rows = append(rows, TopSubject{ + Subject: shortSubject(u.Subject), + Requests: u.Requests, + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + }) + } + + return &PublicStatus{ + Service: "dsgate", Version: s.cfg.Version, Model: s.cfg.Model, + Announce: s.cfg.Announce, + State: state, Detail: detail, + Credit: CreditStatus{ + DayRemainingPct: dayLeft, + PoolRemainingPct: poolLeft, + Donated: donatedCount(s.keys), + }, + Usage: UsageStatus{ + Today: withoutMoney(s.ledger.Today()), + Lifetime: withoutMoney(s.ledger.Lifetime()), + SubjectsToday: h.Subjects, + }, + Live: snap.Live, + Endpoints: snap.Endpoints, + Countries: snap.Countries, + Top: rows, + Keys: PoolStatus{Active: pool.Active, Dry: pool.Dry, Retired: pool.Retired, Total: pool.Total}, + Limits: s.ledger.Status("", "anon").Limits, + System: snap.System, + ResetsAt: midnightUTC(time.Now()), + Now: time.Now().UTC(), + } +} + +// state reduces everything to the one word the page leads with, in the +// order a user cares about: can I use it at all, then is it degraded, +// then is it merely busy. +func (s *Server) state(h quota.Health, pool keyring.Status, snap stats.Snapshot) (string, string) { + switch { + case pool.Active == 0: + return StateDry, "no upstream key in the pool has credit left — bring your own key, or donate one" + case s.upstreamDry.Load(): + return StateDry, "DeepSeek reports the funding account is out of credit" + case h.TotalSpendUSD >= h.TotalBudgetUSD: + return StateDry, "the shared credit pool is spent — bring your own key, or donate one" + case !h.JournalOK: + return StateDegraded, "the spend journal is not writable, so requests are refused until it is" + case h.DaySpendUSD >= h.DailyBudgetUSD: + return StateDayspent, "today's shared budget is spent; it resets at 00:00 UTC" + case snap.Live.InFlight >= int64(s.cfg.MaxInflight): + return StateBusy, "every slot is in use right now; requests may queue briefly" + default: + return StateOperational, "free DeepSeek access is working — no key, no account" + } +} + +// shortSubject cuts an id down to something that identifies a row on a +// leaderboard without identifying the holder. A whole subject can be +// matched against the one in someone's free.json; six characters of +// base64url cannot be, and still reads as a name. +func shortSubject(sub string) string { + if len(sub) <= 6 { + return sub + } + return sub[:6] + "…" +} + +func donatedCount(r *keyring.Ring) int { + n := 0 + for _, k := range r.Status(true).Keys { + if k.Source == keyring.SourceDonor { + n++ + } + } + return n +} + +// pct is what is left, as a percentage, clamped. A negative remainder +// (reservations can briefly exceed the balance) reads as zero rather than +// as a negative bar. +func pct(remaining, total float64) float64 { + if total <= 0 { + return 0 + } + p := remaining / total * 100 + switch { + case p < 0: + return 0 + case p > 100: + return 100 + } + return float64(int64(p*10+0.5)) / 10 +} + +func midnightUTC(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) +} + +// --- operator view ------------------------------------------------------- + +// AdminStatus is everything the public document withholds: exact money, +// per-key health, journal durability. +type AdminStatus struct { + *PublicStatus + Health quota.Health `json:"ledger"` + UpstreamAvailable bool `json:"upstream_available"` + KeyDetail []keyring.Key `json:"keys"` + Subjects []quota.SubjectUsage `json:"subjects_today"` +} + +func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { + if !s.adminOK(r) { + writeError(w, http.StatusNotFound, typeRejected, "not found") + return + } + writeJSON(w, http.StatusOK, AdminStatus{ + PublicStatus: s.buildStatus(), + Health: s.ledger.Health(), + UpstreamAvailable: !s.upstreamDry.Load(), + KeyDetail: s.keys.Status(true).Keys, + Subjects: s.ledger.TopSubjects(200), + }) +} + +func (s *Server) adminOK(r *http.Request) bool { + if s.cfg.AdminToken == "" { + return false + } + return subtleEqual(r.Header.Get("X-Admin-Token"), s.cfg.AdminToken) +} + +// --- key donation -------------------------------------------------------- + +// donateRequest is an operator handing the pool another key. +type donateRequest struct { + Key string `json:"key"` + Label string `json:"label"` +} + +// handleAdminKeys lists, adds, retires, revives and removes keys without +// a restart, so a donation lands in seconds and a compromised key leaves +// in seconds. +// +// It is admin-gated rather than public on purpose. A public "paste your +// API key here" form would be a service that collects other people's +// credentials over the open internet, and teaching users that habit is +// worse than the friction it saves — the donation path is a private +// message to a human, who adds it here. +func (s *Server) handleAdminKeys(w http.ResponseWriter, r *http.Request) { + if !s.adminOK(r) { + writeError(w, http.StatusNotFound, typeRejected, "not found") + return + } + + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.keys.Status(true)) + + case http.MethodPost: + var req donateRequest + if err := decodeJSON(r, 8<<10, &req); err != nil { + writeError(w, http.StatusBadRequest, typeRejected, err.Error()) + return + } + if strings.TrimSpace(req.Key) == "" { + writeError(w, http.StatusBadRequest, typeRejected, "no key given") + return + } + fp, added := s.keys.Donate(req.Key, req.Label) + s.invalidateStatus() + writeJSON(w, http.StatusOK, map[string]any{ + "fingerprint": fp, "added": added, + "pool": s.keys.Status(false), + }) + + case http.MethodDelete: + fp := r.URL.Query().Get("fingerprint") + action := r.URL.Query().Get("action") + var ok bool + switch action { + case "retire": + s.keys.Retire(fp, "retired by operator") + ok = true + case "revive": + ok = s.keys.Revive(fp) + default: + ok = s.keys.Remove(fp) + } + s.invalidateStatus() + writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "pool": s.keys.Status(false)}) + + default: + writeError(w, http.StatusMethodNotAllowed, typeRejected, "GET, POST or DELETE") + } +} + +func (s *Server) invalidateStatus() { + s.statusMu.Lock() + s.statusDoc = nil + s.statusMu.Unlock() +} diff --git a/gateway/internal/server/status_test.go b/gateway/internal/server/status_test.go new file mode 100644 index 0000000..2cfe6f9 --- /dev/null +++ b/gateway/internal/server/status_test.go @@ -0,0 +1,341 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/thevibeworks/deepseek-cli/gateway/internal/quota" +) + +func getStatus(t *testing.T, h *harness) PublicStatus { + t.Helper() + resp := h.do(t, "GET", "/v1/status", "", "") + defer resp.Body.Close() + if resp.StatusCode != 200 { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status: HTTP %d: %s", resp.StatusCode, raw) + } + var st PublicStatus + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatal(err) + } + return st +} + +func TestStatusIsUnauthenticatedAndUsable(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(85, 40)) + }) + h := newHarness(t, up, nil) + tok := h.enrol(t) + h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`).Body.Close() + h.settle(t) + + st := getStatus(t, h) + if st.State != StateOperational { + t.Errorf("state = %q, want %q", st.State, StateOperational) + } + if st.Detail == "" { + t.Error("state has no human explanation") + } + if st.Usage.Today.Requests != 1 { + t.Errorf("today's requests = %d, want 1", st.Usage.Today.Requests) + } + if st.Usage.Today.InputTokens != 85 || st.Usage.Today.OutputTokens != 40 { + t.Errorf("today's tokens = %+v, want 85 in / 40 out", st.Usage.Today) + } + if st.Usage.SubjectsToday != 1 { + t.Errorf("subjects_today = %d, want 1", st.Usage.SubjectsToday) + } + if len(st.Live.Series) == 0 { + t.Error("the sparkline series is empty") + } + if st.Keys.Active != 1 { + t.Errorf("key_pool.active = %d, want 1", st.Keys.Active) + } + if st.Limits.Requests == 0 { + t.Error("the per-user limits are not reported") + } +} + +// The public document is the one an attacker reads too. It must not carry +// the numbers that turn the budget breaker into a progress bar. +func TestStatusWithholdsMoney(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(1000, 900)) + }) + h := newHarness(t, up, nil) + tok := h.enrol(t) + h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`).Body.Close() + h.settle(t) + + resp := h.do(t, "GET", "/v1/status", "", "") + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + + var st PublicStatus + json.Unmarshal(raw, &st) + if st.Usage.Today.SpentUSD != 0 || st.Usage.Lifetime.SpentUSD != 0 { + t.Errorf("the public document reports spend: today=%v lifetime=%v", + st.Usage.Today.SpentUSD, st.Usage.Lifetime.SpentUSD) + } + for _, banned := range []string{"daily_budget_usd", "total_budget_usd", "day_spend_usd", "total_spend_usd", "reserved_usd"} { + if strings.Contains(string(raw), banned) { + t.Errorf("the public document carries %q", banned) + } + } + // Percentages are the point: they answer "how much is left" without + // saying how much more it would take to empty it. + if st.Credit.PoolRemainingPct <= 0 || st.Credit.PoolRemainingPct > 100 { + t.Errorf("pool_remaining_pct = %v, want a sane percentage", st.Credit.PoolRemainingPct) + } +} + +// A whole subject id can be matched against the one in someone's +// free.json. The leaderboard must not publish one. +func TestStatusTruncatesSubjectIDs(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, nil) + tok := h.enrol(t) + full := subjectOf(t, tok) + h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`).Body.Close() + h.settle(t) + + resp := h.do(t, "GET", "/v1/status", "", "") + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if strings.Contains(string(raw), full) { + t.Fatal("the status page published a whole subject id") + } + + var st PublicStatus + json.Unmarshal(raw, &st) + if len(st.Top) != 1 { + t.Fatalf("top_subjects has %d rows, want 1", len(st.Top)) + } + if !strings.HasPrefix(full, strings.TrimSuffix(st.Top[0].Subject, "…")) { + t.Errorf("the truncated id %q is not a prefix of the real one", st.Top[0].Subject) + } + if st.Top[0].Requests != 1 { + t.Errorf("leaderboard requests = %d, want 1", st.Top[0].Requests) + } +} + +// Geography is aggregate or it is nothing. +func TestStatusCountsCountriesNotAddresses(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, nil) + tok := h.enrol(t) + + req, _ := http.NewRequest("POST", h.base+"/chat/completions", strings.NewReader(`{"messages":[]}`)) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("CF-IPCountry", "DE") + resp, err := h.client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + h.settle(t) + + st := getStatus(t, h) + if len(st.Countries) != 1 || st.Countries[0].Name != "DE" { + t.Fatalf("countries = %+v, want DE", st.Countries) + } + // And the country must not be attached to the subject anywhere. + if len(st.Top) > 0 && strings.Contains(st.Top[0].Subject, "DE") { + t.Error("a country was joined onto a subject id") + } +} + +func TestStatusReportsExhaustion(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, func(cfg *Config, lim *quota.Limits) { + lim.TotalBudgetUSD = 0.001 + }) + // Spend the pool for real rather than configuring it to zero: the + // state has to follow actual spend, which is the thing that goes + // wrong in production. + h.ledger.Admit("someone", 0) + h.ledger.Charge("someone", "chat", "deepseek-v4-flash", 10, 0, 10, 0.002, 0, false) + + st := getStatus(t, h) + if st.State != StateDry { + t.Errorf("state = %q with an empty pool, want %q", st.State, StateDry) + } + if !strings.Contains(st.Detail, "key") { + t.Errorf("the exhausted detail does not point anywhere useful: %q", st.Detail) + } +} + +func TestAdminStatusNeedsTheTokenAndCarriesMoney(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, func(cfg *Config, lim *quota.Limits) { + cfg.AdminToken = "operator-only" + }) + + resp := h.do(t, "GET", "/admin/status", "", "") + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("unauthenticated /admin/status: HTTP %d, want 404", resp.StatusCode) + } + + req, _ := http.NewRequest("GET", h.base+"/admin/status", nil) + req.Header.Set("X-Admin-Token", "operator-only") + resp2, err := h.client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Fatalf("authenticated /admin/status: HTTP %d", resp2.StatusCode) + } + raw, _ := io.ReadAll(resp2.Body) + if !strings.Contains(string(raw), "total_budget_usd") { + t.Error("the operator view withholds the budget it exists to show") + } + // The operator view shows keys, but still never the key itself. + if strings.Contains(string(raw), upstreamKey) { + t.Fatal("the operator view leaked the upstream key") + } +} + +func TestAdminKeysDonateAndRetire(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, func(cfg *Config, lim *quota.Limits) { + cfg.AdminToken = "operator-only" + }) + + admin := func(method, path, body string) *http.Response { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, _ := http.NewRequest(method, h.base+path, rdr) + req.Header.Set("X-Admin-Token", "operator-only") + req.Header.Set("Content-Type", "application/json") + resp, err := h.client.Do(req) + if err != nil { + t.Fatal(err) + } + return resp + } + + resp := admin("POST", "/admin/keys", `{"key":"sk-donated-by-a-stranger","label":"anon"}`) + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("donate: HTTP %d", resp.StatusCode) + } + var out struct { + Fingerprint string `json:"fingerprint"` + Added bool `json:"added"` + } + json.NewDecoder(resp.Body).Decode(&out) + if !out.Added { + t.Fatal("the donated key was not added") + } + + st := getStatus(t, h) + if st.Keys.Total != 2 || st.Keys.Active != 2 { + t.Errorf("key_pool = %+v, want 2 total / 2 active", st.Keys) + } + if st.Credit.Donated != 1 { + t.Errorf("donated_keys = %d, want 1", st.Credit.Donated) + } + + resp2 := admin("DELETE", "/admin/keys?fingerprint="+strings.ReplaceAll(out.Fingerprint, "…", "%E2%80%A6")+"&action=retire", "") + resp2.Body.Close() + + // Unauthenticated callers get nothing from any of it. + resp3 := h.do(t, "GET", "/admin/keys", "", "") + resp3.Body.Close() + if resp3.StatusCode != http.StatusNotFound { + t.Errorf("unauthenticated /admin/keys: HTTP %d, want 404", resp3.StatusCode) + } +} + +// A key upstream refuses with 402 must leave the rotation, and the next +// request must go out on a different one rather than failing. +func TestExhaustedKeyStepsAsideAndTheNextKeyServes(t *testing.T) { + var seen []string + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + seen = append(seen, auth) + if auth == "sk-empty" { + w.WriteHeader(http.StatusPaymentRequired) + io.WriteString(w, `{"error":{"message":"Insufficient Balance"}}`) + return + } + io.WriteString(w, chatReply(10, 10)) + }) + h := newHarness(t, up, func(cfg *Config, lim *quota.Limits) { + cfg.UpstreamKeys = []string{"sk-empty", "sk-funded"} + lim.DailyRequests = 50 + }) + tok := h.enrol(t) + + // First request lands on whichever key the rotation starts with; by + // the third, the empty one must be out of the pool for good. + for i := 0; i < 3; i++ { + h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`).Body.Close() + h.settle(t) + } + if got := h.Server.keys.Status(false); got.Dry != 1 || got.Active != 1 { + t.Fatalf("pool = %+v, want the empty key dry and one active", got) + } + + seen = nil + resp := h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`) + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("after retirement: HTTP %d, want 200 on the funded key", resp.StatusCode) + } + for _, k := range seen { + if k == "sk-empty" { + t.Error("a retired key was used again") + } + } +} + +// With every key gone the service must say so honestly rather than +// relaying a confusing upstream error. +func TestEmptyPoolIsAnHonest402(t *testing.T) { + up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + io.WriteString(w, `{"error":{"message":"Insufficient Balance"}}`) + }) + h := newHarness(t, up, func(cfg *Config, lim *quota.Limits) { + cfg.UpstreamKeys = []string{"sk-empty"} + lim.DailyRequests = 50 + }) + tok := h.enrol(t) + + h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`).Body.Close() + h.settle(t) + + resp := h.do(t, "POST", "/chat/completions", tok, `{"messages":[]}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusPaymentRequired { + t.Fatalf("HTTP %d with an empty pool, want 402", resp.StatusCode) + } + if e := decodeError(t, resp); !strings.Contains(e.Message, "your own key") { + t.Errorf("the 402 does not point at the way forward: %q", e.Message) + } + if st := getStatus(t, h); st.State != StateDry { + t.Errorf("state = %q, want %q", st.State, StateDry) + } +} diff --git a/gateway/internal/server/web.go b/gateway/internal/server/web.go new file mode 100644 index 0000000..4e3b6e9 --- /dev/null +++ b/gateway/internal/server/web.go @@ -0,0 +1,146 @@ +package server + +import ( + "crypto/subtle" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "strings" +) + +// The dashboard, and the pages that explain what this service is and what +// it does with a prompt. +// +// They are embedded rather than served from disk so that the gateway +// stays one binary with nothing beside it — the same property that earns +// it a place on a box running someone else's production. It also means +// the page and the JSON it reads can never be different versions of each +// other, which is the usual way a status page starts lying. +// +//go:embed web/index.html web/style.css web/app.js web/pages/*.html +var webFS embed.FS + +// webCacheSec is how long a browser may hold these files. Short, because +// the whole point of a status page is that it is current, and the assets +// are a few kilobytes on a service that serves megabytes of tokens. +const webCacheSec = 300 + +// routeWeb mounts the site. Every path is explicit: a file server rooted +// at an embedded directory would happily serve anything that later lands +// in it, and this binary holds an API key. +func (s *Server) routeWeb(mux *http.ServeMux) { + mux.HandleFunc("GET /{$}", s.serveAsset("web/index.html", "text/html; charset=utf-8")) + mux.HandleFunc("GET /style.css", s.serveAsset("web/style.css", "text/css; charset=utf-8")) + mux.HandleFunc("GET /app.js", s.serveAsset("web/app.js", "text/javascript; charset=utf-8")) + + for _, page := range []string{"privacy", "terms", "story", "vision", "economics"} { + h := s.serveAsset("web/pages/"+page+".html", "text/html; charset=utf-8") + mux.HandleFunc("GET /"+page, h) + mux.HandleFunc("GET /"+page+"/", h) + } + + mux.HandleFunc("GET /robots.txt", s.serveRobots) + mux.HandleFunc("GET /sitemap.xml", s.serveSitemap) +} + +func (s *Server) serveAsset(name, contentType string) http.HandlerFunc { + body, err := webFS.ReadFile(name) + return func(w http.ResponseWriter, r *http.Request) { + if err != nil { + // A missing asset is a build mistake, not a runtime condition. + // Saying so beats serving an empty page that looks like an + // outage. + writeError(w, http.StatusInternalServerError, typeInternal, + "this build is missing "+name) + return + } + h := w.Header() + h.Set("Content-Type", contentType) + h.Set("Cache-Control", fmt.Sprintf("public, max-age=%d", webCacheSec)) + // The page loads nothing from anywhere else and posts nowhere, so + // it can say so. connect-src stays 'self' because the dashboard + // polls its own /v1/status. + h.Set("Content-Security-Policy", + "default-src 'none'; style-src 'self'; script-src 'self' 'unsafe-inline'; "+ + "connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + w.Write(body) + } +} + +func (s *Server) serveRobots(w http.ResponseWriter, r *http.Request) { + base := s.publicBase() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + // The API paths are not content and crawling them wastes the pool's + // rate limits on robots. The pages are the point, so they stay open. + fmt.Fprintf(w, "User-agent: *\nAllow: /$\nAllow: /privacy\nAllow: /terms\nAllow: /story\nAllow: /vision\nAllow: /economics\n"+ + "Disallow: /v1/\nDisallow: /admin/\nDisallow: /chat/\nDisallow: /responses\nDisallow: /anthropic/\n\nSitemap: %s/sitemap.xml\n", base) +} + +func (s *Server) serveSitemap(w http.ResponseWriter, r *http.Request) { + base := s.publicBase() + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + for _, p := range []struct { + path string + pri string + freq string + }{ + {"/", "1.0", "hourly"}, + {"/story", "0.8", "monthly"}, + {"/vision", "0.8", "monthly"}, + {"/economics", "0.8", "monthly"}, + {"/privacy", "0.5", "yearly"}, + {"/terms", "0.5", "yearly"}, + } { + fmt.Fprintf(&b, " %s%s%s%s\n", + base, p.path, p.freq, p.pri) + } + b.WriteString("\n") + io.WriteString(w, b.String()) +} + +// publicBase is the URL this service is reached at, for the absolute URLs +// a sitemap and a canonical tag require. +func (s *Server) publicBase() string { + if s.cfg.Announce != "" { + return strings.TrimRight(s.cfg.Announce, "/") + } + return "https://freeseek.1lm.io" +} + +// --- small shared helpers ------------------------------------------------ + +// decodeJSON reads a size-limited JSON body. +func decodeJSON(r *http.Request, limit int64, v any) error { + dec := json.NewDecoder(io.LimitReader(r.Body, limit)) + if err := dec.Decode(v); err != nil { + return fmt.Errorf("could not read the request: %w", err) + } + return nil +} + +// subtleEqual compares two secrets in constant time, so an admin token +// cannot be recovered a byte at a time by timing the comparison. +func subtleEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// assetNames lists what got embedded, for the build-time test that keeps +// this file and the web directory honest with each other. +func assetNames() []string { + var out []string + fs.WalkDir(webFS, "web", func(p string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + out = append(out, p) + } + return nil + }) + return out +} diff --git a/gateway/internal/server/web/app.js b/gateway/internal/server/web/app.js new file mode 100644 index 0000000..119a9c0 --- /dev/null +++ b/gateway/internal/server/web/app.js @@ -0,0 +1,482 @@ +/* freeseek status dashboard — vanilla JS, no dependencies. + Polls GET /v1/status every 5s; exponential backoff to 60s on failure. */ +"use strict"; +(function () { + var $ = function (id) { return document.getElementById(id); }; + var reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + + /* ---------- formatting ---------- */ + + function fmtCompact(n) { + if (typeof n !== "number" || !isFinite(n)) return "—"; + var neg = n < 0 ? "-" : ""; + var a = Math.abs(n); + if (a < 1000) return neg + String(Math.round(a)); + var units = [[1e9, "B"], [1e6, "M"], [1e3, "k"]]; + for (var i = 0; i < units.length; i++) { + if (a >= units[i][0]) { + var v = a / units[i][0]; + var s = v >= 100 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, ""); + return neg + s + units[i][1]; + } + } + return neg + String(a); + } + + function fmtInt(n) { + if (typeof n !== "number" || !isFinite(n)) return "—"; + return Math.round(n).toLocaleString("en-US"); + } + + function fmtDur(sec) { + if (typeof sec !== "number" || !isFinite(sec) || sec < 0) return "—"; + sec = Math.floor(sec); + var d = Math.floor(sec / 86400); + var h = Math.floor((sec % 86400) / 3600); + var m = Math.floor((sec % 3600) / 60); + var s = sec % 60; + if (d > 0) return d + "d " + h + "h"; + if (h > 0) return h + "h " + m + "m"; + if (m > 0) return m + "m " + s + "s"; + return s + "s"; + } + + function fmtRate(n) { + if (typeof n !== "number" || !isFinite(n)) return "—"; + return n >= 100 ? String(Math.round(n)) : n.toFixed(1); + } + + function setText(id, text) { + var el = $(id); + if (el) el.textContent = text; + } + + /* ---------- theme ---------- */ + + function currentTheme() { + var t = document.documentElement.dataset.theme; + if (t === "dark" || t === "light") return t; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + $("theme-toggle").addEventListener("click", function () { + var next = currentTheme() === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { localStorage.setItem("theme", next); } catch (e) { /* private mode */ } + chart.refreshColors(); + chart.draw(); + }); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function () { + chart.refreshColors(); + chart.draw(); + }); + + /* ---------- copy button ---------- */ + + var copyBtn = $("copy-btn"); + copyBtn.addEventListener("click", function () { + var text = $("install-code").innerText; + function done(msg) { + copyBtn.textContent = msg; + setTimeout(function () { copyBtn.textContent = "copy"; }, 1600); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function () { done("copied"); }, + function () { done("failed"); } + ); + } else { + done("failed"); + } + }); + + /* ---------- state pill ---------- */ + + // state -> [label, css class]. cyan = ok, yellow = busy/degraded, pink = alerts. + var STATES = { + operational: ["operational", "st-ok"], + busy: ["busy", "st-busy"], + degraded: ["degraded", "st-busy"], + day_exhausted: ["daily budget exhausted", "st-warn"], + credit_exhausted: ["credit pool exhausted", "st-warn"] + }; + + function setPill(label, cls) { + var pill = $("pill"); + pill.textContent = label; + pill.className = "pill " + cls; + } + + /* ---------- sparkline chart ---------- */ + + var chart = (function () { + var canvas = $("spark"); + var ctx = canvas.getContext("2d"); + var target = []; // latest series from the server + var shown = []; // what is currently drawn (tweens toward target) + var tweenFrom = null; + var tweenStart = 0; + var raf = 0; + var hoverIdx = -1; + var colors = { line: "#00c2e9", grid: "#9a9a9a" }; + var TWEEN_MS = 280; + + function refreshColors() { + var cs = getComputedStyle(canvas); + colors.line = cs.color; + colors.grid = cs.borderTopColor || cs.borderColor || colors.grid; + } + + function size() { + var dpr = window.devicePixelRatio || 1; + var w = canvas.clientWidth || 300; + var h = canvas.clientHeight || 150; + if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { w: w, h: h }; + } + + function draw() { + var dim = size(); + var w = dim.w, h = dim.h; + var pad = 4; + ctx.clearRect(0, 0, w, h); + + var s = shown; + var n = s.length; + + // baseline + ctx.globalAlpha = 0.5; + ctx.strokeStyle = colors.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h - 0.5); + ctx.lineTo(w, h - 0.5); + ctx.stroke(); + ctx.globalAlpha = 1; + + if (n < 2) return; + + var max = 1; + for (var i = 0; i < n; i++) if (s[i] > max) max = s[i]; + + function x(i) { return (i / (n - 1)) * w; } + function y(v) { return h - pad - (v / max) * (h - pad * 2); } + + // area fill + ctx.beginPath(); + ctx.moveTo(0, h); + for (var j = 0; j < n; j++) ctx.lineTo(x(j), y(s[j])); + ctx.lineTo(w, h); + ctx.closePath(); + ctx.globalAlpha = 0.16; + ctx.fillStyle = colors.line; + ctx.fill(); + ctx.globalAlpha = 1; + + // line + ctx.beginPath(); + for (var k = 0; k < n; k++) { + if (k === 0) ctx.moveTo(x(k), y(s[k])); + else ctx.lineTo(x(k), y(s[k])); + } + ctx.strokeStyle = colors.line; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.stroke(); + + // hover crosshair + if (hoverIdx >= 0 && hoverIdx < n) { + var hx = x(hoverIdx); + ctx.globalAlpha = 0.6; + ctx.strokeStyle = colors.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(hx + 0.5, 0); + ctx.lineTo(hx + 0.5, h); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.arc(hx, y(s[hoverIdx]), 3.5, 0, Math.PI * 2); + ctx.fillStyle = colors.line; + ctx.fill(); + } + } + + function step(ts) { + var t = Math.min(1, (ts - tweenStart) / TWEEN_MS); + var e = 1 - Math.pow(1 - t, 3); // ease-out cubic + for (var i = 0; i < target.length; i++) { + var from = tweenFrom[i] || 0; + shown[i] = from + (target[i] - from) * e; + } + shown.length = target.length; + draw(); + if (t < 1) raf = requestAnimationFrame(step); + } + + function update(series) { + if (!Array.isArray(series)) series = []; + var clean = []; + for (var i = 0; i < series.length; i++) { + var v = Number(series[i]); + clean.push(isFinite(v) && v > 0 ? v : 0); + } + var prevShown = shown.slice(); + target = clean; + if (raf) cancelAnimationFrame(raf); + if (reducedMotion.matches || prevShown.length === 0) { + shown = clean.slice(); + draw(); + return; + } + // align previous frame to the new series length (both end at "now") + tweenFrom = []; + var shift = clean.length - prevShown.length; + for (var j = 0; j < clean.length; j++) { + var pi = j - shift; + tweenFrom.push(pi >= 0 && pi < prevShown.length ? prevShown[pi] : 0); + } + tweenStart = performance.now(); + raf = requestAnimationFrame(step); + } + + canvas.addEventListener("pointermove", function (ev) { + var n = shown.length; + if (n < 2) return; + var rect = canvas.getBoundingClientRect(); + var frac = (ev.clientX - rect.left) / rect.width; + hoverIdx = Math.max(0, Math.min(n - 1, Math.round(frac * (n - 1)))); + var age = n - 1 - hoverIdx; + setText("spark-readout", Math.round(shown[hoverIdx]) + " tok/s · " + + (age === 0 ? "now" : age + "s ago")); + draw(); + }); + canvas.addEventListener("pointerleave", function () { + hoverIdx = -1; + setText("spark-readout", ""); + draw(); + }); + window.addEventListener("resize", draw); + + refreshColors(); + draw(); + return { update: update, draw: draw, refreshColors: refreshColors }; + })(); + + /* ---------- gauges ---------- */ + + function setGauge(baseId, pct) { + var track = $(baseId); + var fill = $(baseId + "-fill"); + var val = $(baseId + "-val"); + if (typeof pct !== "number" || !isFinite(pct)) { + val.textContent = "—"; + return; + } + var p = Math.max(0, Math.min(100, pct)); + fill.style.width = p + "%"; + fill.classList.toggle("crit", p < 15); + fill.classList.toggle("warn", p >= 15 && p < 40); + val.textContent = p.toFixed(1) + "%"; + track.setAttribute("aria-valuenow", p.toFixed(1)); + track.setAttribute("aria-valuetext", p.toFixed(1) + "% remaining"); + } + + /* ---------- bar lists ---------- */ + + function renderBars(listId, items, nameOf) { + var ul = $(listId); + ul.textContent = ""; + if (!Array.isArray(items) || items.length === 0) { + var li = document.createElement("li"); + li.className = "empty"; + li.textContent = "no data yet"; + ul.appendChild(li); + return; + } + var max = 1; + items.forEach(function (it) { if (it.count > max) max = it.count; }); + items.forEach(function (it) { + var li = document.createElement("li"); + var name = document.createElement("span"); + name.className = "name"; + name.textContent = nameOf(it); + var bar = document.createElement("span"); + bar.className = "bar"; + bar.style.width = Math.max(2, (it.count / max) * 100) + "%"; + var count = document.createElement("span"); + count.className = "count"; + count.textContent = fmtCompact(it.count); + li.appendChild(name); + li.appendChild(bar); + li.appendChild(count); + ul.appendChild(li); + }); + } + + function flagEmoji(cc) { + if (typeof cc !== "string" || !/^[A-Za-z]{2}$/.test(cc)) return ""; + var u = cc.toUpperCase(); + return String.fromCodePoint( + 0x1f1e6 + u.charCodeAt(0) - 65, + 0x1f1e6 + u.charCodeAt(1) - 65 + ); + } + + /* ---------- subjects table ---------- */ + + function renderSubjects(rows) { + var tbody = $("subjects-body"); + tbody.textContent = ""; + if (!Array.isArray(rows) || rows.length === 0) { + var tr = document.createElement("tr"); + var td = document.createElement("td"); + td.colSpan = 4; + td.className = "empty"; + td.textContent = "no data yet"; + tr.appendChild(td); + tbody.appendChild(tr); + return; + } + rows.forEach(function (r) { + var tr = document.createElement("tr"); + [r.subject, fmtInt(r.requests), fmtCompact(r.input_tokens), fmtCompact(r.output_tokens)] + .forEach(function (v) { + var td = document.createElement("td"); + td.textContent = v == null ? "—" : String(v); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + } + + /* ---------- reset countdown ---------- */ + + var resetsAtMs = NaN; + var clockOffset = 0; // serverNow - clientNow + + function tickCountdown() { + var el = $("reset-note"); + if (!isFinite(resetsAtMs)) return; + var remain = resetsAtMs - (Date.now() + clockOffset); + var at = new Date(resetsAtMs); + var hhmm = String(at.getUTCHours()).padStart(2, "0") + ":" + + String(at.getUTCMinutes()).padStart(2, "0"); + el.textContent = remain <= 0 + ? "daily budget resetting… (" + hhmm + " UTC)" + : "daily budget resets in " + fmtDur(remain / 1000) + " (" + hhmm + " UTC)"; + } + setInterval(tickCountdown, 1000); + + /* ---------- render ---------- */ + + function render(d) { + var st = STATES[d.state] || [String(d.state || "unknown"), "st-off"]; + setPill(st[0], st[1]); + setText("detail", d.detail || ""); + setText("t-model", d.model || "—"); + + var live = d.live || {}; + setText("t-tps", fmtRate(live.tokens_per_sec)); + setText("t-rpm", fmtRate(live.requests_per_min)); + setText("t-live", fmtInt(live.subjects_5m)); + setText("t-flight", fmtInt(live.in_flight)); + + var usage = d.usage || {}; + setText("t-today", fmtInt(usage.subjects_today)); + + var sys = d.system || {}; + setText("t-uptime", fmtDur(sys.uptime_sec)); + + chart.update(live.series); + + var credit = d.credit || {}; + setGauge("g-day", credit.day_remaining_pct); + setGauge("g-pool", credit.pool_remaining_pct); + + var today = usage.today || {}; + var life = usage.lifetime || {}; + setText("to-req", fmtCompact(today.requests)); + setText("lt-req", fmtCompact(life.requests)); + setText("to-in", fmtCompact(today.input_tokens)); + setText("lt-in", fmtCompact(life.input_tokens)); + setText("to-out", fmtCompact(today.output_tokens)); + setText("lt-out", fmtCompact(life.output_tokens)); + + renderBars("endpoints", d.endpoints || [], function (it) { return it.name; }); + renderBars("countries", d.countries || [], function (it) { + var f = flagEmoji(it.name); + return (f ? f + " " : "") + it.name; + }); + renderSubjects(d.top_subjects); + + var kp = d.key_pool || {}; + setText("kp-line", + fmtInt(kp.active) + " active · " + fmtInt(kp.retired) + " retired · " + + fmtInt(kp.total) + " total · " + fmtInt(credit.donated_keys) + " donated"); + + setText("sys-load", typeof sys.load1 === "number" ? sys.load1.toFixed(2) : "—"); + setText("sys-heap", typeof sys.heap_mb === "number" ? sys.heap_mb.toFixed(1) + " MB" : "—"); + setText("sys-goroutines", fmtInt(sys.goroutines)); + setText("sys-cpu", fmtInt(sys.num_cpu)); + setText("sys-go", sys.go_version || "—"); + setText("sys-version", d.version || "—"); + + var lim = d.daily_limits_per_user || {}; + setText("lim-req", fmtInt(lim.requests)); + setText("lim-in", fmtCompact(lim.input_tokens)); + setText("lim-out", fmtCompact(lim.output_tokens)); + + var rAt = Date.parse(d.resets_at); + var sNow = Date.parse(d.now); + if (isFinite(rAt)) resetsAtMs = rAt; + if (isFinite(sNow)) clockOffset = sNow - Date.now(); + tickCountdown(); + } + + /* ---------- poll loop with backoff ---------- */ + + var POLL_MS = 5000; + var MAX_BACKOFF_MS = 60000; + var delay = POLL_MS; + var hasData = false; + + function setOnline(ok) { + $("offline").hidden = ok; + $("dash").classList.toggle("stale", !ok); + if (!ok) setPill("unreachable", "st-warn"); + } + + function poll() { + fetch("/v1/status", { cache: "no-store" }) + .then(function (r) { + if (!r.ok) throw new Error("http " + r.status); + return r.json(); + }) + .then(function (data) { + try { + render(data); + hasData = true; + } catch (e) { + // a render bug must not kill the loop + if (window.console && console.error) console.error("render:", e); + } + delay = POLL_MS; + setOnline(true); + }) + .catch(function () { + delay = Math.min(delay * 2, MAX_BACKOFF_MS); + setOnline(false); + if (!hasData) setText("detail", "gateway not responding — it may be down or restarting"); + }) + .then(function () { + setTimeout(poll, delay); + }); + } + + poll(); +})(); diff --git a/gateway/internal/server/web/index.html b/gateway/internal/server/web/index.html new file mode 100644 index 0000000..51a65ea --- /dev/null +++ b/gateway/internal/server/web/index.html @@ -0,0 +1,208 @@ + + + + + +freeseek — free DeepSeek API, no key, no account + + + + + + + + + + + + + + + + + + + +
+
+ freeseek + connecting… + +
+
+ +
+ +
+

Free DeepSeek API. No key. No account.

+

A community-run, OpenAI-compatible gateway to + deepseek-v4-flash. Your client solves a one-second + proof-of-work puzzle — that is the whole signup.

+
+ +
go install github.com/thevibeworks/deepseek-cli/cmd/deepseek@latest
+deepseek free
+deepseek chat "why is the sky blue"
+
+

OpenAI-compatible: point any OpenAI client at base URL + https://freeseek.1lm.io. Shared community pool — be kind, no SLA.

+
+ +
+
+

Live status

+

+
+ + + +
+
tokens/sec
+
requests/min
+
live users (5m)
+
in flight
+
users today
+
uptime
+
+ +
+
+ output tokens/sec — last 5 minutes + +
+ + +
+ +
+
+

Budget

+
+
today's budget remaining
+
+
+
+
+
+
credit pool remaining
+
+
+
+
+

daily budget resets at 00:00 UTC

+
+ +
+

Totals

+ + + + + + + +
todaylifetime
requests
input tokens
output tokens
+

model

+
+
+ +
+
+

Endpoints

+
  • no data yet
+
+
+

Countries

+
  • no data yet
+
+
+ +
+

Top users today

+ + + + + + + +
anonymous ids — the gateway never sees names, emails or accounts
anonymous idrequestsinput tokensoutput tokens
no data yet
+
+ +
+
+

Key pool

+

— active · — retired · — total · — donated

+

Running on donated DeepSeek API keys. Want to donate one? + Open an issue + and we'll arrange a private channel — keys are never accepted through any + web form, including this page.

+
+
+

System

+
    +
  • load
  • +
  • heap
  • +
  • goroutines
  • +
  • cpus
  • +
  • go
  • +
  • build
  • +
+
+
+
+ +
+

How it works

+
    +
  1. Proof of work. Your client burns about one second of CPU + on a small puzzle. No email, no signup, no tracking — the puzzle is the gate.
  2. +
  3. Token. The gateway hands back a short-lived anonymous + token tied to nothing but that puzzle solution.
  4. +
  5. Metered proxy. Requests are counted against fair-use + limits and forwarded to DeepSeek on pooled keys.
  6. +
+

Per-user daily limits

+
    +
  • 30 requests
  • +
  • 60k input tokens
  • +
  • 20k output tokens
  • +
+
+ +
+ + + + + diff --git a/gateway/internal/server/web/pages/economics.html b/gateway/internal/server/web/pages/economics.html new file mode 100644 index 0000000..2048ebb --- /dev/null +++ b/gateway/internal/server/web/pages/economics.html @@ -0,0 +1,73 @@ + + + + + +Economics — freeseek + + + + + + + + +
+ freeseek + +
+
+

Cheap tokens are the point

+ +

This service resells nothing and marks up nothing, so its existence hangs on a single number: what a token costs. That number has quietly become one of the widest price spreads in software. As of August 2026, a million output tokens from deepseek-v4-flash cost $0.28. The same million from a frontier flagship cost $25 to $50 — $180 from the pro tiers. The models are not equivalent, but on everyday work they are far closer in capability than they are in price.

+ +

The rate cards

+

Published list prices, USD per million tokens, checked against each vendor's own pricing page on 2026-08-06:

+ + + + + + + + + + + + + +
modelinputcached inputoutput
deepseek-v4-flash$0.14$0.0028$0.28
deepseek-v4-pro$0.435$0.003625$0.87
GPT-5.2$1.75$0.175$14.00
Claude Sonnet 5*$2.00$0.20$10.00
GPT-5.4$2.50$0.25$15.00
Claude Opus 5$5.00$0.50$25.00
GPT-5.5$5.00$0.50$30.00
Claude Fable 5$10.00$1.00$50.00
GPT-5.5-pro$30.00$180.00
+

*Introductory pricing through 2026-08-31; $3 / $15 after. Sources: DeepSeek, Anthropic, OpenAI. Prices change monthly; when this table and a vendor's page disagree, the vendor's page is right.

+ +

Read down the output column: flash to Fable 5 is 179×. The cached-input column is starker — $1.00 against $0.0028 is 357× — and DeepSeek's cache is automatic and free to write, where Anthropic bills cache writes at 1.25–2× the input rate.

+ +

The same task

+

Rate cards mislead without a workload, so take an ordinary agent exchange — the shape Anthropic itself uses as a worked example: 50k input tokens of which 40k are cache reads, 15k output tokens. Counting the same nominal tokens at each vendor's list prices:

+ + + + + + + + + + + +
modelthat exchange costsvs flash
deepseek-v4-flash$0.0057
Claude Sonnet 5$0.1831×
GPT-5.4$0.2646×
Claude Opus 5$0.4578×
GPT-5.5$0.5291×
Claude Fable 5$0.89156×
GPT-5.5-pro$4.20735×
+

Not twenty percent cheaper. Thirty to a few hundred times cheaper, depending on the model and how much of the prompt caches.

+ +

What this does not claim

+

Per-token is not per-task. A stronger model that solves a hard problem in one attempt can beat a cheaper model that needs three, and on the hardest work the frontier models earn their price. Tokenizers differ too — Anthropic notes that its current tokenizer produces roughly 30% more tokens for the same text than its previous one, so identical work is not identical token counts across vendors. And DeepSeek has announced peak-hour pricing at 2× the listed rates, effective date not yet set. The honest claim is narrower and still remarkable: for the broad middle of real work — summarize, translate, refactor, answer, glue — the going rate differs by two orders of magnitude depending on whose API you call.

+ +

Why it matters

+

Chat is measured in thousands of tokens; agents are measured in millions. The moment a model works unattended — reading files, retrying, checking its own output — token consumption stops tracking human attention and starts tracking machine patience. An overnight agent run that emits ten million output tokens costs $2.80 at flash prices and $500 at Fable prices. One of those is "leave it running"; the other is a line item that gets a meeting. At frontier prices, autonomy is a luxury good. At flash prices, it is a background process.

+

Whatever AGI turns out to be, it will be made of tokens, and nobody runs civilization-scale inference at $50 per million. Every 10× drop in token price makes a class of applications viable that was silly the day before — the same way compute-per-dollar curves, not any single breakthrough, decided what software got built. Cheap tokens are not the budget option. They are the substrate.

+ +

This page is also the explanation of the gateway you are reading it on. At flash prices, a dollar buys roughly three thousand ordinary conversational turns; at frontier list prices, the same dollar buys about fifty. A free tier funded by donated keys and pocket money is arithmetic that only works at the bottom of that table — which is why it runs on deepseek-v4-flash, and why there is no paid tier to upsell you to.

+
+ + + diff --git a/gateway/internal/server/web/pages/privacy.html b/gateway/internal/server/web/pages/privacy.html new file mode 100644 index 0000000..773739f --- /dev/null +++ b/gateway/internal/server/web/pages/privacy.html @@ -0,0 +1,66 @@ + + + + + +Privacy — freeseek + + + + + + + + +
+ freeseek + +
+
+

Privacy

+

Last updated: 2026-08-06

+ +

This service, dsgate, is a free keyless proxy to the DeepSeek API, run by the open-source project thevibeworks/deepseek-cli. There is no account system, so most of what a privacy policy usually has to explain simply does not exist here. This page lists exactly what is recorded, exactly what is not, and what leaves this machine for DeepSeek. Every claim on this page corresponds to code you can read in the repository.

+ +

What we record

+

Every billable request appends one line to an accounting journal. That line contains, in full:

+ +

That is the complete record. The journal exists because this is a shared credit pool spending real money, and the budget needs a memory. It contains nothing that identifies a person.

+ +

What we do not record

+

The gateway never stores or logs prompts, completions, IP addresses, or request headers. This is not a configuration choice that could be flipped; the journal has no field for any of them, and the statistics code is written so that no function in it accepts an IP address at all. The nginx in front of the gateway runs with access_log off, so there is no web-server log holding IPs either. Prompt logging was deliberately never built, not even behind a debug flag, on the reasoning that a flag that can log prompts is a flag that eventually will.

+

One narrow exception, stated precisely: the enrolment endpoint counts how many proof-of-work challenges it has issued per address bucket during the current UTC day, so that difficulty can escalate against identity farming. That count resets at midnight, is never written to the journal, and is never joined to a token or a request.

+ +

What leaves for DeepSeek

+

Your prompt does not stay on your machine. It transits this gateway and is forwarded to api.deepseek.com, where it is processed under DeepSeek's own terms of service and privacy practices. We pass the bytes through and do not keep them, but DeepSeek receives them the same as if you had called the API yourself. Do not send anything sensitive through this service. For sensitive work, bring your own key from platform.deepseek.com and skip the proxy entirely.

+

Each forwarded request carries your anonymous subject id as DeepSeek's user_id field. This is the mechanism DeepSeek provides for one account fronting many users: it attributes content-safety events to the individual subject rather than the whole pool, and it keeps each subject's prompt cache isolated from strangers'.

+ +

Geography

+

The dashboard shows where traffic comes from as a per-country histogram. The input is a two-letter country code supplied by the network edge; no IP address ever reaches the code that counts it. The histogram is aggregate only, is never linked to a subject, lives in memory, and is lost whenever the gateway restarts. A country total is a fact about the service, not about a person.

+ +

Cookies and local storage

+

This site sets no cookies. It stores one value in your browser's localStorage: your light-or-dark theme preference. The CLI stores your free-tier token in your own configuration directory on your own machine; it is never held server-side, because the server has no user table to hold it in.

+ +

Retention

+

Journal files are the financial record of the shared pool and are retained; since they contain only the fields listed above, retaining them retains nothing personal. Live dashboard statistics are kept in memory only and vanish on restart. Per-address enrolment counts last one UTC day. Tokens themselves expire seven days after they are minted.

+ +

How to be forgotten

+

Run deepseek free off. That deletes the token from your machine, which is the only place it exists. On the server side there is nothing to delete that points at you: a subject id is 16 random bytes with no name, email, or address attached, so we could not look up "your" journal entries even if you asked us to. Any token you abandon stops working within seven days regardless.

+ +

What we could see but choose not to

+

Honesty requires saying this plainly. A proxy necessarily has your prompt in memory while it forwards it, sees the IP address of the connection, and sees your headers. We could log all of it. We record none of it, and the code is public so that this is a checkable claim rather than a promise: read internal/quota/quota.go and internal/stats/stats.go in the repository and confirm there is nowhere for that data to go. What you should trust is the code, not this page.

+ +

Contact

+

This is a hobby service run by maintainers of an open-source project, not a company, and there is no data-protection office. Questions and requests go to the GitHub issue tracker.

+
+ + + diff --git a/gateway/internal/server/web/pages/story.html b/gateway/internal/server/web/pages/story.html new file mode 100644 index 0000000..2848cb2 --- /dev/null +++ b/gateway/internal/server/web/pages/story.html @@ -0,0 +1,49 @@ + + + + + +Story — freeseek + + + + + + + + +
+ freeseek + +
+
+

Why this exists

+ +

We built a command-line tool for the DeepSeek API, and then watched everyone hit the same wall before they could form an opinion about it. The tool is free, the model is cheap, the install is one command. But the first real step was always the same: go get an API key. Sign up, top up, paste a secret into an environment variable, and only then find out whether the thing is any good. The evaluation cost more than the evaluation was worth.

+ +

So we put our own key behind a metered proxy and gave it away. Type deepseek free once and deepseek chat "hi" works. No signup, no card, no email. That decision spends real money on strangers' behalf, and everything interesting about this service is in how that spending is bounded.

+ +

Why proof-of-work instead of signup

+

The obvious move is a login. Sign in with GitHub and we get a real identity, an account age we can check, someone to attribute abuse to. We designed that tier in and may still build it. But as the front door it kills the exact property the feature exists for: install it and it works. The moment there is a signup, the magic is gone and we are just another dashboard asking for your details before showing you anything.

+

The next obvious move is rate limiting by IP address, which costs fifty lines and fails immediately: a five-dollar proxy pool defeats it entirely, and one university NAT shares one quota between two thousand students.

+

Proof-of-work is the middle path. Your machine burns about one second of one core solving a puzzle, and gets a bearer token in exchange. Nothing personal changes hands; identity just costs something. Every student behind the campus NAT mints their own token. And when one address starts minting greedily, the puzzle gets harder for that address: the fourth mint of the day costs four times the first, the eighth about a thousand times. No blocklists, no ASN database to keep current, just a price that rises with appetite.

+ +

The budget breaker is the real security

+

Here is the honest part. Proof-of-work is not a security boundary and is not pretending to be one. CPU is cheap; a spot instance can mint thousands of identities overnight. Any scheme that tries to make identity trustworthy on the open internet loses, because identity on the open internet is not something you can win.

+

So we stopped trying. The per-user quota is a fairness mechanism: it keeps one person from crowding out the rest on a normal day. The security mechanism is a budget that cannot be exceeded no matter how many identities exist. The gateway keeps an append-only journal of every charge, synced to disk per debit, and refuses new requests the moment the day's budget or the lifetime credit pool would be crossed. Each request's worst possible cost is reserved before it is admitted and settled to its real cost afterwards, so the budget is a ceiling, not a horizon. If a response cannot be metered, it is charged a deliberately pessimistic estimate; unbillable must never mean free. If the journal cannot be written to disk, the gateway stops admitting requests entirely, because spend that cannot be recorded is spend a restart would silently refund.

+

The trade we accepted, stated plainly: a determined attacker can burn the day's budget in an hour, and honest users get "come back tomorrow". We chose bounded loss over guaranteed availability, because we can survive a bad day and cannot survive a bad invoice.

+ +

Why a transparent proxy

+

The gateway does not reimplement the DeepSeek API. The CLI already speaks four wire formats against one configurable base URL, and a typed per-endpoint gateway would have to be extended for each of them and would drift from upstream the day it shipped. Instead the gateway's job is narrow: authenticate the token, apply policy to the request, pass the bytes through, meter the response, charge the account. Because it proxies faithfully, chat, Anthropic, Responses, FIM and the model list all work with no client changes, and so does whatever DeepSeek ships next month. It even bills streaming without touching the stream, because all three streaming formats turn out to emit their usage figures in the final event; the gateway tees the bytes and reads the tail.

+

One field it does rewrite deliberately: every upstream request carries the token's anonymous subject as DeepSeek's user_id. That is the mechanism DeepSeek built for one account fronting many users. It attributes safety events to the user who caused them rather than to the whole pool, and it keeps strangers' prompt caches isolated from each other, which without it would be our privacy bug.

+ +

Small on purpose

+

There is no database. Counters live in memory and every debit appends to a journal file you can read with tail, replayed at boot. The whole gateway is a single static binary sharing a one-gigabyte box with other services, which is what a service this size should cost to run. There is no user table, which means there is nothing to breach and nothing to migrate. And there is no prompt logging, not for debugging, not behind a flag, because a flag that can log prompts is a flag that will.

+ +

The result is deliberately modest: a shared pool, funded by donated keys and our own money, that gives a stranger about thirty requests a day until it runs out. It exists so that the first DeepSeek call of your life can happen in the minute after you install the CLI, and the decision about whether to get a real key can be made from experience instead of faith. Where it goes from here is on the vision page.

+
+ + + diff --git a/gateway/internal/server/web/pages/terms.html b/gateway/internal/server/web/pages/terms.html new file mode 100644 index 0000000..40f9227 --- /dev/null +++ b/gateway/internal/server/web/pages/terms.html @@ -0,0 +1,76 @@ + + + + + +Terms — freeseek + + + + + + + + +
+ freeseek + +
+
+

Terms of service

+

Last updated: 2026-08-06

+ +

This service, dsgate at freeseek.1lm.io, is a free, keyless proxy to the DeepSeek API. It is a hobby project run by the maintainers of the open-source project thevibeworks/deepseek-cli. It is not a company and there is no legal entity behind it. These terms are written to be honest rather than to sound like a contract, because they are not one: they are the conditions under which we are willing to spend our own money on your API calls. By using the service you accept them.

+ +

1. What you are using

+

A shared community credit pool, funded by donated API keys and the maintainers' own money. Your requests are relayed to api.deepseek.com and billed against that pool. The pool is finite. It can and will run out, sometimes for the rest of the day, eventually perhaps for good.

+ +

2. No warranty, no availability

+

The service is provided as-is and as-available, with no warranty of any kind. There is no SLA, no uptime guarantee, and no paid tier that would buy you one. A daily budget circuit breaker protects the pool, which means a busy or abusive day can exhaust the service before you get to it; when that happens the honest answer is "come back after 00:00 UTC". The service may be suspended, changed, or shut down permanently at any time, without notice.

+ +

3. Quotas

+

Per user, per UTC day, resetting at 00:00 UTC:

+ +

Enrolment tokens expire seven days after they are minted; the CLI renews them automatically.

+

Circumventing the quota system is prohibited. That includes minting multiple identities to multiply your allowance, evading or outsourcing the proof-of-work, stockpiling tokens, and sharing or distributing tokens to others. The proof-of-work exists to make each identity cost something; treating it as a farming target is abuse of a shared resource that other people paid for.

+ +

4. DeepSeek's terms apply to you

+

Your prompts do not run here. They run at DeepSeek, so you must also comply with the DeepSeek Open Platform Terms of Service. Every request you send carries your anonymous subject id as DeepSeek's user_id, which means content-safety violations are attributable to your token specifically. If DeepSeek's terms and these terms disagree about what you may generate, the stricter one wins.

+ +

5. Acceptable use

+

Do not use the service to:

+ +

If you have real volume or a real product, bring your own key from platform.deepseek.com. It is cheap, it is unmetered by us, and it is the intended path.

+ +

6. What the gateway does to your requests

+

This is a policy-enforcing proxy, and you should know what the policy touches. The gateway pins the model, clamps the output-token cap, and overwrites any user identity field with your subject id. It refuses request parameters that multiply cost (n, best_of above 1) and server-side tools such as web search, whose cost cannot be metered. Only an allowlisted set of endpoints is proxied at all. Apart from those named fields, your request is forwarded as you sent it.

+ +

7. Revocation and termination

+

Any token may be revoked at any time, without notice and without appeal. A revoked token does not heal at midnight. In practice revocation is used against abuse, but nothing here obliges us to justify it: this is a gift, and a gift can be withdrawn.

+ +

8. Liability

+

To the maximum extent permitted by applicable law, the operators accept no liability for anything arising from your use of this service: not for downtime, not for lost work, not for model output, not for what DeepSeek does with your prompts. The total value you have paid us is zero, and our liability is capped at the same figure.

+ +

9. Changes

+

These terms may change without notice. The current version is always at freeseek.1lm.io/terms, and its history lives in the repository like everything else.

+ +

10. Governing spirit

+

An honest note in place of the usual jurisdiction clause: we are not going to invent a company address or claim the courts of some carefully chosen state, because there is no company. If something is wrong, open an issue on the GitHub issue tracker and a human will read it. If you need a counterparty you can hold to a contract, this free tier is not that, and DeepSeek's own platform is where to find one.

+
+ + + diff --git a/gateway/internal/server/web/pages/vision.html b/gateway/internal/server/web/pages/vision.html new file mode 100644 index 0000000..65873d9 --- /dev/null +++ b/gateway/internal/server/web/pages/vision.html @@ -0,0 +1,49 @@ + + + + + +Vision — freeseek + + + + + + + + +
+ freeseek + +
+
+

Where this goes

+ +

Free access here is activation, not an entitlement. The whole goal is that one person gets one successful DeepSeek call before deciding whether an API key is worth their time. Thirty requests a day is enough to evaluate a tool honestly; it is deliberately not enough to build on. If you finish a day here and want more, the free tier has done its job, and the next step is a key of your own from platform.deepseek.com.

+ +

How it stays alive

+

The pool is funded by donated API keys and the maintainers' own money, protected by a daily budget breaker so a bad day costs a bounded amount. That is the entire sustainability model, and it means the arithmetic is simple: the pool grows only if people donate keys. There is no revenue, no investor, and no plan for either. At flash prices a dollar buys roughly three thousand ordinary conversational turns, so modest donations go a surprisingly long way. When the pool is empty, the service says so and waits.

+ +

A possible verified tier

+

The anonymous proof-of-work tier is the floor, sized for evaluation. The design has always reserved an upgrade rung: sign in with GitHub, and an account with real age and history gets a larger daily quota, because that identity is genuinely costly to farm. It is designed in but not built, and it will only ever be an addition on top of the anonymous tier, never a replacement for it. The keyless first call is the point of the project and it stays.

+ +

Never a paid tier

+

There will never be a paid tier of this gateway. This is worth stating as a commitment rather than a current fact, because it constrains every future decision: the moment we charged for access we would be reselling DeepSeek's API with a markup and a worse SLA, which is a business we do not want and you should not want to buy from. The paid path is and will remain bringing your own key directly to DeepSeek, where you get every model, no proxy in the middle, and your money buys exactly what it says.

+ +

What would make us shut it down

+

Honesty about the exit, too. We will shut this down if abuse consumes the budget so consistently that honest users get nothing but "come back tomorrow"; if the service creates legal or safety problems that a hobby project cannot responsibly carry; or if donations and our own patience run out together. The budget breaker guarantees the ending would be quiet rather than expensive: the service refuses politely, and the CLI keeps working with your own key. Nothing about the CLI depends on this gateway existing.

+ +

How to help

+ + +

The measure of success is modest and specific: someone installs the CLI, runs one command, gets a real answer from a real model, and decides from evidence whether to go further. Everything else here exists to make that moment cheap, bounded, and honest.

+
+ + + diff --git a/gateway/internal/server/web/style.css b/gateway/internal/server/web/style.css new file mode 100644 index 0000000..b0e71a0 --- /dev/null +++ b/gateway/internal/server/web/style.css @@ -0,0 +1,477 @@ +/* freeseek status dashboard — dark default, light via light-dark() */ + +:root { + color-scheme: light dark; + --bg: light-dark(#fbfaf7, #000000); + --surface: light-dark(#f2f1ec, #0d0d0d); + --surface-2: light-dark(#e8e7e0, #141414); + --line: light-dark(#d4d1c7, #262626); + --text: light-dark(#17171a, #e8e8e8); + --muted: light-dark(#5a5a60, #9a9a9a); + --cyan: light-dark(#0a6e85, #00c2e9); + --pink: light-dark(#bd0e60, #e41478); + --purple: light-dark(#7a12b8, #bf00ff); + --yellow: light-dark(#7a5200, #ffd53d); + --strong: light-dark(#000000, #ffffff); + --dur-fast: 140ms; + --dur-slow: 320ms; + --ease: cubic-bezier(0.25, 0, 0.2, 1); + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--mono); + font-size: 14px; + line-height: 1.55; +} + +.wrap { max-width: 1120px; margin: 0 auto; padding: 0 20px; } + +a { color: var(--cyan); text-decoration: none; } +a:hover { text-decoration: underline; } + +:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; + border-radius: 2px; +} + +code { + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 3px; + padding: 1px 5px; + font-family: var(--mono); + font-size: 0.92em; +} + +h1, h2, h3 { color: var(--strong); line-height: 1.25; } +h2 { font-size: 18px; margin: 0 0 4px; } +h3 { font-size: 13px; margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.08em; } + +/* ---------- masthead ---------- */ + +.masthead { border-bottom: 1px solid var(--line); background: var(--bg); } +.masthead-row { + display: flex; + align-items: center; + gap: 14px; + padding-top: 12px; + padding-bottom: 12px; + flex-wrap: wrap; +} +.wordmark { + font-size: 18px; + font-weight: 700; + color: var(--strong); + letter-spacing: 0.02em; +} +.wordmark span { color: var(--cyan); } +.wordmark:hover { text-decoration: none; } + +.mastnav { margin-left: auto; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } +.mastnav a { color: var(--muted); font-size: 13px; } +.mastnav a:hover { color: var(--text); } + +#theme-toggle { + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + border-radius: 4px; + font-family: var(--mono); + font-size: 14px; + line-height: 1; + padding: 5px 8px; + cursor: pointer; + transition: border-color var(--dur-fast) var(--ease); +} +#theme-toggle:hover { border-color: var(--cyan); } + +/* state pill */ +.pill { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid currentColor; + border-radius: 999px; + padding: 2px 11px; + font-size: 12px; + white-space: nowrap; +} +.pill::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} +.pill.st-ok { color: var(--cyan); } +.pill.st-busy { color: var(--yellow); } +.pill.st-warn { color: var(--pink); } +.pill.st-off { color: var(--muted); } +.pill.st-ok::before { animation: pulse 2.4s var(--ease) infinite; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ---------- hero ---------- */ + +.hero { padding: 40px 0 8px; } +.hero h1 { font-size: clamp(24px, 5vw, 36px); margin: 0 0 10px; } +.hero-sub { max-width: 62ch; margin: 0 0 18px; color: var(--muted); } +.hero-sub strong { color: var(--text); } +.hero-note { color: var(--muted); font-size: 13px; max-width: 70ch; } + +.codeblock { + position: relative; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + max-width: 720px; +} +.codeblock pre { + margin: 0; + padding: 16px 18px; + overflow-x: auto; + font-size: 13px; + line-height: 1.7; +} +.codeblock code { background: none; border: 0; padding: 0; } +#copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: var(--surface-2); + color: var(--muted); + border: 1px solid var(--line); + border-radius: 4px; + font-family: var(--mono); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; + transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease); +} +#copy-btn:hover { color: var(--cyan); border-color: var(--cyan); } + +/* ---------- dashboard ---------- */ + +#dash { padding: 26px 0 6px; } +.dash-head { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.detail { margin: 0; color: var(--muted); font-size: 13px; } + +.offline { + border: 1px solid var(--pink); + color: var(--pink); + border-radius: 6px; + padding: 10px 14px; + margin: 0 0 14px; + font-size: 13px; +} + +#dash.stale .tile-v, +#dash.stale .gauge-fill, +#dash.stale .bar { opacity: 0.5; } + +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 16px 18px; + margin-bottom: 14px; +} + +.tiles { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 10px; + margin-bottom: 14px; +} +.tile { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.tile-v { + font-size: 22px; + font-weight: 700; + color: var(--strong); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; +} +.tile-l { font-size: 11px; color: var(--muted); letter-spacing: 0.04em; } + +/* chart */ +.chart { padding: 14px 18px 10px; } +.chart-cap { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 12px; + color: var(--muted); + margin-bottom: 8px; + flex-wrap: wrap; +} +#spark-readout { color: var(--cyan); font-variant-numeric: tabular-nums; } +#spark { + display: block; + width: 100%; + height: 150px; + color: var(--cyan); /* line + fill colour, read by app.js */ + border-color: var(--muted); /* axis/grid colour, read by app.js */ + touch-action: pan-y; +} +.chart-x { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--muted); + margin-top: 4px; +} + +/* two-column card rows */ +.cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 14px; +} +.cols .card { margin-bottom: 0; } + +/* gauges */ +.gauge { margin-bottom: 14px; } +.gauge-top { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 13px; + margin-bottom: 6px; +} +.gauge-top span:last-child { + color: var(--strong); + font-variant-numeric: tabular-nums; +} +.gauge-track { + height: 10px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 999px; + overflow: hidden; +} +.gauge-fill { + height: 100%; + width: 0; + background: var(--cyan); + border-radius: 999px; + transition: width var(--dur-slow) var(--ease), background-color var(--dur-slow) var(--ease); +} +.gauge-fill.warn { background: var(--yellow); } +.gauge-fill.crit { background: var(--pink); } + +.fineprint { color: var(--muted); font-size: 12px; margin: 8px 0 0; } + +/* tables */ +table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } +caption { text-align: left; color: var(--muted); font-size: 12px; padding-bottom: 8px; } +th, td { text-align: right; padding: 5px 8px; font-size: 13px; border-bottom: 1px solid var(--line); } +th { color: var(--muted); font-weight: 400; } +thead th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; } +tbody th, td:first-child, th:first-child { text-align: left; } +tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; } +.totals td { color: var(--strong); } +.subjects td:first-child { color: var(--muted); } + +/* bar lists (endpoints, countries) */ +.barlist { list-style: none; margin: 0; padding: 0; } +.barlist li { + display: grid; + grid-template-columns: minmax(72px, auto) 1fr auto; + align-items: center; + gap: 10px; + padding: 4px 0; + font-size: 13px; +} +.barlist .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.barlist .bar { + height: 8px; + background: var(--cyan); + border-radius: 4px; + min-width: 2px; + transition: width var(--dur-slow) var(--ease); +} +.barlist .count { color: var(--muted); font-variant-numeric: tabular-nums; } +.barlist .empty, .subjects .empty { color: var(--muted); } + +/* key pool + system */ +.keypool { margin: 0; color: var(--strong); } +.sysline { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 18px; } +.sysline li { color: var(--muted); font-size: 13px; } +.sysline span { color: var(--text); } + +/* ---------- how it works ---------- */ + +.how { padding: 26px 0 10px; border-top: 1px solid var(--line); } +.how h2 { margin-bottom: 14px; } +.how h3 { margin-top: 20px; } +.steps { margin: 0; padding-left: 22px; max-width: 72ch; } +.steps li { margin-bottom: 10px; } +.steps strong { color: var(--cyan); } +.limits { list-style: none; margin: 0; padding: 0; display: flex; gap: 10px; flex-wrap: wrap; } +.limits li { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 12px; + font-size: 13px; + color: var(--muted); +} +.limits span { color: var(--strong); font-weight: 700; } + +/* ---------- footer ---------- */ + +.footer { + border-top: 1px solid var(--line); + margin-top: 30px; + padding: 20px 0 30px; + font-size: 13px; + color: var(--muted); +} +.footer nav { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; } +.footer p { margin: 0; max-width: 70ch; } + +/* ---------- prose pages ---------- */ + +/* The dashboard is an instrument panel; privacy, terms, story and vision + * are long-form reading, and the two want different rhythm. They share + * the masthead and footer, so the rules here only add what reading needs: + * a measure, vertical rhythm, and a slightly taller line. + * + * These pages carry no .wrap element — they are a masthead, a
and + * a footer — so the horizontal padding lives on those three directly. + * They are marked with class="doc" on rather than detected with + * :has(), so the selector says what it means and works everywhere. */ + +.doc .masthead { + display: flex; + align-items: baseline; + gap: 20px; + flex-wrap: wrap; + max-width: 820px; + margin: 0 auto; + padding: 16px 20px; +} +.doc .masthead nav { + display: flex; + gap: 14px; + flex-wrap: wrap; + margin-left: auto; +} +.doc .masthead nav a { color: var(--muted); font-size: 13px; } +.doc .masthead nav a:hover { color: var(--text); } + +.prose { + max-width: 74ch; + margin: 0 auto; + padding: 28px 20px 8px; + /* Prose is read, not scanned. A little more leading than the dashboard + * uses, which is tuned for numbers in tiles. */ + line-height: 1.7; +} + +.prose h1 { font-size: clamp(24px, 5vw, 32px); margin: 0 0 16px; } +.prose h2 { + font-size: 17px; + margin: 32px 0 10px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.prose h3 { + font-size: 13px; + margin: 22px 0 8px; + text-transform: none; + letter-spacing: 0; + color: var(--strong); +} +.prose p { margin: 0 0 14px; } +.prose ul, .prose ol { margin: 0 0 14px; padding-left: 1.4em; } +.prose li { margin: 0 0 6px; } +.prose li::marker { color: var(--muted); } +.prose dt { color: var(--strong); margin-top: 12px; } +.prose dd { margin: 0 0 10px; padding-left: 1.4em; color: var(--muted); } +.prose table { margin: 0 0 18px; } +.prose a { text-decoration: underline; text-underline-offset: 2px; } + +/* The opening paragraph, set larger so the page states its purpose + * before anyone decides whether to read the rest. */ +.prose .lede { + font-size: 15px; + color: var(--text); + margin-bottom: 22px; +} + +/* Dates, provenance, asides. Quiet by construction. */ +.prose .note { + color: var(--muted); + font-size: 12px; +} + +/* The one voice that has to interrupt: what a reader must not assume. + * A left rule rather than a filled box — a full alert panel in the + * middle of a legal page reads as decoration and gets skipped. */ +.prose .warn { + border-left: 2px solid var(--pink); + padding: 2px 0 2px 14px; + margin: 0 0 16px; + color: var(--text); +} + +.doc .footer { max-width: 820px; margin: 30px auto 0; padding-left: 20px; padding-right: 20px; } + +/* ---------- responsive ---------- */ + +@media (max-width: 920px) { + .tiles { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 680px) { + .cols { grid-template-columns: 1fr; } + .hero { padding-top: 28px; } + th, td { padding: 5px 6px; } + /* keep the copy button clear of the first code line */ + .codeblock pre { padding-top: 42px; } +} +@media (max-width: 440px) { + .tiles { grid-template-columns: repeat(2, 1fr); } + .tile-v { font-size: 19px; } + .wrap { padding: 0 14px; } +} +@media (min-width: 1800px) { + body { font-size: 15px; } + .wrap { max-width: 1320px; } +} + +/* ---------- reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/gateway/internal/stats/stats.go b/gateway/internal/stats/stats.go new file mode 100644 index 0000000..7454bf2 --- /dev/null +++ b/gateway/internal/stats/stats.go @@ -0,0 +1,299 @@ +// Package stats is what the dashboard reads: live throughput, who is +// active, which endpoints are busy, and where the traffic comes from. +// +// It exists separately from package quota because the two answer +// different questions and must not be confused. Quota is money and it is +// durable — every debit is journalled and survives a restart. This is +// observability and it is deliberately ephemeral: everything here lives +// in a ring buffer in memory, is bounded, and is lost on restart. Losing +// it costs a graph; losing quota state costs the credit pool. +// +// The privacy line is drawn inside this package, not above it. `deepseek +// free` promises no IP addresses are recorded, so no function here +// accepts one. Geography arrives already reduced to a two-letter country +// code by the edge, and is counted into an aggregate histogram that +// cannot be joined back to a subject — a country total is a fact about +// the service, not about a person. +package stats + +import ( + "os" + "runtime" + "sort" + "strconv" + "sync" + "time" +) + +// windowSec is how far back the live figures look. Five minutes is long +// enough that one slow request does not make the graph jump and short +// enough that "live" is not a lie. +const windowSec = 300 + +// liveWindow is how recently a subject must have sent something to count +// as here now. +const liveWindow = 5 * time.Minute + +// maxTracked bounds the per-subject last-seen map. Subjects are cheap to +// mint, so this is a memory bound, not a policy: past it, new subjects +// are simply not counted as live until a sweep frees room. +const maxTracked = 20000 + +// bucket is one second of traffic. +type bucket struct { + sec int64 // unix second this bucket represents, 0 when unused + requests int64 + input int64 + output int64 +} + +// Collector accumulates the live view. +type Collector struct { + mu sync.Mutex + + ring [windowSec]bucket + + endpoints map[string]int64 + countries map[string]int64 + lastSeen map[string]int64 // subject -> unix seconds + + // Since-boot totals. The durable lifetime figures come from the + // ledger's journals; these are just what this process has seen. + bootRequests int64 + bootInput int64 + bootOutput int64 + + inFlight int64 + started time.Time + + now func() time.Time +} + +func New() *Collector { + return &Collector{ + endpoints: map[string]int64{}, + countries: map[string]int64{}, + lastSeen: map[string]int64{}, + started: time.Now(), + now: time.Now, + } +} + +// SetClock replaces the time source, for tests. +func (c *Collector) SetClock(now func() time.Time) { + c.mu.Lock() + c.now = now + c.mu.Unlock() +} + +// Seen records that a subject sent a request from a country. The country +// is a two-letter code from the edge, or "" when unknown; no address of +// any kind reaches this package. +func (c *Collector) Seen(subject, country, endpoint string) { + c.mu.Lock() + defer c.mu.Unlock() + now := c.now().Unix() + + if endpoint != "" { + c.endpoints[endpoint]++ + } + if country != "" && len(country) == 2 && len(c.countries) < 512 { + c.countries[country]++ + } + if subject != "" { + if _, known := c.lastSeen[subject]; known || len(c.lastSeen) < maxTracked { + c.lastSeen[subject] = now + } else { + c.sweepLocked(now) + if len(c.lastSeen) < maxTracked { + c.lastSeen[subject] = now + } + } + } +} + +// Charged records a completed request's measured tokens. It is called +// from the same place the ledger is charged, so the graph and the money +// cannot drift apart. +func (c *Collector) Charged(input, output int) { + c.mu.Lock() + defer c.mu.Unlock() + now := c.now().Unix() + b := c.bucketLocked(now) + b.requests++ + b.input += int64(input) + b.output += int64(output) + + c.bootRequests++ + c.bootInput += int64(input) + c.bootOutput += int64(output) +} + +// bucketLocked returns the ring slot for a second, clearing it first if +// it still holds an older second's traffic. +func (c *Collector) bucketLocked(sec int64) *bucket { + b := &c.ring[sec%windowSec] + if b.sec != sec { + *b = bucket{sec: sec} + } + return b +} + +// InFlight adjusts the count of requests currently being proxied. +func (c *Collector) InFlight(delta int) { + c.mu.Lock() + c.inFlight += int64(delta) + if c.inFlight < 0 { + c.inFlight = 0 + } + c.mu.Unlock() +} + +func (c *Collector) sweepLocked(now int64) { + cutoff := now - int64(liveWindow.Seconds()) + for sub, at := range c.lastSeen { + if at < cutoff { + delete(c.lastSeen, sub) + } + } +} + +// Count is one row of a histogram. +type Count struct { + Name string `json:"name"` + Count int64 `json:"count"` +} + +// Live is the moment-in-time view. +type Live struct { + Subjects5m int `json:"subjects_5m"` + InFlight int64 `json:"in_flight"` + TokensPerSec float64 `json:"tokens_per_sec"` + RequestsPerMin float64 `json:"requests_per_min"` + // Series is per-second output tokens over the window, oldest first, + // for the sparkline. It is the raw material of the graph rather than + // a rendered one, so the page can draw it however it likes. + Series []int64 `json:"series"` +} + +// System is the box this runs on. +type System struct { + UptimeSec int64 `json:"uptime_sec"` + Load1 float64 `json:"load1"` + Goroutines int `json:"goroutines"` + HeapMB float64 `json:"heap_mb"` + NumCPU int `json:"num_cpu"` + GoVersion string `json:"go_version"` + BootRequest int64 `json:"requests_since_boot"` + BootInput int64 `json:"input_tokens_since_boot"` + BootOutput int64 `json:"output_tokens_since_boot"` +} + +// Snapshot is everything the dashboard needs from this package. +type Snapshot struct { + Live Live `json:"live"` + Endpoints []Count `json:"endpoints"` + Countries []Count `json:"countries"` + System System `json:"system"` +} + +// Snapshot reads the live view. Cheap enough to call per request, but the +// server caches it anyway because the dashboard polls. +func (c *Collector) Snapshot() Snapshot { + c.mu.Lock() + defer c.mu.Unlock() + + now := c.now().Unix() + c.sweepLocked(now) + + // Walk the window oldest to newest so the series is in drawing order. + // The current second is excluded: it is still filling, and a partial + // second rendered as a full one makes every graph end in a dip. + series := make([]int64, 0, windowSec-1) + var reqs, toks int64 + for i := windowSec - 1; i >= 1; i-- { + sec := now - int64(i) + b := c.ring[sec%windowSec] + if b.sec != sec { + series = append(series, 0) + continue + } + series = append(series, b.output) + reqs += b.requests + toks += b.input + b.output + } + + elapsed := float64(windowSec - 1) + return Snapshot{ + Live: Live{ + Subjects5m: len(c.lastSeen), + InFlight: c.inFlight, + TokensPerSec: round2(float64(toks) / elapsed), + RequestsPerMin: round2(float64(reqs) / elapsed * 60), + Series: series, + }, + Endpoints: topLocked(c.endpoints, 10), + Countries: topLocked(c.countries, 12), + System: System{ + UptimeSec: int64(c.now().Sub(c.started).Seconds()), + Load1: loadAvg1(), + Goroutines: runtime.NumGoroutine(), + HeapMB: heapMB(), + NumCPU: runtime.NumCPU(), + GoVersion: runtime.Version(), + BootRequest: c.bootRequests, + BootInput: c.bootInput, + BootOutput: c.bootOutput, + }, + } +} + +// topLocked sorts a histogram by count, then by name so that equal counts +// do not shuffle between polls and make the dashboard twitch. +func topLocked(m map[string]int64, n int) []Count { + out := make([]Count, 0, len(m)) + for k, v := range m { + out = append(out, Count{Name: k, Count: v}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Name < out[j].Name + }) + if len(out) > n { + out = out[:n] + } + return out +} + +func round2(f float64) float64 { + return float64(int64(f*100+0.5)) / 100 +} + +func heapMB() float64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return round2(float64(m.HeapAlloc) / (1 << 20)) +} + +// loadAvg1 reads the one-minute load average. Linux-only by construction: +// this is the only place in the gateway that touches /proc, and a box +// without it simply reports zero rather than the package growing a +// per-platform abstraction for one number on a status page. +func loadAvg1() float64 { + b, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0 + } + for i := 0; i < len(b); i++ { + if b[i] == ' ' { + f, err := strconv.ParseFloat(string(b[:i]), 64) + if err != nil { + return 0 + } + return f + } + } + return 0 +} diff --git a/gateway/internal/stats/stats_test.go b/gateway/internal/stats/stats_test.go new file mode 100644 index 0000000..705118e --- /dev/null +++ b/gateway/internal/stats/stats_test.go @@ -0,0 +1,148 @@ +package stats + +import ( + "sync" + "testing" + "time" +) + +func atClock(t time.Time) (*Collector, *time.Time) { + c := New() + now := t + c.SetClock(func() time.Time { return now }) + return c, &now +} + +func TestThroughputOverTheWindow(t *testing.T) { + c, now := atClock(time.Unix(1_700_000_000, 0)) + + // Ten seconds of steady traffic: 100 output tokens a second. + for i := 0; i < 10; i++ { + c.Charged(50, 100) + *now = now.Add(time.Second) + } + + got := c.Snapshot().Live + // 10 requests x 150 tokens over the 299-second window. + want := 1500.0 / 299.0 + if diff := got.TokensPerSec - want; diff > 0.02 || diff < -0.02 { + t.Errorf("tokens_per_sec = %v, want about %v", got.TokensPerSec, want) + } + if got.RequestsPerMin <= 0 { + t.Error("requests_per_min did not move after ten requests") + } + if len(got.Series) != windowSec-1 { + t.Fatalf("series has %d points, want %d", len(got.Series), windowSec-1) + } + var sum int64 + for _, v := range got.Series { + sum += v + } + if sum != 1000 { + t.Errorf("series totals %d output tokens, want 1000", sum) + } +} + +// A bucket older than the window must not be counted again when the ring +// wraps onto its slot. +func TestOldTrafficLeavesTheWindow(t *testing.T) { + c, now := atClock(time.Unix(1_700_000_000, 0)) + c.Charged(1000, 1000) + + *now = now.Add(time.Duration(windowSec+5) * time.Second) + got := c.Snapshot().Live + if got.TokensPerSec != 0 { + t.Errorf("tokens_per_sec = %v after the window passed, want 0", got.TokensPerSec) + } + for i, v := range got.Series { + if v != 0 { + t.Fatalf("series[%d] = %d, want 0 — stale ring slot was read as current", i, v) + } + } +} + +func TestLiveSubjectsExpire(t *testing.T) { + c, now := atClock(time.Unix(1_700_000_000, 0)) + c.Seen("alice", "US", "chat") + c.Seen("bob", "DE", "chat") + + if got := c.Snapshot().Live.Subjects5m; got != 2 { + t.Fatalf("subjects_5m = %d, want 2", got) + } + *now = now.Add(6 * time.Minute) + if got := c.Snapshot().Live.Subjects5m; got != 0 { + t.Errorf("subjects_5m = %d six minutes later, want 0", got) + } +} + +// Geography is an aggregate or it is nothing: the collector must not +// accept anything that could be an address, and must count only sane +// two-letter codes. +func TestCountriesAreAggregateAndValidated(t *testing.T) { + c, _ := atClock(time.Unix(1_700_000_000, 0)) + c.Seen("alice", "US", "chat") + c.Seen("bob", "US", "chat") + c.Seen("carol", "", "chat") + c.Seen("dave", "203.0.113.7", "chat") // not a country code + c.Seen("erin", "usa", "chat") // three letters + + got := c.Snapshot().Countries + if len(got) != 1 || got[0].Name != "US" || got[0].Count != 2 { + t.Fatalf("countries = %+v, want exactly US:2", got) + } +} + +func TestEndpointsRankByVolume(t *testing.T) { + c, _ := atClock(time.Unix(1_700_000_000, 0)) + for i := 0; i < 3; i++ { + c.Seen("a", "", "chat") + } + c.Seen("a", "", "anthropic") + + got := c.Snapshot().Endpoints + if len(got) != 2 || got[0].Name != "chat" || got[0].Count != 3 { + t.Fatalf("endpoints = %+v, want chat first with 3", got) + } +} + +func TestInFlightNeverGoesNegative(t *testing.T) { + c, _ := atClock(time.Unix(1_700_000_000, 0)) + c.InFlight(1) + c.InFlight(-1) + c.InFlight(-1) // a double release must not underflow the gauge + if got := c.Snapshot().Live.InFlight; got != 0 { + t.Errorf("in_flight = %d, want 0", got) + } +} + +func TestTrackedSubjectsAreBounded(t *testing.T) { + c, _ := atClock(time.Unix(1_700_000_000, 0)) + for i := 0; i < maxTracked+500; i++ { + c.Seen(string(rune(i%1000))+"-"+time.Duration(i).String(), "", "chat") + } + if got := c.Snapshot().Live.Subjects5m; got > maxTracked { + t.Errorf("tracked %d subjects, want at most %d", got, maxTracked) + } +} + +func TestConcurrentUseIsSafe(t *testing.T) { + c := New() + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 200; j++ { + c.Seen("sub", "US", "chat") + c.Charged(10, 20) + c.InFlight(1) + c.Snapshot() + c.InFlight(-1) + } + }(i) + } + wg.Wait() + if got := c.Snapshot().Live.InFlight; got != 0 { + t.Errorf("in_flight = %d after all work finished, want 0", got) + } +}