From cef34dfdd7e9b907258cbe76ded2a27e9b614804 Mon Sep 17 00:00:00 2001 From: tttboy123 <3383341447@qq.com> Date: Sat, 1 Aug 2026 18:41:39 +0800 Subject: [PATCH 1/3] refactor: add fact-first provider contracts --- .../2026-08-01-provider-card-retirement.md | 43 +++++--- openusage_bar/providers/__init__.py | 8 ++ openusage_bar/providers/builtins.py | 47 ++++++++- openusage_bar/providers/contracts.py | 80 ++++++++++++++- tests/test_adapter_registry.py | 98 ++++++++++++++++++- 5 files changed, 254 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-08-01-provider-card-retirement.md b/docs/superpowers/plans/2026-08-01-provider-card-retirement.md index a0427bd0..4103a979 100644 --- a/docs/superpowers/plans/2026-08-01-provider-card-retirement.md +++ b/docs/superpowers/plans/2026-08-01-provider-card-retirement.md @@ -4,7 +4,7 @@ **Goal:** Remove `ProviderCard` and `LegacyCardAdapter` from the Collector-to-ledger path while preserving the existing menu-bar and Usage Details output. -**Architecture:** Provider adapters return only fact-specific results and sanitized source failures. Static Provider identity moves into `ProviderBinding`; the Collector writes those identities and facts directly. `ProviderCard`, `Overview`, stale-card merging and the card cache remain presentation compatibility code until the Python UI is retired, but they are no longer inputs to the durable ledger. +**Architecture:** Provider adapters return only fact-specific results, sanitized source failures and the actual source attribution used by that attempt. Stable Provider identity moves into `ProviderBinding`; credential/source kind remains attempt-specific because one configured Provider (for example Step Plan) may use either an API key or a browser session. The Collector combines both into `ProviderInstance` and writes facts directly. `ProviderCard`, `Overview`, stale-card merging and the card cache remain presentation compatibility code until the Python UI is retired, but they are no longer inputs to the durable ledger. **Tech Stack:** Python 3 dataclasses and protocols, existing SQLite activity ledger, standard-library unittest, existing SwiftUI read-only client. @@ -41,8 +41,6 @@ ProviderDescriptor( family_id="minimax", display_name="MiniMax Primary", category="subscription", - credential_source="minimax_builtin_api", - source_kind="builtin_api", ) ``` @@ -52,8 +50,14 @@ Add a quota source exposing only: source_id = "minimax.coding_plan" source_priority = 20 -def fetch_quota(self) -> QuotaFetchResult: - return QuotaFetchFailure("quota_unavailable") +def fetch_quota(self) -> QuotaCollectionResult: + return QuotaCollectionResult( + result=QuotaFetchFailure("quota_unavailable"), + attribution=SourceAttribution( + credential_source="minimax_builtin_api", + source_kind="builtin_api", + ), + ) ``` Assert that a `fetch()`-only source is rejected from `quota_sources`, duplicate @@ -72,36 +76,44 @@ still accepts `LegacyCardAdapter` in `quota_sources`. - [ ] **Step 3: Implement the minimal contracts** -Add a frozen `ProviderDescriptor` with the six fields above. Validate identifiers -with `validate_id`, category against `PROVIDER_CATEGORIES`, source kind against the -public Provider catalog kinds and display name with `validate_safe_display_name`. +Add a frozen `ProviderDescriptor` with the four stable fields above. Validate +identifiers with `validate_id`, category against `PROVIDER_CATEGORIES` and display +name with `validate_safe_display_name`. Add frozen `SourceAttribution` with +`credential_source` and `source_kind`; validate the first as a stable ID and the +second against the Provider-instance source kinds. Add: ```python -def observed(self, observed_at: datetime) -> ProviderInstance: +def observed( + self, observed_at: datetime, attribution: SourceAttribution +) -> ProviderInstance: return ProviderInstance( provider_id=self.provider_id, family_id=self.family_id, display_name=self.display_name, category=self.category, - credential_source=self.credential_source, - source_kind=self.source_kind, + credential_source=attribution.credential_source, + source_kind=attribution.source_kind, observed_at=observed_at.isoformat(), ) ``` +Add `QuotaCollectionResult` and `BalanceCollectionResult` envelopes containing a +typed fact result plus `SourceAttribution`. This prevents a multi-mode adapter from +publishing a static credential/source claim that was not used by the attempt. + Change the protocols to: ```python class QuotaAdapter(Protocol): source_id: str source_priority: int - def fetch_quota(self) -> QuotaFetchResult: ... + def fetch_quota(self) -> QuotaCollectionResult: ... class BalanceAdapter(Protocol): source_id: str source_priority: int - def fetch_balance(self) -> BalanceFetchResult: ... + def fetch_balance(self) -> BalanceCollectionResult: ... ``` Make `ProviderBinding.descriptor` required and require its Provider/family IDs to @@ -159,8 +171,9 @@ Expected: the headless builder still calls card-producing `fetch()` and reads Change `LedgerRefresher` to own sorted tuples of `(ProviderDescriptor, adapter)`. Within one refresh it must call each fact method through `measure_source_call`, -capture a typed failure on exceptions, and pass immutable result tuples to the -Collector. Delete all reads of `last_quota_result` and `last_balance_result`. +capture a typed failure with the adapter's bounded public attribution on exceptions, +and pass immutable result tuples to the Collector. Delete all reads of +`last_quota_result` and `last_balance_result`. Change `ActivityCollector.refresh` to receive: diff --git a/openusage_bar/providers/__init__.py b/openusage_bar/providers/__init__.py index df9e5caa..233b83be 100644 --- a/openusage_bar/providers/__init__.py +++ b/openusage_bar/providers/__init__.py @@ -1,28 +1,36 @@ """Provider runtime contracts and registration.""" from .contracts import ( + BalanceCollectionResult, CostAdapter, CostImportSuccess, ImportFailure, ProviderBinding, + ProviderDescriptor, + QuotaCollectionResult, QuotaAdapter, QuotaFetchFailure, QuotaFetchSuccess, UsageAdapter, UsageImportSuccess, + SourceAttribution, ) from .registry import AdapterRegistry, UnknownProviderConfig __all__ = [ "AdapterRegistry", + "BalanceCollectionResult", "CostAdapter", "CostImportSuccess", "ImportFailure", "ProviderBinding", + "ProviderDescriptor", + "QuotaCollectionResult", "QuotaAdapter", "QuotaFetchFailure", "QuotaFetchSuccess", "UnknownProviderConfig", "UsageAdapter", "UsageImportSuccess", + "SourceAttribution", ] diff --git a/openusage_bar/providers/builtins.py b/openusage_bar/providers/builtins.py index 50890154..eef39ec6 100644 --- a/openusage_bar/providers/builtins.py +++ b/openusage_bar/providers/builtins.py @@ -32,7 +32,7 @@ ) from ..openusage_adapter import OpenUsageAdapter from ..step_plan import StepPlanAdapter, endpoints_for_site -from .contracts import ProviderBinding +from .contracts import ProviderBinding, ProviderDescriptor from .registry import AdapterRegistry @@ -56,6 +56,20 @@ def _quota_source( return _performance_source(source, source_class) +def _descriptor( + provider_id: str, + family_id: str, + display_name: str, + category: str, +) -> ProviderDescriptor: + return ProviderDescriptor( + provider_id=provider_id, + family_id=family_id, + display_name=display_name, + category=category, + ) + + def default_registry( *, clock: Callable[[], datetime], keychain: object ) -> AdapterRegistry: @@ -69,6 +83,9 @@ def default_registry( registry.register_global(lambda: ProviderBinding( provider_id="openusage", family_id="openusage", + descriptor=_descriptor( + "openusage", "openusage", "OpenUsage", "local_tool" + ), quota_sources=(_quota_source( OpenUsageAdapter(clock), "openusage.cards", 10, "child_process" ),), @@ -78,12 +95,16 @@ def default_registry( )) registry.register_global(lambda: ProviderBinding( provider_id="kiro_cli", family_id="kiro_cli", + descriptor=_descriptor( + "kiro_cli", "kiro_cli", "Kiro", "subscription" + ), quota_sources=(_quota_source( KiroQuotaAdapter(clock=clock), "kiro.codewhisperer", 20 ),), )) registry.register_global(lambda: ProviderBinding( provider_id="codex", family_id="codex", + descriptor=_descriptor("codex", "codex", "Codex", "subscription"), quota_sources=(_quota_source( CodexSubscriptionAdapter(clock=clock), "codex.local_rate_limits", @@ -111,6 +132,9 @@ def minimax(config: MiniMaxConfig) -> ProviderBinding: ) return ProviderBinding( provider_id=config.provider_id, family_id="minimax", + descriptor=_descriptor( + config.provider_id, "minimax", config.name, "subscription" + ), quota_sources=(_quota_source(MiniMaxCodingPlanAdapter( config, keychain, client, clock ), "minimax.coding_plan", 20),), @@ -124,6 +148,9 @@ def openai(config: OpenAIOrganizationConfig) -> ProviderBinding: ) return ProviderBinding( provider_id=config.provider_id, family_id="openai", + descriptor=_descriptor( + config.provider_id, "openai", config.name, "api" + ), quota_sources=(_quota_source( OpenAIOrganizationCardAdapter(config, keychain, clock), "openai.organization", 20 @@ -135,6 +162,9 @@ def moonshot(config: MoonshotConfig) -> ProviderBinding: return ProviderBinding( provider_id=config.provider_id, family_id="moonshot", + descriptor=_descriptor( + config.provider_id, "moonshot", config.name, "api" + ), balance_sources=( _performance_source( MoonshotBalanceAdapter( @@ -154,6 +184,9 @@ def daily_feed(config: DailyUsageFeedConfig) -> ProviderBinding: ) return ProviderBinding( provider_id=config.provider_id, family_id=config.family_id, + descriptor=_descriptor( + config.provider_id, config.family_id, config.name, "api" + ), quota_sources=(_quota_source( DailyUsageFeedCardAdapter(config, keychain, clock), "custom.daily", 20 @@ -170,6 +203,9 @@ def cost_feed(config: DailyCostFeedConfig) -> ProviderBinding: ) return ProviderBinding( provider_id=config.provider_id, family_id=config.family_id, + descriptor=_descriptor( + config.provider_id, config.family_id, config.name, "api" + ), quota_sources=(_quota_source( DailyCostFeedCardAdapter(config, keychain, clock), "custom.cost", 20 @@ -185,6 +221,9 @@ def step_plan(config: StepPlanConfig) -> ProviderBinding: ) return ProviderBinding( provider_id=config.provider_id, family_id="step_plan", + descriptor=_descriptor( + config.provider_id, "step_plan", config.name, "subscription" + ), quota_sources=(_quota_source(StepPlanAdapter( config, keychain, client, clock ), "step_plan.quota", 20),), @@ -194,6 +233,12 @@ def generic(config: GenericProviderConfig) -> ProviderBinding: return ProviderBinding( provider_id=config.provider_id, family_id=config.family_id or config.provider_id, + descriptor=_descriptor( + config.provider_id, + config.family_id or config.provider_id, + config.name, + "api", + ), quota_sources=(_quota_source(GenericHTTPSAdapter( config, keychain, generic_client, clock ), "generic.quota", 20),), diff --git a/openusage_bar/providers/contracts.py b/openusage_bar/providers/contracts.py index 771f5ddb..204f09d8 100644 --- a/openusage_bar/providers/contracts.py +++ b/openusage_bar/providers/contracts.py @@ -2,22 +2,69 @@ import re from dataclasses import dataclass -from datetime import date +from datetime import date, datetime from typing import Protocol from ..activity_records import ( BalanceObservation, DailyCostRow, DailyUsageRow, + ProviderInstance, QuotaObservation, + validate_id, + validate_safe_display_name, ) from ..models import Overview, ProviderCard +from ..provider_catalog import PROVIDER_CATEGORIES, SOURCE_KINDS + + +_PROVIDER_INSTANCE_SOURCE_KINDS = SOURCE_KINDS | frozenset({"generic_https"}) + + +@dataclass(frozen=True) +class SourceAttribution: + credential_source: str + source_kind: str + + def __post_init__(self) -> None: + validate_id("credential_source", self.credential_source) + if self.source_kind not in _PROVIDER_INSTANCE_SOURCE_KINDS: + raise ValueError("source_kind must be a canonical source kind") + + +@dataclass(frozen=True) +class ProviderDescriptor: + provider_id: str + family_id: str + display_name: str + category: str + + def __post_init__(self) -> None: + validate_id("provider_id", self.provider_id) + validate_id("family_id", self.family_id) + validate_safe_display_name(self.display_name) + if self.category not in PROVIDER_CATEGORIES: + raise ValueError("category must be a canonical provider category") + + def observed( + self, observed_at: datetime, attribution: SourceAttribution + ) -> ProviderInstance: + return ProviderInstance( + provider_id=self.provider_id, + family_id=self.family_id, + display_name=self.display_name, + category=self.category, + credential_source=attribution.credential_source, + source_kind=attribution.source_kind, + observed_at=observed_at.isoformat(), + ) class QuotaAdapter(Protocol): source_id: str + source_priority: int - def fetch_quota(self) -> "QuotaFetchResult": ... + def fetch_quota(self) -> "QuotaCollectionResult": ... class LegacyCardAdapter(Protocol): @@ -47,8 +94,9 @@ def fetch_costs(self, since: date, until: date) -> "CostImportResult": ... class BalanceAdapter(Protocol): source_id: str + source_priority: int - def fetch(self) -> Overview | ProviderCard: ... + def fetch_balance(self) -> "BalanceCollectionResult": ... _ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") @@ -155,16 +203,42 @@ def ok(self) -> bool: BalanceFetchResult = BalanceFetchSuccess | BalanceFetchFailure +@dataclass(frozen=True) +class QuotaCollectionResult: + result: QuotaFetchResult + attribution: SourceAttribution + + def __post_init__(self) -> None: + if not isinstance(self.result, (QuotaFetchSuccess, QuotaFetchFailure)): + raise TypeError("quota collection requires a typed result") + + +@dataclass(frozen=True) +class BalanceCollectionResult: + result: BalanceFetchResult + attribution: SourceAttribution + + def __post_init__(self) -> None: + if not isinstance(self.result, (BalanceFetchSuccess, BalanceFetchFailure)): + raise TypeError("balance collection requires a typed result") + + @dataclass(frozen=True) class ProviderBinding: provider_id: str family_id: str + descriptor: ProviderDescriptor balance_sources: tuple[BalanceAdapter, ...] = () quota_sources: tuple[QuotaAdapter | LegacyCardAdapter, ...] = () usage_sources: tuple[UsageAdapter, ...] = () cost_sources: tuple[CostAdapter, ...] = () def __post_init__(self) -> None: + if ( + self.descriptor.provider_id != self.provider_id + or self.descriptor.family_id != self.family_id + ): + raise ValueError("descriptor must match binding identity") object.__setattr__(self, "balance_sources", tuple(self.balance_sources)) object.__setattr__(self, "quota_sources", tuple(self.quota_sources)) object.__setattr__(self, "usage_sources", tuple(self.usage_sources)) diff --git a/tests/test_adapter_registry.py b/tests/test_adapter_registry.py index ee527a67..af78ef91 100644 --- a/tests/test_adapter_registry.py +++ b/tests/test_adapter_registry.py @@ -31,7 +31,13 @@ from openusage_bar.openusage_adapter import OpenUsageAdapter from openusage_bar.performance_timing import RefreshTimingRecorder from openusage_bar.providers.builtins import default_registry -from openusage_bar.providers.contracts import ProviderBinding +from openusage_bar.providers.contracts import ( + ProviderBinding, + ProviderDescriptor, + QuotaCollectionResult, + QuotaFetchFailure, + SourceAttribution, +) from openusage_bar.providers.registry import AdapterRegistry, UnknownProviderConfig from openusage_bar.step_plan import StepPlanAdapter @@ -39,6 +45,17 @@ NOW = datetime(2026, 7, 18, tzinfo=timezone.utc) +def descriptor( + provider_id: str, family_id: str = "custom" +) -> ProviderDescriptor: + return ProviderDescriptor( + provider_id=provider_id, + family_id=family_id, + display_name=provider_id.replace("-", " ").title(), + category="api", + ) + + class AdapterRegistryTests(unittest.TestCase): def registry(self) -> AdapterRegistry: return default_registry( @@ -78,6 +95,75 @@ def configs(self): ), ] + def test_provider_descriptor_combines_stable_identity_with_attempt_source(self): + descriptor = ProviderDescriptor( + provider_id="step-work", + family_id="step_plan", + display_name="Step Plan Work", + category="subscription", + ) + + instance = descriptor.observed( + NOW, + SourceAttribution( + credential_source="step_plan_browser_session", + source_kind="browser_session", + ), + ) + + self.assertEqual(instance.provider_id, "step-work") + self.assertEqual(instance.family_id, "step_plan") + self.assertEqual(instance.display_name, "Step Plan Work") + self.assertEqual(instance.category, "subscription") + self.assertEqual( + instance.credential_source, "step_plan_browser_session" + ) + self.assertEqual(instance.source_kind, "browser_session") + self.assertEqual(instance.observed_at, "2026-07-18T00:00:00.000000Z") + + def test_provider_binding_rejects_descriptor_identity_mismatch(self): + descriptor = ProviderDescriptor( + provider_id="different", + family_id="step_plan", + display_name="Step Plan", + category="subscription", + ) + + with self.assertRaisesRegex( + ValueError, "descriptor must match binding identity" + ): + ProviderBinding( + provider_id="step-work", + family_id="step_plan", + descriptor=descriptor, + ) + + def test_collection_result_keeps_failure_separate_from_source_attribution(self): + attribution = SourceAttribution( + credential_source="step_plan_official_api", + source_kind="official_api", + ) + + collection = QuotaCollectionResult( + result=QuotaFetchFailure("auth_required"), + attribution=attribution, + ) + + self.assertEqual(collection.result.error_code, "auth_required") + self.assertEqual(collection.attribution, attribution) + + def test_source_attribution_rejects_noncanonical_public_values(self): + with self.assertRaisesRegex(ValueError, "credential_source"): + SourceAttribution( + credential_source="not canonical", + source_kind="official_api", + ) + with self.assertRaisesRegex(ValueError, "source_kind"): + SourceAttribution( + credential_source="step_plan_official_api", + source_kind="unknown_transport", + ) + def test_current_configs_build_the_existing_adapter_and_importer_graph(self): bindings = { binding.provider_id: binding @@ -299,14 +385,19 @@ def fetch(self): # pragma: no cover - structural fixture only registry = AdapterRegistry() registry.register_global(lambda: ProviderBinding( provider_id="duplicate-sources", family_id="custom", + descriptor=descriptor("duplicate-sources"), quota_sources=(Source(), Source()), )) with self.assertRaisesRegex(ValueError, "duplicate quota source IDs"): registry.build([]) registry = AdapterRegistry() - registry.register_global(lambda: ProviderBinding("same", "one")) - registry.register_global(lambda: ProviderBinding("same", "two")) + registry.register_global(lambda: ProviderBinding( + "same", "one", descriptor("same", "one") + )) + registry.register_global(lambda: ProviderBinding( + "same", "two", descriptor("same", "two") + )) with self.assertRaisesRegex(ValueError, "duplicate provider IDs"): registry.build([]) @@ -321,6 +412,7 @@ def fetch(self): # pragma: no cover - structural fixture only registry = AdapterRegistry() registry.register_global(lambda: ProviderBinding( provider_id="ordered", family_id="custom", + descriptor=descriptor("ordered"), quota_sources=( Source("z", 20), Source("b", 10), Source("a", 10), ), From 2c8b7bf71540e4b5fc89198b7d14079af91cf7ac Mon Sep 17 00:00:00 2001 From: tttboy123 <3383341447@qq.com> Date: Sat, 1 Aug 2026 18:57:19 +0800 Subject: [PATCH 2/3] refactor: collect provider facts directly --- openusage_bar/aggregator.py | 134 ++++++++++++++++++++++++---- openusage_bar/codex_subscription.py | 43 +++++++-- openusage_bar/daily_history.py | 35 ++++++-- openusage_bar/generic.py | 74 ++++++++++++++- openusage_bar/kiro.py | 75 +++++++++++----- openusage_bar/minimax.py | 50 ++++++++++- openusage_bar/moonshot.py | 45 +++++++++- openusage_bar/providers/builtins.py | 48 +++++----- openusage_bar/step_plan.py | 95 ++++++++++++++++---- tests/test_adapter_registry.py | 25 +++--- tests/test_aggregator.py | 125 +++++++++++++++++++++----- tests/test_codex_subscription.py | 38 +++++++- tests/test_generic.py | 41 ++++++++- tests/test_kiro.py | 19 +++- tests/test_minimax.py | 46 +++++++++- tests/test_moonshot.py | 38 +++++++- tests/test_step_plan.py | 40 ++++++++- 17 files changed, 842 insertions(+), 129 deletions(-) diff --git a/openusage_bar/aggregator.py b/openusage_bar/aggregator.py index 4986ab0c..8f7c0158 100644 --- a/openusage_bar/aggregator.py +++ b/openusage_bar/aggregator.py @@ -20,6 +20,13 @@ measure_source_call, source_class_for, ) +from .providers.contracts import ( + BalanceCollectionResult, + BalanceFetchFailure, + ProviderDescriptor, + QuotaCollectionResult, + QuotaFetchFailure, +) DEFAULT_CACHE_PATH = Path.home() / ".local" / "state" / "openusage-bar" / "cards.json" @@ -251,14 +258,20 @@ class LedgerRefresher: def __init__( self, aggregator, collector, quota_sources=(), balance_sources=(), *, + provider_descriptors=(), + provider_attributions=(), eager_usage_provider_ids=(), + clock=None, timing_recorder: RefreshTimingRecorder | None = None, ) -> None: self.aggregator = aggregator self.collector = collector self.quota_sources = tuple(quota_sources) self.balance_sources = tuple(balance_sources) + self.provider_descriptors = tuple(provider_descriptors) + self.provider_attributions = tuple(provider_attributions) self.eager_usage_provider_ids = tuple(eager_usage_provider_ids) + self.clock = clock or (lambda: datetime.now(timezone.utc)) self.timing_recorder = timing_recorder def refresh(self) -> None: @@ -268,20 +281,86 @@ def refresh(self) -> None: except Exception: pass overview = self.aggregator.refresh() - results = tuple( - (provider_id, source_id, result) - for provider_id, source_id, adapter in self.quota_sources - if (result := getattr(adapter, "last_quota_result", None)) is not None - ) - balance_results = tuple( - (provider_id, source_id, result) - for provider_id, source_id, adapter in self.balance_sources - if (result := getattr(adapter, "last_balance_result", None)) is not None - ) + attempted_at = self.clock().astimezone(timezone.utc) + instances = { + descriptor.provider_id: descriptor.observed( + attempted_at, attribution + ) + for descriptor, attribution in self.provider_attributions + } + results = [] + for descriptor, source_id, adapter in self.quota_sources: + try: + collection = measure_source_call( + self.timing_recorder, + source_class_for(adapter, "network"), + adapter.fetch_quota, + ) + except Exception: + results.append(( + descriptor.provider_id, + source_id, + QuotaFetchFailure("unexpected_failure"), + )) + continue + if not isinstance(collection, QuotaCollectionResult): + results.append(( + descriptor.provider_id, + source_id, + QuotaFetchFailure("invalid_import_result"), + )) + continue + results.append((descriptor.provider_id, source_id, collection.result)) + try: + instances[descriptor.provider_id] = descriptor.observed( + attempted_at, collection.attribution + ) + except (TypeError, ValueError): + pass + + balance_results = [] + for descriptor, source_id, adapter in self.balance_sources: + try: + collection = measure_source_call( + self.timing_recorder, + source_class_for(adapter, "network"), + adapter.fetch_balance, + ) + except Exception: + balance_results.append(( + descriptor.provider_id, + source_id, + BalanceFetchFailure("unexpected_failure"), + )) + continue + if not isinstance(collection, BalanceCollectionResult): + balance_results.append(( + descriptor.provider_id, + source_id, + BalanceFetchFailure("invalid_import_result"), + )) + continue + balance_results.append(( + descriptor.provider_id, source_id, collection.result + )) + try: + instances[descriptor.provider_id] = descriptor.observed( + attempted_at, collection.attribution + ) + except (TypeError, ValueError): + pass + self.collector.refresh( overview, - balance_results=balance_results, - quota_results=results, + provider_instances=tuple( + instances[provider_id] for provider_id in sorted(instances) + ), + provider_families={ + descriptor.provider_id: descriptor.family_id + for descriptor in self.provider_descriptors + }, + balance_results=tuple(balance_results), + quota_results=tuple(results), ) def performance_timing_snapshot(self) -> dict: @@ -322,6 +401,8 @@ def build_headless_refresher( ) for binding in bindings for adapter in (*binding.quota_sources, *binding.balance_sources) + if not hasattr(adapter, "fetch_quota") + and not hasattr(adapter, "fetch_balance") )] openusage_importer = next( source @@ -356,20 +437,21 @@ def build_headless_refresher( ) quota_sources = tuple( ( - binding.provider_id, + binding.descriptor, getattr(adapter, "source_id", type(adapter).__name__), adapter, ) for binding in bindings for adapter in binding.quota_sources - if hasattr(adapter, "last_quota_result") + if hasattr(adapter, "fetch_quota") ) balance_sources = tuple( ( - binding.provider_id, + binding.descriptor, getattr(adapter, "source_id", type(adapter).__name__), adapter, ) for binding in bindings for adapter in binding.balance_sources + if hasattr(adapter, "fetch_balance") ) eager_usage_provider_ids = tuple(sorted( {"codex"} | { @@ -378,8 +460,30 @@ def build_headless_refresher( if getattr(importer, "eager_local", False) is True } )) + attributed_providers = {} + for binding in bindings: + sources = {id(source): source for source in ( + *binding.usage_sources, *binding.cost_sources, + )} + for source in sources.values(): + attribution = getattr(source, "source_attribution", None) + if attribution is not None: + attributed_providers.setdefault( + binding.provider_id, + (binding.descriptor, attribution), + ) return LedgerRefresher( aggregator, collector, quota_sources, balance_sources, + provider_descriptors=tuple( + binding.descriptor + for binding in bindings + if binding.provider_id != "openusage" + ), + provider_attributions=tuple( + attributed_providers[provider_id] + for provider_id in sorted(attributed_providers) + ), eager_usage_provider_ids=eager_usage_provider_ids, + clock=clock, timing_recorder=timing_recorder, ) diff --git a/openusage_bar/codex_subscription.py b/openusage_bar/codex_subscription.py index 1b5091cd..e1a96056 100644 --- a/openusage_bar/codex_subscription.py +++ b/openusage_bar/codex_subscription.py @@ -7,7 +7,12 @@ from typing import Any, Iterator from .models import Category, Overview, ProviderCard, ProviderStatus -from .providers.contracts import QuotaFetchFailure, QuotaFetchSuccess +from .providers.contracts import ( + QuotaCollectionResult, + QuotaFetchFailure, + QuotaFetchSuccess, + SourceAttribution, +) from .providers.quota import percent_observation @@ -246,6 +251,11 @@ def window_id(minutes: int) -> str: class CodexSubscriptionAdapter: + _ATTRIBUTION = SourceAttribution( + credential_source="codex_local_log", + source_kind="local_log", + ) + def __init__( self, sessions_root: Path = DEFAULT_SESSIONS_ROOT, @@ -257,15 +267,36 @@ def __init__( self.max_files = max_files self.last_quota_result = QuotaFetchFailure("not_collected") - def fetch(self) -> Overview: + def _collect( + self, now: datetime, + ) -> tuple[ + tuple[dict[str, Any], datetime] | None, + QuotaCollectionResult, + ]: event = latest_rate_limit_event(self.sessions_root, self.max_files) if event is None: - self.last_quota_result = QuotaFetchFailure("quota_unavailable") - return Overview([]) + return None, QuotaCollectionResult( + result=QuotaFetchFailure("quota_unavailable"), + attribution=self._ATTRIBUTION, + ) rate_limits, observed_at = event - now = self.clock() - self.last_quota_result = parse_rate_limit_observations( + result = parse_rate_limit_observations( rate_limits, observed_at, now ) + return event, QuotaCollectionResult( + result=result, + attribution=self._ATTRIBUTION, + ) + + def fetch_quota(self) -> QuotaCollectionResult: + return self._collect(self.clock())[1] + + def fetch(self) -> Overview: + now = self.clock() + event, collection = self._collect(now) + self.last_quota_result = collection.result + if event is None: + return Overview([]) + rate_limits, observed_at = event card = parse_rate_limit_card(rate_limits, observed_at, now) return Overview([card] if card else []) diff --git a/openusage_bar/daily_history.py b/openusage_bar/daily_history.py index c2c8e05b..3dd5ca87 100644 --- a/openusage_bar/daily_history.py +++ b/openusage_bar/daily_history.py @@ -784,7 +784,9 @@ def _persist_current_quotas( @staticmethod def _provider_ids( - overview: Overview, official_importers: Mapping[str, Any] + overview: Overview, + official_importers: Mapping[str, Any], + provider_families: Mapping[str, str] | None = None, ) -> tuple[str, ...]: return tuple(sorted( { @@ -793,10 +795,14 @@ def _provider_ids( if card.provider_id != "openusage" } | set(official_importers) + | set(provider_families or {}) )) def _publish_provider_instances( - self, overview: Overview, attempted_at: datetime + self, + overview: Overview, + attempted_at: datetime, + provider_instances: tuple[ProviderInstance, ...] = (), ) -> None: for card in sorted(overview.cards, key=lambda item: item.provider_id): try: @@ -805,6 +811,13 @@ def _publish_provider_instances( self.store.upsert_provider_instance(instance) except Exception: pass + for instance in sorted( + provider_instances, key=lambda item: item.provider_id + ): + try: + self.store.upsert_provider_instance(instance) + except Exception: + pass def _refresh_quota_sources( self, @@ -898,11 +911,13 @@ def _refresh_usage_sources( provider_ids: tuple[str, ...], today: date, attempted_at: datetime, + provider_families: Mapping[str, str] | None = None, ) -> None: fallback_families = { card.provider_id: card.family_id or card.provider_id for card in overview.cards } + fallback_families.update(provider_families or {}) fallback_family_counts: dict[str, int] = {} for configured_id in self.official_importers: family_id = fallback_families.get(configured_id, configured_id) @@ -1109,6 +1124,8 @@ def refresh( self, overview: Overview, *, + provider_instances: tuple[ProviderInstance, ...] = (), + provider_families: Mapping[str, str] | None = None, balance_results: tuple[tuple[str, str, object], ...] = (), quota_results: tuple[tuple[str, str, object], ...] = (), ) -> bool: @@ -1118,12 +1135,20 @@ def refresh( current = self.clock() attempted_at = current.astimezone(timezone.utc) today = current.astimezone(self.local_timezone).date() - provider_ids = self._provider_ids(overview, self.official_importers) - self._publish_provider_instances(overview, attempted_at) + provider_ids = self._provider_ids( + overview, self.official_importers, provider_families + ) + self._publish_provider_instances( + overview, attempted_at, provider_instances + ) self._refresh_balance_sources(attempted_at, balance_results) self._refresh_quota_sources(overview, attempted_at, quota_results) self._refresh_usage_sources( - overview, provider_ids, today, attempted_at + overview, + provider_ids, + today, + attempted_at, + provider_families, ) self._refresh_cost_sources(provider_ids, today, attempted_at) try: diff --git a/openusage_bar/generic.py b/openusage_bar/generic.py index 64721394..e414e9aa 100644 --- a/openusage_bar/generic.py +++ b/openusage_bar/generic.py @@ -4,10 +4,15 @@ from typing import Any, Callable from .config import GenericProviderConfig -from .keychain import MacOSKeychain +from .keychain import KeychainError, MacOSKeychain from .models import Category, ProviderCard, ProviderStatus from .network import AuthenticationRequired, BoundedHTTPClient, NetworkError, RateLimited -from .providers.contracts import QuotaFetchFailure, QuotaFetchSuccess +from .providers.contracts import ( + QuotaCollectionResult, + QuotaFetchFailure, + QuotaFetchSuccess, + SourceAttribution, +) from .providers.quota import percent_observation @@ -37,6 +42,11 @@ def _parse_reset(value: Any) -> datetime: class GenericHTTPSAdapter: + _ATTRIBUTION = SourceAttribution( + credential_source="api_key", + source_kind="generic_https", + ) + def __init__( self, config: GenericProviderConfig, @@ -114,6 +124,66 @@ def fetch(self) -> ProviderCard: self.last_quota_result = QuotaFetchFailure("invalid_response") return self._error_card(ProviderStatus.ERROR, "Provider refresh failed", now) + def fetch_quota(self) -> QuotaCollectionResult: + now = self.clock() + try: + secret = self.keychain.get(self.config.provider_id) + except KeychainError: + return QuotaCollectionResult( + result=QuotaFetchFailure("keychain_unavailable"), + attribution=self._ATTRIBUTION, + ) + if not secret: + result = QuotaFetchFailure("auth_required") + else: + value = f"{self.config.auth_prefix} {secret}".strip() + try: + payload = self.client.get_json( + self.config.endpoint, + {self.config.header_name: value}, + ) + if ( + self.config.remaining_percent_path is None + or self.config.quota_window is None + ): + result = QuotaFetchFailure("quota_unavailable") + else: + remaining = float(extract_path( + payload, self.config.remaining_percent_path + )) + if not 0 <= remaining <= 100: + raise ValueError( + "Remaining percentage must be between 0 and 100" + ) + resets_at = ( + _parse_reset(extract_path(payload, self.config.reset_path)) + if self.config.reset_path + else None + ) + result = QuotaFetchSuccess((percent_observation( + provider_id=self.config.provider_id, + account_ref=self.config.account_ref, + source_id="generic.quota", + quota_name=self.config.quota_name, + quota_window=self.config.quota_window, + remaining_percent=remaining, + resets_at=resets_at, + observed_at=now, + applies_to_kind="account", + ),)) + except AuthenticationRequired: + result = QuotaFetchFailure("auth_rejected") + except RateLimited: + result = QuotaFetchFailure("rate_limited") + except NetworkError: + result = QuotaFetchFailure("network_error") + except (MissingField, TypeError, ValueError, OverflowError): + result = QuotaFetchFailure("invalid_response") + return QuotaCollectionResult( + result=result, + attribution=self._ATTRIBUTION, + ) + def _error_card(self, status: ProviderStatus, error: str, now: datetime) -> ProviderCard: return ProviderCard( provider_id=self.config.provider_id, diff --git a/openusage_bar/kiro.py b/openusage_bar/kiro.py index 025be8ae..9b2ba5f2 100644 --- a/openusage_bar/kiro.py +++ b/openusage_bar/kiro.py @@ -16,7 +16,12 @@ from .models import Category, Overview, ProviderCard, ProviderStatus from .network import AuthenticationRequired, BoundedHTTPClient, NetworkError, RateLimited from .activity_store import QuotaObservation -from .providers.contracts import QuotaFetchFailure, QuotaFetchSuccess +from .providers.contracts import ( + QuotaCollectionResult, + QuotaFetchFailure, + QuotaFetchSuccess, + SourceAttribution, +) logger = logging.getLogger(__name__) @@ -263,6 +268,11 @@ def parse_kiro_quota_observations( class KiroQuotaAdapter: + _ATTRIBUTION = SourceAttribution( + credential_source="kiro_codewhisperer_api", + source_kind="official_api", + ) + def __init__( self, client: BoundedHTTPClient | None = None, @@ -274,12 +284,21 @@ def __init__( self.token_reader = token_reader or SecurityKiroTokenReader() self.last_quota_result = QuotaFetchFailure("not_collected") - def fetch(self) -> Overview: + def _collection( + self, result: QuotaFetchSuccess | QuotaFetchFailure + ) -> QuotaCollectionResult: + return QuotaCollectionResult( + result=result, + attribution=self._ATTRIBUTION, + ) + + def _collect(self) -> tuple[Overview, QuotaCollectionResult]: try: raw = self.token_reader.read() if raw is None: - self.last_quota_result = QuotaFetchFailure("quota_unavailable") - return Overview([]) + return Overview([]), self._collection( + QuotaFetchFailure("quota_unavailable") + ) credentials = parse_kiro_credentials(raw) endpoint = self._endpoint(credentials) client = self.client or BoundedHTTPClient( @@ -288,31 +307,45 @@ def fetch(self) -> Overview: ) payload = client.get_json(endpoint, self._headers(credentials)) now = self.clock() - self.last_quota_result = parse_kiro_quota_observations( - payload, now + return Overview([parse_kiro_quota(payload, now)]), self._collection( + parse_kiro_quota_observations(payload, now) ) - return Overview([parse_kiro_quota(payload, now)]) except KeychainError: - self.last_quota_result = QuotaFetchFailure("keychain_unavailable") - return self._unavailable("keychain read failed") + return self._unavailable("keychain read failed"), self._collection( + QuotaFetchFailure("keychain_unavailable") + ) except KiroCredentialError: - self.last_quota_result = QuotaFetchFailure("auth_rejected") - return self._unavailable("credential data invalid") + return self._unavailable("credential data invalid"), self._collection( + QuotaFetchFailure("auth_rejected") + ) except AuthenticationRequired: - self.last_quota_result = QuotaFetchFailure("auth_rejected") - return self._unavailable("authentication required") + return self._unavailable("authentication required"), self._collection( + QuotaFetchFailure("auth_rejected") + ) except RateLimited: - self.last_quota_result = QuotaFetchFailure("rate_limited") - return self._unavailable("rate limit reached") + return self._unavailable("rate limit reached"), self._collection( + QuotaFetchFailure("rate_limited") + ) except NetworkError: - self.last_quota_result = QuotaFetchFailure("network_error") - return self._unavailable("network request failed") + return self._unavailable("network request failed"), self._collection( + QuotaFetchFailure("network_error") + ) except (KiroParseError, TypeError, ValueError): - self.last_quota_result = QuotaFetchFailure("invalid_response") - return self._unavailable("response data invalid") + return self._unavailable("response data invalid"), self._collection( + QuotaFetchFailure("invalid_response") + ) except Exception: - self.last_quota_result = QuotaFetchFailure("unexpected_failure") - return self._unavailable("unexpected failure") + return self._unavailable("unexpected failure"), self._collection( + QuotaFetchFailure("unexpected_failure") + ) + + def fetch_quota(self) -> QuotaCollectionResult: + return self._collect()[1] + + def fetch(self) -> Overview: + overview, collection = self._collect() + self.last_quota_result = collection.result + return overview @staticmethod def _endpoint(credentials: KiroCredentials) -> str: diff --git a/openusage_bar/minimax.py b/openusage_bar/minimax.py index 1cb311d9..47dabbf1 100644 --- a/openusage_bar/minimax.py +++ b/openusage_bar/minimax.py @@ -8,12 +8,17 @@ from .activity_store import DailyUsageRow from .config import MiniMaxConfig -from .keychain import MacOSKeychain +from .keychain import KeychainError, MacOSKeychain from .model_ids import InvalidModelID, canonical_model_id from .models import Category, ProviderCard, ProviderStatus from .network import AuthenticationRequired, BoundedHTTPClient, NetworkError, RateLimited from .providers.contracts import ImportFailure, UsageImportResult, UsageImportSuccess -from .providers.contracts import QuotaFetchFailure, QuotaFetchSuccess +from .providers.contracts import ( + QuotaCollectionResult, + QuotaFetchFailure, + QuotaFetchSuccess, + SourceAttribution, +) from .providers.quota import percent_observation @@ -383,6 +388,11 @@ def _imported_at(self) -> str: class MiniMaxCodingPlanAdapter: + _ATTRIBUTION = SourceAttribution( + credential_source="minimax_builtin_api", + source_kind="builtin_api", + ) + def __init__( self, config: MiniMaxConfig, @@ -544,6 +554,42 @@ def fetch(self) -> ProviderCard: self.last_quota_result = QuotaFetchFailure("network_error") return self._error_card(ProviderStatus.ERROR, "MiniMax refresh failed", now) + def fetch_quota(self) -> QuotaCollectionResult: + now = self.clock() + try: + secret = self.keychain.get(self.config.provider_id) + except KeychainError: + return QuotaCollectionResult( + result=QuotaFetchFailure("keychain_unavailable"), + attribution=self._ATTRIBUTION, + ) + if not secret: + result = QuotaFetchFailure("auth_required") + else: + try: + payload = self.client.get_json( + self.endpoints.quota, + { + "Authorization": f"Bearer {secret}", + "Content-Type": "application/json", + }, + ) + result = parse_minimax_quota_observations( + self.config, payload, now + ) + except AuthenticationRequired: + result = QuotaFetchFailure("auth_rejected") + except RateLimited: + result = QuotaFetchFailure("rate_limited") + except NetworkError: + result = QuotaFetchFailure("network_error") + except (MiniMaxParseError, TypeError, ValueError, OverflowError): + result = QuotaFetchFailure("invalid_response") + return QuotaCollectionResult( + result=result, + attribution=self._ATTRIBUTION, + ) + def _error_card(self, status: ProviderStatus, error: str, now: datetime) -> ProviderCard: return ProviderCard( provider_id=self.config.provider_id, diff --git a/openusage_bar/moonshot.py b/openusage_bar/moonshot.py index 069d846d..f0e94c6d 100644 --- a/openusage_bar/moonshot.py +++ b/openusage_bar/moonshot.py @@ -6,6 +6,7 @@ from .activity_records import BalanceObservation, canonical_decimal from .config import MoonshotConfig +from .keychain import KeychainError from .models import Category, ProviderCard, ProviderStatus from .network import ( AuthenticationRequired, @@ -15,7 +16,12 @@ RateLimited, ResponseTooLarge, ) -from .providers.contracts import BalanceFetchFailure, BalanceFetchSuccess +from .providers.contracts import ( + BalanceCollectionResult, + BalanceFetchFailure, + BalanceFetchSuccess, + SourceAttribution, +) MOONSHOT_ENDPOINTS = { @@ -56,6 +62,10 @@ def _compact_amount(value: str) -> str: class MoonshotBalanceAdapter: source_id = "moonshot.balance" source_priority = 20 + _ATTRIBUTION = SourceAttribution( + credential_source="moonshot_official_api", + source_kind="official_api", + ) def __init__( self, @@ -168,6 +178,39 @@ def fetch(self) -> ProviderCard: ProviderStatus.ERROR, "Balance request failed", now ) + def fetch_balance(self) -> BalanceCollectionResult: + now = self.clock() + try: + secret = self.keychain.get(self.config.provider_id) + except KeychainError: + return BalanceCollectionResult( + result=BalanceFetchFailure("keychain_unavailable"), + attribution=self._ATTRIBUTION, + ) + if not secret: + result = BalanceFetchFailure("authentication_required") + else: + try: + payload = self.client.get_json( + endpoint_for_site(self.config.site), + {"Authorization": f"Bearer {secret}"}, + ) + result = BalanceFetchSuccess((self._observation(payload, now),)) + except AuthenticationRequired: + result = BalanceFetchFailure("authentication_required") + except RateLimited: + result = BalanceFetchFailure("rate_limited") + except (KeyError, TypeError, ValueError, MalformedResponse): + result = BalanceFetchFailure("invalid_response") + except ResponseTooLarge: + result = BalanceFetchFailure("response_too_large") + except NetworkError: + result = BalanceFetchFailure("network_error") + return BalanceCollectionResult( + result=result, + attribution=self._ATTRIBUTION, + ) + def _error_card( self, status: ProviderStatus, message: str, now: datetime ) -> ProviderCard: diff --git a/openusage_bar/providers/builtins.py b/openusage_bar/providers/builtins.py index eef39ec6..45e1ac84 100644 --- a/openusage_bar/providers/builtins.py +++ b/openusage_bar/providers/builtins.py @@ -14,8 +14,8 @@ OpenAIOrganizationConfig, StepPlanConfig, ) -from ..cost_feed import DailyCostFeedCardAdapter, DailyCostFeedImporter -from ..daily_feed import DailyUsageFeedCardAdapter, DailyUsageFeedImporter +from ..cost_feed import DailyCostFeedImporter +from ..daily_feed import DailyUsageFeedImporter from ..daily_history import OpenUsageDailyImporter from ..generic import GenericHTTPSAdapter from ..kiro import KiroQuotaAdapter @@ -26,13 +26,10 @@ ) from ..moonshot import MoonshotBalanceAdapter from ..network import BoundedHTTPClient -from ..openai_organization import ( - OpenAIOrganizationCardAdapter, - OpenAIOrganizationImporter, -) +from ..openai_organization import OpenAIOrganizationImporter from ..openusage_adapter import OpenUsageAdapter from ..step_plan import StepPlanAdapter, endpoints_for_site -from .contracts import ProviderBinding, ProviderDescriptor +from .contracts import ProviderBinding, ProviderDescriptor, SourceAttribution from .registry import AdapterRegistry @@ -43,6 +40,19 @@ def _performance_source(source: object, source_class: str) -> object: return source +def _attributed_source( + source: object, + source_class: str, + credential_source: str, + source_kind: str, +) -> object: + source.source_attribution = SourceAttribution( + credential_source=credential_source, + source_kind=source_kind, + ) + return _performance_source(source, source_class) + + def _quota_source( source: object, source_id: str, @@ -142,19 +152,17 @@ def minimax(config: MiniMaxConfig) -> ProviderBinding: ) def openai(config: OpenAIOrganizationConfig) -> ProviderBinding: - importer = _performance_source( + importer = _attributed_source( OpenAIOrganizationImporter(config, keychain, openai_client, clock), "network", + "openai_admin_api", + "official_api", ) return ProviderBinding( provider_id=config.provider_id, family_id="openai", descriptor=_descriptor( config.provider_id, "openai", config.name, "api" ), - quota_sources=(_quota_source( - OpenAIOrganizationCardAdapter(config, keychain, clock), - "openai.organization", 20 - ),), usage_sources=(importer,), cost_sources=(importer,), ) @@ -176,40 +184,36 @@ def moonshot(config: MoonshotConfig) -> ProviderBinding: ) def daily_feed(config: DailyUsageFeedConfig) -> ProviderBinding: - importer = _performance_source( + importer = _attributed_source( DailyUsageFeedImporter( config, keychain, daily_feed_client, clock ), "network", + "api_key", + "generic_https", ) return ProviderBinding( provider_id=config.provider_id, family_id=config.family_id, descriptor=_descriptor( config.provider_id, config.family_id, config.name, "api" ), - quota_sources=(_quota_source( - DailyUsageFeedCardAdapter(config, keychain, clock), - "custom.daily", 20 - ),), usage_sources=(importer,), ) def cost_feed(config: DailyCostFeedConfig) -> ProviderBinding: - importer = _performance_source( + importer = _attributed_source( DailyCostFeedImporter( config, keychain, daily_feed_client, clock ), "network", + "api_key", + "generic_https", ) return ProviderBinding( provider_id=config.provider_id, family_id=config.family_id, descriptor=_descriptor( config.provider_id, config.family_id, config.name, "api" ), - quota_sources=(_quota_source( - DailyCostFeedCardAdapter(config, keychain, clock), - "custom.cost", 20 - ),), cost_sources=(importer,), ) diff --git a/openusage_bar/step_plan.py b/openusage_bar/step_plan.py index 48602dc7..f353ae8c 100644 --- a/openusage_bar/step_plan.py +++ b/openusage_bar/step_plan.py @@ -13,7 +13,12 @@ from .keychain import KeychainError, MacOSKeychain from .models import Category, ProviderCard, ProviderStatus from .network import AuthenticationRequired, BoundedHTTPClient, NetworkError, RateLimited -from .providers.contracts import QuotaFetchFailure, QuotaFetchSuccess +from .providers.contracts import ( + QuotaCollectionResult, + QuotaFetchFailure, + QuotaFetchSuccess, + SourceAttribution, +) from .providers.quota import percent_observation @@ -172,6 +177,15 @@ def _compact_credit(value: float) -> str: class StepPlanAdapter: + _OFFICIAL_ATTRIBUTION = SourceAttribution( + credential_source="step_plan_official_api", + source_kind="official_api", + ) + _SESSION_ATTRIBUTION = SourceAttribution( + credential_source="step_plan_browser_session", + source_kind="browser_session", + ) + def __init__( self, config: StepPlanConfig, @@ -471,7 +485,69 @@ def fetch(self) -> ProviderCard: ProviderStatus.ERROR, "Step Plan refresh failed", now ) + def fetch_quota(self) -> QuotaCollectionResult: + attribution = self._OFFICIAL_ATTRIBUTION + try: + token = self.keychain.get( + self.config.provider_id + STEP_PLAN_TOKEN_SUFFIX + ) + if token: + attribution = self._SESSION_ATTRIBUTION + webid = self.keychain.get( + self.config.provider_id + STEP_PLAN_WEBID_SUFFIX + ) + if webid: + attribution = self._SESSION_ATTRIBUTION + if token or webid: + if not token or not webid: + result = QuotaFetchFailure("invalid_response") + else: + _session, payload = self._fetch_session_payload( + StepPlanSession(token=token, webid=webid) + ) + result = self.quota_observations( + self.config, payload, self.clock() + ) + else: + api_key = self.keychain.get(self.config.provider_id) + result = QuotaFetchFailure( + "quota_unavailable" if api_key else "auth_required" + ) + except AuthenticationRequired: + result = QuotaFetchFailure("auth_rejected") + except RateLimited: + result = QuotaFetchFailure("rate_limited") + except KeychainError: + result = QuotaFetchFailure("keychain_unavailable") + except NetworkError: + result = QuotaFetchFailure("network_error") + except (StepPlanParseError, TypeError, ValueError): + result = QuotaFetchFailure("invalid_response") + return QuotaCollectionResult( + result=result, + attribution=attribution, + ) + def _fetch_session(self, session: StepPlanSession, now: datetime) -> ProviderCard: + session, quota_payload = self._fetch_session_payload(session) + + status_payload: dict[str, Any] | None = None + try: + status_payload = self.client.post_json( + self.endpoints.status, + self._session_headers(session), + {}, + ) + except NetworkError: + pass + self.last_quota_result = self.quota_observations( + self.config, quota_payload, now + ) + return self.parse_quota(self.config, quota_payload, status_payload, now) + + def _fetch_session_payload( + self, session: StepPlanSession + ) -> tuple[StepPlanSession, dict[str, Any]]: try: quota_payload = self.client.post_json( self.endpoints.quota, @@ -489,20 +565,9 @@ def _fetch_session(self, session: StepPlanSession, now: datetime) -> ProviderCar self._session_headers(session), {}, ) - - status_payload: dict[str, Any] | None = None - try: - status_payload = self.client.post_json( - self.endpoints.status, - self._session_headers(session), - {}, - ) - except NetworkError: - pass - self.last_quota_result = self.quota_observations( - self.config, quota_payload, now - ) - return self.parse_quota(self.config, quota_payload, status_payload, now) + if not isinstance(quota_payload, dict): + raise StepPlanParseError("StepFun quota response is invalid") + return session, quota_payload def _refresh_session(self, session: StepPlanSession) -> StepPlanSession: payload = self.client.post_json( diff --git a/tests/test_adapter_registry.py b/tests/test_adapter_registry.py index af78ef91..2cfb67bf 100644 --- a/tests/test_adapter_registry.py +++ b/tests/test_adapter_registry.py @@ -17,17 +17,14 @@ OpenAIOrganizationConfig, StepPlanConfig, ) -from openusage_bar.cost_feed import DailyCostFeedCardAdapter, DailyCostFeedImporter -from openusage_bar.daily_feed import DailyUsageFeedCardAdapter, DailyUsageFeedImporter +from openusage_bar.cost_feed import DailyCostFeedImporter +from openusage_bar.daily_feed import DailyUsageFeedImporter from openusage_bar.daily_history import OpenUsageDailyImporter from openusage_bar.generic import GenericHTTPSAdapter from openusage_bar.kiro import KiroQuotaAdapter from openusage_bar.minimax import MiniMaxBillingImporter, MiniMaxCodingPlanAdapter from openusage_bar.moonshot import MoonshotBalanceAdapter -from openusage_bar.openai_organization import ( - OpenAIOrganizationCardAdapter, - OpenAIOrganizationImporter, -) +from openusage_bar.openai_organization import OpenAIOrganizationImporter from openusage_bar.openusage_adapter import OpenUsageAdapter from openusage_bar.performance_timing import RefreshTimingRecorder from openusage_bar.providers.builtins import default_registry @@ -176,18 +173,18 @@ def test_current_configs_build_the_existing_adapter_and_importer_graph(self): "codex": ( (CodexSubscriptionAdapter,), (CodexLocalDailyImporter,), (), ), - "cost-work": ((DailyCostFeedCardAdapter,), (), (DailyCostFeedImporter,)), + "cost-work": ((), (), (DailyCostFeedImporter,)), "minimax-work": ( (MiniMaxCodingPlanAdapter,), (MiniMaxBillingImporter,), (), ), "moonshot-work": ((), (), ()), "openai": ( - (OpenAIOrganizationCardAdapter,), + (), (OpenAIOrganizationImporter,), (OpenAIOrganizationImporter,), ), "glm-work": ( - (DailyUsageFeedCardAdapter,), (DailyUsageFeedImporter,), (), + (), (DailyUsageFeedImporter,), (), ), "step-work": ((StepPlanAdapter,), (), ()), "generic-work": ((GenericHTTPSAdapter,), (), ()), @@ -305,8 +302,14 @@ def test_openusage_base_precedes_direct_quota_overrides(self): refresher = build_headless_refresher(Mock()) runtime_types = [type(adapter) for adapter in refresher.aggregator.adapters] self.assertIs(runtime_types[0], OpenUsageAdapter) - self.assertGreater(runtime_types.index(CodexSubscriptionAdapter), 0) - self.assertGreater(runtime_types.index(KiroQuotaAdapter), 0) + self.assertNotIn(CodexSubscriptionAdapter, runtime_types) + self.assertNotIn(KiroQuotaAdapter, runtime_types) + direct_types = [ + type(adapter) for _descriptor, _source_id, adapter + in refresher.quota_sources + ] + self.assertIn(CodexSubscriptionAdapter, direct_types) + self.assertIn(KiroQuotaAdapter, direct_types) def test_builtin_sources_declare_privacy_safe_performance_classes(self): bindings = { diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py index f65ff985..a5981fae 100644 --- a/tests/test_aggregator.py +++ b/tests/test_aggregator.py @@ -12,9 +12,16 @@ from openusage_bar.aggregator import ( Aggregator, BoundedReadOnlyKeychain, CardCache, LedgerRefresher, merge_cards, ) +from openusage_bar.activity_records import ProviderInstance from openusage_bar.daily_history import ActivityCollector, DailyImportResult from openusage_bar.models import Category, Overview, ProviderCard, ProviderStatus from openusage_bar.performance_timing import RefreshTimingRecorder +from openusage_bar.providers.contracts import ( + ProviderDescriptor, + QuotaCollectionResult, + QuotaFetchFailure, + SourceAttribution, +) NOW = datetime(2026, 7, 14, tzinfo=timezone.utc) @@ -172,25 +179,85 @@ def test_ledger_refresher_forwards_explicit_quota_results(self): aggregator.refresh.return_value = overview collector = Mock() adapter = Mock() - adapter.last_quota_result = object() + adapter.performance_source_class = "local_file" + adapter.fetch.side_effect = AssertionError("card path must not run") + failure = QuotaFetchFailure("quota_unavailable") + adapter.fetch_quota.return_value = QuotaCollectionResult( + result=failure, + attribution=SourceAttribution( + credential_source="codex_local_log", + source_kind="local_log", + ), + ) + descriptor = ProviderDescriptor( + provider_id="codex", + family_id="codex", + display_name="Codex", + category="subscription", + ) LedgerRefresher( aggregator, collector, - (("codex", "codex.local_rate_limits", adapter),), + ((descriptor, "codex.local_rate_limits", adapter),), + provider_descriptors=(descriptor,), + clock=lambda: NOW, ).refresh() + adapter.fetch_quota.assert_called_once_with() + adapter.fetch.assert_not_called() collector.refresh.assert_called_once_with( overview, + provider_instances=(ProviderInstance( + provider_id="codex", + family_id="codex", + display_name="Codex", + category="subscription", + credential_source="codex_local_log", + source_kind="local_log", + observed_at="2026-07-14T00:00:00.000000Z", + ),), + provider_families={"codex": "codex"}, balance_results=(), quota_results=(( - "codex", "codex.local_rate_limits", adapter.last_quota_result, + "codex", "codex.local_rate_limits", failure, ),), ) + def test_usage_only_provider_identity_does_not_require_a_card(self): + overview = Overview([]) + aggregator = Mock() + aggregator.refresh.return_value = overview + collector = Mock() + descriptor = ProviderDescriptor( + provider_id="openai", + family_id="openai", + display_name="OpenAI Organization", + category="api", + ) + attribution = SourceAttribution( + credential_source="openai_admin_api", + source_kind="official_api", + ) + + LedgerRefresher( + aggregator, + collector, + provider_descriptors=(descriptor,), + provider_attributions=((descriptor, attribution),), + clock=lambda: NOW, + ).refresh() + + collector.refresh.assert_called_once_with( + overview, + provider_instances=(descriptor.observed(NOW, attribution),), + provider_families={"openai": "openai"}, + balance_results=(), + quota_results=(), + ) + def test_daily_feed_uses_shared_bounded_keychain_and_no_redirect_client(self): from openusage_bar.aggregator import build_headless_refresher from openusage_bar.config import DailyUsageFeedConfig - from openusage_bar.daily_feed import DailyUsageFeedCardAdapter from openusage_bar.keychain import BoundedMacOSKeychain configured = DailyUsageFeedConfig( @@ -206,15 +273,15 @@ def test_daily_feed_uses_shared_bounded_keychain_and_no_redirect_client(self): ): refresher = build_headless_refresher(Mock()) - card_adapter = next( - adapter - for adapter in refresher.aggregator.adapters - if isinstance(adapter, DailyUsageFeedCardAdapter) - ) importer = refresher.collector.official_importers["glm-work"] - self.assertIs(importer.keychain, card_adapter.keychain) self.assertIsInstance(importer.keychain, BoundedMacOSKeychain) self.assertEqual(importer.client.allowed_redirect_hosts, frozenset()) + attribution = dict( + (descriptor.provider_id, source) + for descriptor, source in refresher.provider_attributions + )["glm-work"] + self.assertEqual(attribution.credential_source, "api_key") + self.assertEqual(attribution.source_kind, "generic_https") def test_codex_local_sessions_are_primary_for_eager_collection(self): from openusage_bar.aggregator import build_headless_refresher @@ -228,6 +295,17 @@ def test_codex_local_sessions_are_primary_for_eager_collection(self): self.assertEqual(importer.usage_source_id, "codex.local_sessions") self.assertEqual(refresher.eager_usage_provider_ids, ("codex",)) + def test_openusage_meta_source_is_not_treated_as_a_provider(self): + from openusage_bar.aggregator import build_headless_refresher + + with patch("openusage_bar.config.ProviderConfigStore.load", return_value=[]): + refresher = build_headless_refresher(Mock()) + + self.assertNotIn( + "openusage", + {item.provider_id for item in refresher.provider_descriptors}, + ) + def test_minimax_reuses_keychain_and_client_for_quota_and_daily_tokens(self): from openusage_bar.aggregator import build_headless_refresher from openusage_bar.config import MiniMaxConfig @@ -242,22 +320,21 @@ def test_minimax_reuses_keychain_and_client_for_quota_and_daily_tokens(self): ): refresher = build_headless_refresher(Mock()) - card_adapter = next( + quota_adapter = next( adapter - for adapter in refresher.aggregator.adapters + for _descriptor, _source_id, adapter in refresher.quota_sources if isinstance(adapter, MiniMaxCodingPlanAdapter) ) importer = refresher.collector.official_importers["minimax-main"] self.assertIsInstance(importer, MiniMaxBillingImporter) - self.assertIs(importer.keychain, card_adapter.keychain) - self.assertIs(importer.client, card_adapter.client) + self.assertIs(importer.keychain, quota_adapter.keychain) + self.assertIs(importer.client, quota_adapter.client) self.assertEqual(importer.client.allowed_redirect_hosts, frozenset()) def test_openai_organization_uses_bounded_keychain_and_no_redirect_client(self): from openusage_bar.aggregator import build_headless_refresher from openusage_bar.config import OpenAIOrganizationConfig from openusage_bar.keychain import BoundedMacOSKeychain - from openusage_bar.openai_organization import OpenAIOrganizationCardAdapter with patch( "openusage_bar.config.ProviderConfigStore.load", @@ -265,15 +342,17 @@ def test_openai_organization_uses_bounded_keychain_and_no_redirect_client(self): ): refresher = build_headless_refresher(Mock()) - card_adapter = next( - adapter - for adapter in refresher.aggregator.adapters - if isinstance(adapter, OpenAIOrganizationCardAdapter) - ) importer = refresher.collector.official_importers["openai"] - self.assertIsInstance(card_adapter.keychain, BoundedMacOSKeychain) - self.assertIs(importer.keychain, card_adapter.keychain) + self.assertIsInstance(importer.keychain, BoundedMacOSKeychain) self.assertEqual(importer.client.allowed_redirect_hosts, frozenset()) + attribution = dict( + (descriptor.provider_id, source) + for descriptor, source in refresher.provider_attributions + )["openai"] + self.assertEqual( + attribution.credential_source, "openai_admin_api" + ) + self.assertEqual(attribution.source_kind, "official_api") def test_headless_sources_share_bounded_read_write_keychain(self): from openusage_bar.aggregator import build_headless_refresher @@ -289,7 +368,7 @@ def test_headless_sources_share_bounded_read_write_keychain(self): step_plan = next( adapter - for adapter in refresher.aggregator.adapters + for _descriptor, _source_id, adapter in refresher.quota_sources if isinstance(adapter, StepPlanAdapter) ) self.assertIsInstance(step_plan.keychain, BoundedMacOSKeychain) diff --git a/tests/test_codex_subscription.py b/tests/test_codex_subscription.py index c9de89f1..a0d10662 100644 --- a/tests/test_codex_subscription.py +++ b/tests/test_codex_subscription.py @@ -10,7 +10,10 @@ parse_rate_limit_card, parse_rate_limit_observations, ) -from openusage_bar.providers.contracts import QuotaFetchSuccess +from openusage_bar.providers.contracts import ( + QuotaCollectionResult, + QuotaFetchSuccess, +) from openusage_bar.models import Category, ProviderStatus @@ -181,6 +184,39 @@ def test_adapter_returns_no_override_without_current_quota(self): self.assertEqual(overview.cards, []) + def test_adapter_collects_quota_fact_with_local_log_attribution(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "session.jsonl").write_text( + json.dumps( + { + "timestamp": "2026-07-14T00:30:00Z", + "payload": { + "type": "token_count", + "rate_limits": rate_limits( + window(25, 300, NOW + timedelta(hours=2)) + ), + }, + } + ) + + "\n", + encoding="utf-8", + ) + adapter = CodexSubscriptionAdapter(root, clock=lambda: NOW) + + collection = adapter.fetch_quota() + + self.assertIsInstance(collection, QuotaCollectionResult) + self.assertIsInstance(collection.result, QuotaFetchSuccess) + self.assertEqual( + collection.attribution.credential_source, "codex_local_log" + ) + self.assertEqual(collection.attribution.source_kind, "local_log") + self.assertEqual( + collection.result.observations[0].source_id, + "codex.local_rate_limits", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_generic.py b/tests/test_generic.py index 59225573..4141df4c 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -8,9 +8,13 @@ from openusage_bar.config import GenericProviderConfig from openusage_bar.daily_history import ActivityCollector, DailyImportResult from openusage_bar.generic import GenericHTTPSAdapter, MissingField, extract_path +from openusage_bar.keychain import KeychainError from openusage_bar.models import Overview, ProviderStatus from openusage_bar.network import NetworkError -from openusage_bar.providers.contracts import QuotaFetchSuccess +from openusage_bar.providers.contracts import ( + QuotaCollectionResult, + QuotaFetchSuccess, +) NOW = datetime(2026, 7, 14, tzinfo=timezone.utc) @@ -105,6 +109,41 @@ def test_success_card_publishes_sanitized_generic_identity(self): self.assertNotIn(private, repr(card)) self.assertNotIn(private, ledger) + def test_collects_quota_once_without_building_identity_from_card(self): + keychain = Mock() + keychain.get.return_value = "bounded-key" + client = Mock() + client.get_json.return_value = { + "data": { + "remaining": 73, + "percent": 73, + "reset_at": "2026-07-15T00:00:00Z", + "plan": "Pro", + } + } + adapter = GenericHTTPSAdapter( + config(), keychain, client, lambda: NOW + ) + + collection = adapter.fetch_quota() + + self.assertIsInstance(collection, QuotaCollectionResult) + self.assertIsInstance(collection.result, QuotaFetchSuccess) + self.assertEqual(client.get_json.call_count, 1) + self.assertEqual(collection.attribution.credential_source, "api_key") + self.assertEqual(collection.attribution.source_kind, "generic_https") + + def test_direct_quota_sanitizes_keychain_failures(self): + keychain = Mock() + keychain.get.side_effect = KeychainError("private system detail") + client = Mock() + adapter = GenericHTTPSAdapter(config(), keychain, client, lambda: NOW) + + collection = adapter.fetch_quota() + + self.assertEqual(collection.result.error_code, "keychain_unavailable") + client.get_json.assert_not_called() + def test_error_card_still_publishes_identity_without_error_secrets(self): endpoint = "https://api.example.com/private-error" api_key = "sk-sanitized-private-error-key" diff --git a/tests/test_kiro.py b/tests/test_kiro.py index 4f678f92..88dd8e54 100644 --- a/tests/test_kiro.py +++ b/tests/test_kiro.py @@ -20,7 +20,10 @@ parse_kiro_quota, parse_kiro_quota_observations, ) -from openusage_bar.providers.contracts import QuotaFetchSuccess +from openusage_bar.providers.contracts import ( + QuotaCollectionResult, + QuotaFetchSuccess, +) NOW = datetime(2026, 7, 14, tzinfo=timezone.utc) @@ -319,6 +322,20 @@ def test_fetch_builds_fixed_aws_request_and_returns_quota(self): self.assertEqual(headers["Authorization"], f"Bearer {SECRET}") self.assertEqual(headers["User-Agent"], "KiroIDE") + def test_collects_quota_fact_once_with_official_api_attribution(self): + adapter, client = self.adapter() + + collection = adapter.fetch_quota() + + self.assertIsInstance(collection, QuotaCollectionResult) + self.assertIsInstance(collection.result, QuotaFetchSuccess) + self.assertEqual(len(client.calls), 1) + self.assertEqual( + collection.attribution.credential_source, + "kiro_codewhisperer_api", + ) + self.assertEqual(collection.attribution.source_kind, "official_api") + def test_request_id_uses_packaged_os_randomness_without_uuid_dependency(self): credentials = parse_kiro_credentials(credential_json()) with patch( diff --git a/tests/test_minimax.py b/tests/test_minimax.py index 61e2ffae..47af1fc3 100644 --- a/tests/test_minimax.py +++ b/tests/test_minimax.py @@ -4,6 +4,7 @@ from urllib.parse import parse_qs, urlsplit from openusage_bar.config import MiniMaxConfig +from openusage_bar.keychain import KeychainError from openusage_bar.minimax import ( MINIMAX_BILLING_SOURCE_ID, MiniMaxBillingImporter, @@ -14,7 +15,10 @@ ) from openusage_bar.models import ProviderStatus from openusage_bar.openai_organization import ImportFailure, UsageImportSuccess -from openusage_bar.providers.contracts import QuotaFetchSuccess +from openusage_bar.providers.contracts import ( + QuotaCollectionResult, + QuotaFetchSuccess, +) NOW = datetime(2026, 7, 14, tzinfo=timezone.utc) @@ -293,6 +297,46 @@ def test_fetch_uses_official_token_plan_endpoint(self): }, ) + def test_collects_quota_once_with_builtin_api_attribution(self): + keychain = Mock() + keychain.get.return_value = "subscription-key" + client = Mock() + client.get_json.return_value = { + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 75, + } + ], + "base_resp": {"status_code": 0}, + } + adapter = MiniMaxCodingPlanAdapter( + MiniMaxConfig("m", "MiniMax"), keychain, client, lambda: NOW + ) + + collection = adapter.fetch_quota() + + self.assertIsInstance(collection, QuotaCollectionResult) + self.assertIsInstance(collection.result, QuotaFetchSuccess) + self.assertEqual(client.get_json.call_count, 1) + self.assertEqual( + collection.attribution.credential_source, "minimax_builtin_api" + ) + self.assertEqual(collection.attribution.source_kind, "builtin_api") + + def test_direct_quota_sanitizes_keychain_failures(self): + keychain = Mock() + keychain.get.side_effect = KeychainError("private system detail") + client = Mock() + adapter = MiniMaxCodingPlanAdapter( + MiniMaxConfig("m", "MiniMax"), keychain, client, lambda: NOW + ) + + collection = adapter.fetch_quota() + + self.assertEqual(collection.result.error_code, "keychain_unavailable") + client.get_json.assert_not_called() + def test_fetch_uses_international_token_plan_endpoint_without_cross_retry(self): keychain = Mock() keychain.get.return_value = "subscription-key" diff --git a/tests/test_moonshot.py b/tests/test_moonshot.py index bdcc649c..a0aefa16 100644 --- a/tests/test_moonshot.py +++ b/tests/test_moonshot.py @@ -2,12 +2,18 @@ import unittest from datetime import datetime, timezone +from unittest.mock import Mock from openusage_bar.activity_records import BalanceObservation from openusage_bar.config import MoonshotConfig +from openusage_bar.keychain import KeychainError from openusage_bar.models import Category, ProviderStatus from openusage_bar.moonshot import MoonshotBalanceAdapter, endpoint_for_site -from openusage_bar.providers.contracts import BalanceFetchFailure, BalanceFetchSuccess +from openusage_bar.providers.contracts import ( + BalanceCollectionResult, + BalanceFetchFailure, + BalanceFetchSuccess, +) NOW = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc) @@ -119,6 +125,36 @@ def test_fetch_publishes_api_balance_without_capacity_percentage(self): ), ) + def test_collects_balance_once_with_official_api_attribution(self): + adapter, _keychain, client = self.adapter(site="china") + + collection = adapter.fetch_balance() + + self.assertIsInstance(collection, BalanceCollectionResult) + self.assertIsInstance(collection.result, BalanceFetchSuccess) + self.assertEqual(len(client.requests), 1) + self.assertEqual( + collection.attribution.credential_source, + "moonshot_official_api", + ) + self.assertEqual(collection.attribution.source_kind, "official_api") + + def test_direct_balance_sanitizes_keychain_failures(self): + keychain = FakeKeychain() + keychain.get = Mock(side_effect=KeychainError("private system detail")) + client = FakeClient({}) + adapter = MoonshotBalanceAdapter( + MoonshotConfig("moonshot-china", "Moonshot", site="china"), + keychain, + client, + lambda: NOW, + ) + + collection = adapter.fetch_balance() + + self.assertEqual(collection.result.error_code, "keychain_unavailable") + self.assertEqual(client.requests, []) + def test_international_site_uses_usd(self): adapter, _, _ = self.adapter(site="international") diff --git a/tests/test_step_plan.py b/tests/test_step_plan.py index 84f9ee76..82ffdac3 100644 --- a/tests/test_step_plan.py +++ b/tests/test_step_plan.py @@ -7,7 +7,10 @@ from openusage_bar.config import StepPlanConfig from openusage_bar.keychain import KeychainError from openusage_bar.models import Category, ProviderStatus -from openusage_bar.providers.contracts import QuotaFetchSuccess +from openusage_bar.providers.contracts import ( + QuotaCollectionResult, + QuotaFetchSuccess, +) from openusage_bar.step_plan import ( STEP_PLAN_MODELS_ENDPOINT, STEP_PLAN_RATE_LIMIT_ENDPOINT, @@ -195,6 +198,41 @@ def post_json(endpoint, headers, body): self.assertEqual(card.source_kind, "browser_session") client.get_json.assert_not_called() + def test_collects_session_quota_without_presentation_status_request(self): + keychain = Mock() + keychain.get.side_effect = lambda account: { + "step-plan-main" + STEP_PLAN_TOKEN_SUFFIX: "access...refresh", + "step-plan-main" + STEP_PLAN_WEBID_SUFFIX: "web-id", + }.get(account) + client = Mock() + client.post_json.return_value = { + "status": 1, + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": "0", + "weekly_usage_reset_time": "0", + } + adapter = StepPlanAdapter( + StepPlanConfig("step-plan-main", "Step Plan"), + keychain, + client, + lambda: NOW, + ) + + collection = adapter.fetch_quota() + + self.assertIsInstance(collection, QuotaCollectionResult) + self.assertIsInstance(collection.result, QuotaFetchSuccess) + client.post_json.assert_called_once() + self.assertEqual( + client.post_json.call_args.args[0], STEP_PLAN_RATE_LIMIT_ENDPOINT + ) + self.assertEqual( + collection.attribution.credential_source, + "step_plan_browser_session", + ) + self.assertEqual(collection.attribution.source_kind, "browser_session") + def test_live_zero_reset_timestamps_do_not_hide_valid_quota(self): card = StepPlanAdapter.parse_quota( StepPlanConfig("step-plan-main", "Step Plan"), From 44207219bd61e731ff8c7804714046abac356fbd Mon Sep 17 00:00:00 2001 From: tttboy123 <3383341447@qq.com> Date: Sat, 1 Aug 2026 19:04:56 +0800 Subject: [PATCH 3/3] docs: record WQ-19A verification --- ROADMAP.md | 2 +- .../plans/2026-07-18-openusage-work-queue.zh-CN.md | 6 +++++- .../plans/2026-08-01-provider-card-retirement.md | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 19f6be52..0b6e9576 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ accessibility, and external-machine evidence therefore remains pending. | 0.4.x Hardening | Align public release metadata, freeze Token accounting semantics, add daily reconciliation, and prove installation plus unattended refresh. | **Released.** Repository, unattended refresh, restart, upgrade and rollback evidence is recorded; remaining Provider truth work moved to 0.5. | Build, package, release smoke and visible local recovery evidence pass without weakening Unknown semantics. | | 0.5 Data Trust | Audit declared Provider capabilities against authoritative sources and real accounts, then publish a reusable Provider Adapter Kit. | **Implemented, live evidence pending.** The kit and conformance fixtures are released; open real-account gates remain tracked per Provider. | First-wave Providers have redacted live evidence; second-wave Providers have an authoritative source or an explicit unsupported result; UI, CLI, and API agree at one `dataRevision`; an external contributor can use the adapter kit. | | 0.6 RC | Stabilize Local API v1 compatibility, run a public beta, and enforce measured performance budgets. | **Current public pre-release.** v0.6.0 and Local API v1 are published; external qualification is 0 / 5 and the 30-day clock is not started. | N-1 API compatibility passes; external participants verify install, upgrade, rollback and diagnostics; performance meets the recorded baseline; the product remains fully usable without Loom. | -| 0.7 Infrastructure Boundary | Separate the OS-neutral fact contract from the macOS distribution, retire Card-first core paths, and freeze producer interoperability. | **In progress.** ADR 0001, the OS-neutral contract and machine-readable release state passed PR #53 required CI; merge is pending. Card-first retirement, `openusage-export/v1` and the separate Runtime Observation proposal remain open. | Core adapters emit facts before presentation; the macOS invariant is distribution-only; `openusage-export/v1` has fixtures; any request telemetry uses separate bounded storage. | +| 0.7 Infrastructure Boundary | Separate the OS-neutral fact contract from the macOS distribution, retire Card-first core paths, and freeze producer interoperability. | **In progress.** PR #53 merged ADR 0001, the OS-neutral contract and machine-readable release state. WQ-19A direct Provider fact collection has passed the complete local release gates; WQ-19B, `openusage-export/v1` and the separate Runtime Observation proposal remain open. | Core adapters emit facts before presentation; the macOS invariant is distribution-only; `openusage-export/v1` has fixtures; any request telemetry uses separate bounded storage. | | 1.0 Stable | Complete an external, opt-in, no-telemetry canary and publish an auditable stable release. | **Planned.** Release tooling is implemented and CI verified; the live canary has not run. | At least five external Apple Silicon Macs and five Provider configurations complete 30 days without a blocking incident; each completes N-1 upgrade and rollback; release checksum, manifest, SPDX SBOM, provenance, attestation, dependency, privacy, and data-integrity gates pass. | Detailed task order and evidence requirements live in the diff --git a/docs/superpowers/plans/2026-07-18-openusage-work-queue.zh-CN.md b/docs/superpowers/plans/2026-07-18-openusage-work-queue.zh-CN.md index 9a6297ef..c0eea0c8 100644 --- a/docs/superpowers/plans/2026-07-18-openusage-work-queue.zh-CN.md +++ b/docs/superpowers/plans/2026-07-18-openusage-work-queue.zh-CN.md @@ -884,11 +884,15 @@ release smoke。公开 intake 已打开,但外部机器仍为 0 / 5,30 天 - **WQ-18:基础设施边界与发布状态。** 固化 Fact、Telemetry、Reservation、 Policy 四层写权限;Core Contract 改为 OS-neutral;OpenUsage Bar 分发继续 单独要求 macOS;以严格 JSON 统一版本、API 与 Canary 状态。 - **仓库实现、本地完整门禁与 PR #53 required CI 已完成;当前等待合入。** + **仓库实现、本地完整门禁与 PR #53 required CI 已完成并已合入 `main`。** - **WQ-19:移除 Card-first 核心遗留。** 逐个 Adapter 由 `LegacyCardAdapter` 迁移为 fact-specific result,`ProviderCard` 只留在 Presentation;每个 Provider 使用独立 RED → GREEN 切片。精确迁移顺序见 [`2026-08-01-provider-card-retirement.md`](2026-08-01-provider-card-retirement.md)。 + **WQ-19A 已完成本地实现和完整发行门禁:Codex、Kiro、MiniMax、Step Plan、 + Generic HTTPS 与 Moonshot 直接返回带来源归属的额度/余额事实,headless + Collector 不再读取 `last_*` 卡片旁路;OpenAI 与自定义 Feed 不再注册伪额度 + 卡片。WQ-19B 仍须等待 WQ-20 冻结 `openusage-export/v1`。** - **WQ-20:冻结 `openusage-export/v1`。** 固定 producer 版本、Provider filter、Token 口径、Coverage、空结果、范围/分页与能力协商,并提供 N-1 Fixture;OpenUsage Bar 不依赖未声明的开发 Commit 行为。 diff --git a/docs/superpowers/plans/2026-08-01-provider-card-retirement.md b/docs/superpowers/plans/2026-08-01-provider-card-retirement.md index 4103a979..edf59af0 100644 --- a/docs/superpowers/plans/2026-08-01-provider-card-retirement.md +++ b/docs/superpowers/plans/2026-08-01-provider-card-retirement.md @@ -428,7 +428,12 @@ Expected: all tests pass; Local API v1 and generated Swift fixtures are unchange ```bash scripts/audit_dependencies.sh .build-venv/bin/python scripts/release_secret_scan.py -.build-venv/bin/python scripts/privacy_scan.py openusage_bar tests +.build-venv/bin/python scripts/privacy_scan.py \ + openusage_bar/resources/release-state.v1.json \ + openusage_bar/resources/provider-catalog.v1.json \ + openusage_bar/resources/local-api-v1.schema.json \ + swift_app/Sources/UsageCore/GeneratedProviderCatalog.swift \ + swift_app/Sources/UsageCore/GeneratedActivitySchema.swift .build-venv/bin/python scripts/verify_release_metadata.py scripts/build_app.sh ```