okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnantigravity: limit marks per model-family metere9df547

main
25.0 KiB600 linesraw
1"""Quota readings land raw, each account keeps its own cadence, and failures back off.
2
3The daemon-wide poller maps every account it can see — an OAuth account from
4creds.json, an API-key profile whose base url names a feed — onto provide-usage
5credentials and records each report as it came. This is the only place that
6wiring is visible from outside, so it is E2E: the usage core itself is a fake
7executable answering the `usage advance -` envelope (the real one needs
8accounts), pointed at by ALBEDO_USAGE_CORE with short poll settings.
9
10The cadence is asserted through the interval between one account's consecutive
11readings, not through poll counts over wall time: counts race the machine, and
12a hyper poll arriving a second late was read as "the busy cadence did not kick
13in" when the schedule was exactly as configured. The intervals are what the
14poller actually kept.
15"""
16
17import json
18from datetime import datetime
19import os
20import stat
21import tempfile
22import time
23import unittest
24import urllib.error
25
26from harness import Albedo, Provider, exclusive, text
27
28
29def milliseconds(timestamp):
30    return round(
31        datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() * 1000
32    )
33
34
35# The busy cadence must stay distinguishable from the ordinary one, and the
36# first failure backoff waits 2x the ordinary cadence, which sets the length.
37# How the backoff grows and where it stops is test/daemon/quota_test.gleam.
38POLL_SECONDS = 2
39BUSY_SECONDS = 1
40
41FAKE_USAGE = """#!/usr/bin/env python3
42# Fake usage-core: one `advance -` envelope line in, one report line out. Each
43# account is answered immediately, so one poll is one process. The provider in
44# the envelope picks the script: anthropic is ordinary (5 percent), hyper is
45# busy (85 percent, resetting soon), deepseek fails (an error report is data,
46# not a driver failure). The happy path touches no file, so nothing can make
47# it exit 1; anything unexpected is logged to fake-error.txt and answered as
48# an error report rather than crashing, which the driver would report as the
49# CLI itself failing.
50import json, os, sys, time, traceback
51
52ERRORS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake-error.txt")
53RECOVERED = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recovered")
54
55def report_for(provider, credential):
56    if provider == "anthropic" and credential.get("accountId") == "recovering-account":
57        # Fails its first poll, then answers for good. A failure with no limits
58        # is filed under the empty limit id, which a later success never reuses.
59        if not os.path.exists(RECOVERED):
60            open(RECOVERED, "w").close()
61            return {"error": "the fixture key failed once"}
62        return {"limits": [{"id": "weekly", "label": "Weekly usage", "usedPercent": 7,
63                            "windowLabel": "7 days", "status": "ok"}]}
64    if provider == "anthropic":
65        return {"planType": "pro",
66                "limits": [{"id": "weekly", "label": "Weekly usage",
67                            "usedPercent": 5, "windowLabel": "7 days",
68                            "status": "ok"}]}
69    if provider == "google-antigravity":
70        now_ms = int(time.time() * 1000)
71        return {
72            "limits": [
73                {
74                    "id": "gemini",
75                    "label": "Gemini",
76                    "usedPercent": 65,
77                    "windowLabel": "1 hour",
78                    "windowSeconds": 3600,
79                    "resetsAt": now_ms + 3600000,
80                    "status": "ok",
81                },
82                {
83                    "id": "claude",
84                    "label": "Claude",
85                    "usedPercent": 100,
86                    "windowLabel": "6 days",
87                    "windowSeconds": 518400,
88                    "resetsAt": 1799999999000,
89                    "status": "exceeded",
90                },
91                {
92                    "id": "gpt-oss",
93                    "label": "GPT-OSS",
94                    "usedPercent": 100,
95                    "windowLabel": "6 days",
96                    "windowSeconds": 518400,
97                    "resetsAt": 1799999999000,
98                    "status": "exceeded",
99                },
100            ]
101        }
102    if provider == "deepseek":
103        return {"error": "the fixture deepseek key has no quota"}
104    return {"limits": [{"id": "primary", "label": "Primary", "usedPercent": 85,
105                        "windowLabel": "1 hour", "windowSeconds": 3600,
106                        "resetsAt": int(time.time() * 1000) + 600000,
107                        "status": "ok"}]}
108
109try:
110    line = sys.stdin.readline()
111    envelope = json.loads(line)
112    print(json.dumps({"report": report_for(envelope["provider"], envelope.get("credential", {}))}))
113except Exception:
114    # A bad envelope lands here too: record it and answer an error report, so
115    # the failure shows up as a reading rather than as an exit-1 crash.
116    try:
117        with open(ERRORS, "a") as errors:
118            errors.write(traceback.format_exc())
119    except Exception:
120        pass
121    print(json.dumps({"report": {"error": "the fake usage core failed"}}))
122"""
123
124
125# exclusive: configures global quota cadence, credentials, and usage-core environment
126@exclusive
127class QuotaTests(unittest.TestCase):
128    def setUp(self):
129        self.provider = Provider(lambda _request: text("ok"))
130        self.addCleanup(self.provider.close)
131        self.core = tempfile.TemporaryDirectory(prefix="quota-core-")
132        self.addCleanup(self.core.cleanup)
133        self.usage = os.path.join(self.core.name, "usage")
134        with open(self.usage, "w") as out:
135            out.write(FAKE_USAGE)
136        os.chmod(self.usage, os.stat(self.usage).st_mode | stat.S_IEXEC)
137
138        def prepare(app):
139            # The poller resolves the core through the daemon's environment.
140            app.env["ALBEDO_USAGE_CORE"] = self.usage
141            app.write_extensions(
142                {
143                    "quota": {
144                        "pollSeconds": POLL_SECONDS,
145                        "busyPollSeconds": BUSY_SECONDS,
146                    }
147                }
148            )
149            app.store_secrets(
150                "accounts",
151                {
152                    "anthropic": [
153                        {
154                            "type": "oauth",
155                            "access": "fixture-anthropic-access",
156                            "refresh": "fixture-anthropic-refresh",
157                            "expires": int(time.time() * 1000) + 3600000,
158                            "accountId": "fixture-account",
159                        },
160                        {
161                            "type": "oauth",
162                            "access": "fixture-recovering-access",
163                            "refresh": "fixture-recovering-refresh",
164                            "expires": int(time.time() * 1000) + 3600000,
165                            "accountId": "recovering-account",
166                        },
167                    ],
168                    "google-antigravity": [
169                        {
170                            "type": "oauth",
171                            "access": "fixture-antigravity-access",
172                            "refresh": "fixture-antigravity-refresh",
173                            "expires": int(time.time() * 1000) + 3600000,
174                            "projectId": "fixture-project",
175                            "email": "antigravity@example.com",
176                            "limitedUntil": 1799999999000,
177                        }
178                    ],
179                },
180            )
181
182        self.app = Albedo(
183            self.provider,
184            providers={
185                "fixture": {
186                    "baseUrl": self.provider.url,
187                    "apiKey": "fixture-key",
188                    "model": "fixture-model",
189                    "protocol": "chat_completions",
190                },
191                "charm-hyper": {
192                    "baseUrl": "https://hyper.charm.land/v1",
193                    "apiKey": "fixture-hyper-key",
194                    "model": "fixture-model",
195                    "protocol": "chat_completions",
196                },
197                "deepseek-fixture": {
198                    "baseUrl": "https://api.deepseek.com/v1",
199                    "apiKey": "fixture-deepseek-key",
200                    "model": "fixture-model",
201                    "protocol": "chat_completions",
202                },
203            },
204            prepare=prepare,
205        )
206        # The daemon boots with the fake core once prepare has named it, so
207        # its cadence starts here.
208        self.since = time.time() * 1000
209        self.app.__enter__()
210        self.addCleanup(self.app.__exit__, None, None, None)
211
212    def readings(self):
213        with self.app.api("/server") as response:
214            return json.load(response)["quota"]
215
216    def samples(self):
217        """Every stored sample, newest first, through the history pages."""
218        rows, token = [], None
219        while len(rows) < 1000:
220            query = "&next=" + token if token else ""
221            with self.app.api(
222                f"/server?include=quota_history&limit=200{query}"
223            ) as response:
224                page = json.load(response)["quota_history"]
225            rows.extend(page["items"])
226            if page["next"] is None:
227                break
228            token = page["next"]
229        return rows
230
231    def observed(self, account):
232        """One timestamp per poll of one account since the boot, oldest first."""
233        return sorted(
234            {
235                milliseconds(row["observed_at"])
236                for row in self.samples()
237                if row["account_id"] == account
238                and milliseconds(row["observed_at"]) > self.since
239            }
240        )
241
242    def intervals(self, account):
243        times = self.observed(account)
244        return [later - earlier for earlier, later in zip(times, times[1:])]
245
246    def fake_errors(self):
247        try:
248            with open(os.path.join(self.core.name, "fake-error.txt")) as errors:
249                return errors.read()
250        except FileNotFoundError:
251            return ""
252
253    def wait_for(self, condition, timeout, message):
254        deadline = time.monotonic() + timeout
255        while time.monotonic() < deadline:
256            if condition():
257                return
258            time.sleep(0.25)
259        self.fail(message)
260
261    def test_readings_land_and_each_account_keeps_its_cadence(self):
262        # Readings land for every account: the OAuth account from creds.json,
263        # the hyper-charm profile, and the deepseek profile.
264        wanted = {
265            ("anthropic", "weekly"),
266            ("hyper", "primary"),
267            ("deepseek", ""),
268            ("google-antigravity", "gemini"),
269            ("google-antigravity", "claude"),
270            ("google-antigravity", "gpt-oss"),
271        }
272        reading = {}
273        deadline = time.monotonic() + 40
274        while time.monotonic() < deadline:
275            reading = {(r["provider"], r["limit_id"]): r for r in self.readings()}
276            if wanted.issubset(reading):
277                break
278            time.sleep(0.25)
279        else:
280            self.fail(
281                "quota readings did not land: "
282                + json.dumps(self.readings())
283                + self.fake_errors()
284            )
285        hyper = reading[("hyper", "primary")]
286        self.assertEqual(hyper["account_id"], "charm-hyper")
287        self.assertEqual(hyper["used_percent"], 85.0)
288        self.assertEqual(hyper["window_seconds"], 3600)
289        self.assertGreater(milliseconds(hyper["resets_at"]), int(time.time() * 1000))
290        self.assertEqual(hyper["status"], "ok")
291        self.assertEqual(hyper["source"], "poll")
292        anthropic = reading[("anthropic", "weekly")]
293        self.assertEqual(anthropic["account_id"], "fixture-account")
294        self.assertEqual(anthropic["used_percent"], 5.0)
295        # The report plan and each limit's window label are readings too, kept
296        # exactly as reported; where the report had neither, they stay null.
297        self.assertEqual(anthropic["plan"], "pro")
298        self.assertEqual(anthropic["window_label"], "7 days")
299        self.assertIsNone(hyper["plan"])
300        self.assertEqual(hyper["window_label"], "1 hour")
301        # A failed report is recorded as a reading too, error and all; a
302        # missing percentage stays unknown rather than becoming zero.
303        deepseek = reading[("deepseek", "")]
304        self.assertEqual(set(deepseek["error"]), {"code", "detail"})
305        self.assertEqual(deepseek["error"]["code"], "quota_failed")
306        self.assertIn("no quota", deepseek["error"]["detail"])
307        self.assertLessEqual(len(deepseek["error"]["detail"]), 4096)
308        self.assertIsNone(deepseek["used_percent"])
309        self.assertIsNone(deepseek["plan"])
310
311        # Google Antigravity readings for all three model-family meters
312        gemini = reading[("google-antigravity", "gemini")]
313        self.assertEqual(gemini["account_id"], "antigravity@example.com")
314        self.assertEqual(gemini["used_percent"], 65.0)
315        self.assertEqual(gemini["window_label"], "1 hour")
316        self.assertEqual(gemini["window_seconds"], 3600)
317        self.assertEqual(gemini["status"], "ok")
318
319        claude = reading[("google-antigravity", "claude")]
320        self.assertEqual(claude["account_id"], "antigravity@example.com")
321        self.assertEqual(claude["used_percent"], 100.0)
322        self.assertEqual(claude["window_label"], "6 days")
323        self.assertEqual(claude["window_seconds"], 518400)
324        self.assertEqual(claude["status"], "exceeded")
325
326        gpt_oss = reading[("google-antigravity", "gpt-oss")]
327        self.assertEqual(gpt_oss["account_id"], "antigravity@example.com")
328        self.assertEqual(gpt_oss["used_percent"], 100.0)
329        self.assertEqual(gpt_oss["window_label"], "6 days")
330        self.assertEqual(gpt_oss["window_seconds"], 518400)
331        self.assertEqual(gpt_oss["status"], "exceeded")
332
333        # The poller heal re-keyed creds.json with legacy limitedUntil replaced by limitedMeters
334        def healed():
335            creds_path = self.app.home / "creds.json"
336            if not creds_path.exists():
337                return False
338            data = (
339                json.loads(creds_path.read_text())
340                .get("accounts", {})
341                .get("google-antigravity", [])
342            )
343            return bool(data and "limitedMeters" in data[0])
344
345        self.wait_for(healed, 15, "poller heal did not re-key creds.json")
346        creds = json.loads((self.app.home / "creds.json").read_text())["accounts"][
347            "google-antigravity"
348        ][0]
349        self.assertNotIn("limitedUntil", creds)
350        self.assertIn("limitedMeters", creds)
351        self.assertEqual(creds["limitedMeters"]["claude"], 1799999999000)
352
353        # Enough polls of each account to read a cadence off the intervals.
354        self.wait_for(
355            lambda: len(self.observed("charm-hyper")) >= 5,
356            60,
357            "the busy cadence never produced five polls: "
358            + json.dumps(self.intervals("charm-hyper"))
359            + self.fake_errors(),
360        )
361        self.wait_for(
362            lambda: len(self.observed("fixture-account")) >= 3,
363            60,
364            "the ordinary cadence never produced three polls: "
365            + json.dumps(self.intervals("fixture-account"))
366            + self.fake_errors(),
367        )
368        self.wait_for(
369            lambda: len(self.observed("deepseek-fixture")) >= 2,
370            60,
371            "the failure backoff never produced a second poll: "
372            + json.dumps(self.intervals("deepseek-fixture"))
373            + self.fake_errors(),
374        )
375
376        # The schedule as the poller kept it: one account's consecutive
377        # readings are one poll duration plus its wait, so each cadence shows
378        # up as an interval with generous bounds either side of its setting.
379        # The busy cadence (85 percent, resetting soon) is busyPollSeconds,
380        # the ordinary one is pollSeconds, and the failing account backs off
381        # past the ordinary cadence.
382        busy = self.intervals("charm-hyper")
383        ordinary = self.intervals("fixture-account")
384        backed_off = self.intervals("deepseek-fixture")
385
386        def median(values):
387            return sorted(values)[len(values) // 2]
388
389        message = (
390            f"busy {busy}, ordinary {ordinary}, backed off {backed_off}, "
391            f"fake errors {self.fake_errors()!r}"
392        )
393        self.assertGreaterEqual(median(busy), BUSY_SECONDS * 900, message)
394        self.assertLess(
395            median(busy),
396            POLL_SECONDS * 900,
397            message + " — the busy account polled at the ordinary cadence",
398        )
399        self.assertGreaterEqual(median(ordinary), POLL_SECONDS * 900, message)
400        self.assertLess(median(ordinary), POLL_SECONDS * 1700, message)
401        self.assertGreaterEqual(
402            min(backed_off),
403            POLL_SECONDS * 1800,
404            message + " — failures did not back off",
405        )
406
407        # The feed is the fake, so a driver failure ("usage is not built", the
408        # CLI crashing, the feed not finishing) is a bug in the host half, not
409        # weather. An exit-1 burst would surface here as readings carrying it.
410        failures = [
411            row
412            for row in self.samples()
413            if milliseconds(row["observed_at"]) > self.since
414            and row["error"]
415            and "usage" in row["error"]["detail"]
416        ]
417        self.assertEqual(failures, [], "the usage driver failed against the fake")
418        self.assertEqual(self.fake_errors(), "")
419
420        # The latest reading per account and limit is the newest sample, and
421        # history pages from it by row id, newest first.
422        latest = {(r["provider"], r["limit_id"]): r for r in self.readings()}
423        with self.app.api("/server?include=quota_history&limit=200") as response:
424            page = json.load(response)["quota_history"]
425        samples = page["items"]
426        ids = [sample["sequence"] for sample in samples]
427        self.assertTrue(
428            all(later < earlier for earlier, later in zip(ids, ids[1:])),
429            f"history is not newest first: {ids}",
430        )
431        newest = max(
432            (s for s in samples if s["provider"] == "hyper"),
433            key=lambda s: s["sequence"],
434        )
435        self.assertEqual(
436            newest["observed_at"], latest[("hyper", "primary")]["observed_at"]
437        )
438        with self.app.api("/server?include=quota_history&limit=2") as response:
439            first = json.load(response)["quota_history"]
440        self.assertEqual(len(first["items"]), 2)
441        self.assertIsNotNone(first["next"])
442        with self.app.api(
443            f"/server?include=quota_history&limit=2&next={first['next']}"
444        ) as response:
445            older = json.load(response)["quota_history"]
446        self.assertTrue(older["items"])
447        self.assertTrue(
448            all(
449                sample["sequence"] < first["items"][-1]["sequence"]
450                for sample in older["items"]
451            ),
452            "quota continuation reached newer rows",
453        )
454
455        # An account that failed once and then answered has an old failure filed
456        # under the empty limit id. The latest readings drop it once a later
457        # reading of a real limit shows the account answering; deepseek, which
458        # never answers, keeps its failure.
459        self.wait_for(
460            lambda: any(
461                r["account_id"] != "fixture-account"
462                and r["provider"] == "anthropic"
463                and r["limit_id"] == "weekly"
464                for r in self.readings()
465            ),
466            40,
467            "the recovering account never answered",
468        )
469        recovered = [
470            r
471            for r in self.readings()
472            if r["provider"] == "anthropic" and r["account_id"] != "fixture-account"
473        ]
474        self.assertEqual(
475            [r["limit_id"] for r in recovered],
476            ["weekly"],
477            f"a failure the account recovered from is still its latest reading: {recovered}",
478        )
479        failures = [
480            (r["provider"], r["limit_id"]) for r in self.readings() if r["error"]
481        ]
482        self.assertIn(("deepseek", ""), failures)
483        self.assertTrue(
484            any(
485                row["limit_id"] == "" and row["error"]
486                for row in self.samples()
487                if row["provider"] == "anthropic"
488                and row["account_id"] != "fixture-account"
489            ),
490            "the recovering account never recorded the failure this checks is dropped",
491        )
492
493        # Filtered history returns only that limit's rows newest-first.
494        hyper_filter = (
495            "quota_provider=hyper&quota_account=charm-hyper&quota_limit=primary"
496        )
497        with self.app.api(
498            f"/server?include=quota_history&limit=200&{hyper_filter}"
499        ) as response:
500            hyper_history = json.load(response)["quota_history"]["items"]
501        self.assertGreaterEqual(len(hyper_history), 5)
502        self.assertTrue(
503            all(
504                s["provider"] == "hyper"
505                and s["account_id"] == "charm-hyper"
506                and s["limit_id"] == "primary"
507                for s in hyper_history
508            ),
509            "filtered history returned observations from other limits",
510        )
511        hyper_ids = [s["sequence"] for s in hyper_history]
512        self.assertTrue(
513            all(later < earlier for earlier, later in zip(hyper_ids, hyper_ids[1:])),
514            f"filtered history is not newest first: {hyper_ids}",
515        )
516
517        # quota_since in the future returns zero rows and next null.
518        future_since = "2099-01-01T00:00:00Z"
519        with self.app.api(
520            f"/server?include=quota_history&limit=200&{hyper_filter}&quota_since={future_since}"
521        ) as response:
522            future_page = json.load(response)["quota_history"]
523        self.assertEqual(future_page["items"], [])
524        self.assertIsNone(future_page["next"])
525
526        # since between two readings drops the older.
527        cutoff = hyper_history[1]["observed_at"]
528        with self.app.api(
529            f"/server?include=quota_history&limit=200&{hyper_filter}&quota_since={cutoff}"
530        ) as response:
531            bounded_items = json.load(response)["quota_history"]["items"]
532        self.assertTrue(
533            all(
534                milliseconds(s["observed_at"]) >= milliseconds(cutoff)
535                for s in bounded_items
536            ),
537            "quota_since kept observations older than cutoff",
538        )
539        bounded_ids = [s["sequence"] for s in bounded_items]
540        self.assertIn(hyper_history[0]["sequence"], bounded_ids)
541        self.assertIn(hyper_history[1]["sequence"], bounded_ids)
542        self.assertNotIn(hyper_history[-1]["sequence"], bounded_ids)
543
544        # Paging a filtered history with limit=1 follows next across all of that
545        # limit's rows with strictly decreasing sequence.
546        paged_seqs = []
547        next_cursor = None
548        while True:
549            cursor_param = f"&next={next_cursor}" if next_cursor else ""
550            with self.app.api(
551                f"/server?include=quota_history&limit=1&{hyper_filter}{cursor_param}"
552            ) as response:
553                page_data = json.load(response)["quota_history"]
554            self.assertEqual(len(page_data["items"]), 1)
555            paged_seqs.append(page_data["items"][0]["sequence"])
556            next_cursor = page_data["next"]
557            if not next_cursor:
558                break
559        self.assertEqual(paged_seqs, hyper_ids)
560        self.assertTrue(
561            all(later < earlier for earlier, later in zip(paged_seqs, paged_seqs[1:])),
562            "paged filtered sequence is not strictly decreasing",
563        )
564
565        # A cursor from one filter replayed on another is rejected 400.
566        with self.app.api(
567            f"/server?include=quota_history&limit=1&{hyper_filter}"
568        ) as response:
569            token = json.load(response)["quota_history"]["next"]
570        self.assertIsNotNone(token)
571        anthropic_filter = (
572            "quota_provider=anthropic&quota_account=fixture-account&quota_limit=weekly"
573        )
574        with self.assertRaises(urllib.error.HTTPError) as cm:
575            self.app.api(
576                f"/server?include=quota_history&limit=1&{anthropic_filter}&next={token}"
577            )
578        self.assertEqual(cm.exception.code, 400)
579
580        # Partial filter (only quota_account) is 400.
581        with self.assertRaises(urllib.error.HTTPError) as cm:
582            self.app.api("/server?include=quota_history&quota_account=charm-hyper")
583        self.assertEqual(cm.exception.code, 400)
584
585        # quota_since without the trio is 400.
586        with self.assertRaises(urllib.error.HTTPError) as cm:
587            self.app.api(f"/server?include=quota_history&quota_since={future_since}")
588        self.assertEqual(cm.exception.code, 400)
589
590        # Bad since is 400.
591        with self.assertRaises(urllib.error.HTTPError) as cm:
592            self.app.api(
593                f"/server?include=quota_history&{hyper_filter}&quota_since=not-a-timestamp"
594            )
595        self.assertEqual(cm.exception.code, 400)
596
597        # Filter parameters require include=quota_history.
598        with self.assertRaises(urllib.error.HTTPError) as cm:
599            self.app.api(f"/server?{hyper_filter}")
600        self.assertEqual(cm.exception.code, 400)