Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 行为。
Expand Down
50 changes: 34 additions & 16 deletions docs/superpowers/plans/2026-08-01-provider-card-retirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -41,8 +41,6 @@ ProviderDescriptor(
family_id="minimax",
display_name="MiniMax Primary",
category="subscription",
credential_source="minimax_builtin_api",
source_kind="builtin_api",
)
```

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -415,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
```
Expand Down
134 changes: 119 additions & 15 deletions openusage_bar/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"} | {
Expand All @@ -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,
)
Loading