feat(opencode): switch usage query to official zen usage API - #24
feat(opencode): switch usage query to official zen usage API#24linletian wants to merge 3 commits into
Conversation
The debug-only validateFormatContract assert trapped deterministically: idRegex requires the wrk_ ID to be followed by '/' (lookahead), but knownGoodSample had no trailing slash, so firstMatch was always nil. Any debug call into resolveWorkspaceID() — including the XCTest host app at launch — crashed before tests could run. Pre-existing since 473f4c8; surfaced now when running the suite.
OpenCode shipped GET https://opencode.ai/zen/go/v1/usage (PR #16513, commits 2b8a5969e9 + d470434746). The endpoint reports the three plan windows (rolling 5h / weekly / monthly) as used-percent + absolute resetsAt, metered server-side and counting plan (lite) usage only — consistent across devices and free of the local-SQLite path's two defects (per-device data, plan/balance mixing). - OpenCodeSupplier: NetworkClient + Bearer Zen API key; empty key fails fast with a configure-in-Settings error - OpenCodeResponseParser: parse the new shape; rawData keeps the existing key contract (percent + <dim>:end_time), drops :used/:limit - RefreshService/UsageCardView/MetricSnapshot: OpenCode joins the generic percent-only display; remove the now-dead dollar display and overageUSD plumbing - InstanceEditorView: OpenCode gets a regular API key field; all OpenCode instances keep sharing one keychain entry so each refresh cycle issues a single request - Delete the retired local path: SQL templates, window algorithms, OpenCodeGoLimits, Shell/ module and its tests - Docs synced per docs-first rule (provider interface, ARCHITECTURE, PRD, READMEs, AGENTS.md, kimi investigation)
linletian
left a comment
There was a problem hiding this comment.
Code Review — PR #24
Overall: clean cutover from the local SQLite path to the official Zen HTTP API. The Shell/ module and the overageUSD plumbing are fully removed, docs are synced across the stack, and the new parser is well covered (real fixture, rate-limited, clamping, malformed input, makeResponse shape, format-contract fix).
d0495c0 (the format-contract fix) is correct — adding the trailing / to knownGoodSample matches idRegex's lookahead, and the new comment on the constant explains why.
Five findings, by priority:
1. rate-limited → 100 is documented but not enforced — medium
APIUsageStatus/Suppliers/OpenCodeResponseParser.swift:9 claims the parser reports the value "always 100 when status is rate-limited", but the implementation (OpenCodeResponseParser.swift:52-62) only reads dict["percent"] and never inspects status. The status field doesn't even appear on ParsedWindow. If the server ever emits {"status":"rate-limited","percent":42} (schema drift, partial outage), the parser silently uses 42 — the menu bar would render 42% on a rate-limited window, and the rate-limited status would be invisible downstream.
testParseRateLimitedWindow only exercises percent:100 + status:rate-limited (the dual-100 happy path); it does not cover a status=rate-limited + percent≠100 mismatch.
Fix options (pick one):
- Tighten
parseWindow: whenstatus == "rate-limited", forcepercent = 100regardless of the reported value, and add a negative test (e.g.status=rate-limited, percent=42→percent == 100). - Soften the doc to "as reported by the server" and pipe
statusthroughParsedWindowso a future "rate-limited" badge or glow effect can consume it.
2. ISO8601 parser lacks a fallback — low
APIUsageStatus/Suppliers/OpenCodeResponseParser.swift:65-69 uses withFractionalSeconds exclusively. The contract is "milliseconds always present" and the fixture + docs/provider-interfaces/opencode_go.md §2.2 confirm it, but if the server ever returns a bare 2026-09-07T00:00:00Z (no fraction), the whole cycle throws and all three OpenCode instances break at once. resetsAt is metadata, not the core signal — a one-line retry with a non-fractional ISO8601DateFormatter is cheap insurance.
3. do/catch in OpenCodeSupplier.fetchUsage is just log + rethrow — nit
APIUsageStatus/Suppliers/OpenCodeSupplier.swift:37-47:
let parsed: OpenCodeResponseParser.Parsed
do {
parsed = try parser.parse(response)
} catch {
logger.osLogger.error(...)
throw error
}The catch only logs and rethrows — no recovery, no wrapping. Either inline the log call on the throw site, or move the diagnostic log into OpenCodeResponseParser itself so every call site benefits. Minor.
4. openCodePlaceholderRef uses a local:// URL-like prefix — nit
APIUsageStatus/Services/KeychainService.swift:158:
static let openCodePlaceholderRef = "local://opencode-go"The value is just a kSecAttrAccount string — nothing downstream parses it as a URL. The local:// scheme reads like a URL and may mislead future readers into looking for URL handling. A plain opaque constant like "opencode-go-shared" is clearer about its role.
5. URL(string:)! hardcoded in the supplier — nit
APIUsageStatus/Suppliers/OpenCodeSupplier.swift:31 hardcodes URL(string: "https://opencode.ai/zen/go/v1/usage")!. Force-unwrap is safe for a compile-time constant, but no other supplier in the codebase uses this pattern. Extract to a private static let usageURL = URL(string: "...")! at the top of the file for stylistic consistency and easier test injection.
Verified OK (no action needed)
- All
overageUSD/:used/:limitreferences cleared from source and tests (grepover the tree returns no business matches). Shell/module fully removed fromproject.pbxproj(build files, file refs, group folder, andShellProcessRunnerTests.swiftall gone).- Three OpenCode instances share
KeychainService.openCodePlaceholderRef;RefreshServicegroups byapiKeyRefand de-dupes to a single fetch per cycle. Empty key is rejected up front with a clear "configure in Settings" error. - rawData key contract (
5h/weekly/monthlypercent +<dim>:end_timems) aligns withMetricConfig.keyfor clean 1:N mapping — no upstream changes needed beyond the supplier. Endpoint.exposesFailureBodyInLog = trueis consistent with Kimi; OpenCode's{type, error:{type, message}}error body has no PII, so public logging is safe.- Docs fully synced:
AGENTS.md,README.md,README_zh-CN.md,docs/ARCHITECTURE.md,docs/PRD.md,docs/kimi-api-failures-investigation.md,docs/provider-interfaces/opencode_go.md. OpenCodeWorkspaceResolver.knownGoodSampletrailing-/fix correctly matchesidRegexlookahead; the new comment on the constant is accurate.OpenCodeSupplier.fetchUsageURL handling and parser error messages look good.
…#24 review) - parseWindow forces percent=100 when status is rate-limited instead of trusting the payload — the documented contract is now enforced, so schema drift (rate-limited + percent<100) can't render a partial bar on an exhausted window - resetsAt falls back to second-resolution ISO8601 when milliseconds are absent, so a fraction-less timestamp no longer fails all three windows at once - Tests: rate-limited/percent mismatch, bare-timestamp acceptance
|
Review disposition (analysis first, fixes in #1 rate-limited not enforced — accurate, fixed. #2 ISO8601 no-fraction fallback — accurate, fixed. #3 do/catch is log+rethrow — declined (intentional). The shape mirrors #4 #5 inline |
linletian
left a comment
There was a problem hiding this comment.
Re-Review — PR #24 (after 463de65)
Thanks for the detailed disposition and the targeted fix. All five findings are now closed.
#1 rate-limited enforcement — fixed, verified
OpenCodeResponseParser.swift:65-66 now enforces the contract server-side-of-the-parser:
let isRateLimited = dict["status"] as? String == "rate-limited"
return ParsedWindow(
percent: isRateLimited ? 100 : max(0, min(100, rawPercent)),
endTimeMs: Int64(endDate.timeIntervalSince1970 * 1000)
)The doc comment (lines 8-15) is also rewritten to reflect the new behavior — "the parser forces percent to 100 when status is rate-limited" with a pointer to Subscription.analyze*Usage. testParseRateLimitedForcesHundredOnMismatch exercises the exact schema-drift case (rate-limited + percent:42 → 100). Doc and code now agree.
#2 ISO8601 fallback — fixed, verified
New parseResetsAt helper (lines 68-72) tries the fractional formatter first, then falls back to plain .withInternetDateTime. testParseResetsAtWithoutFractionalSeconds covers a bare 2026-09-02T19:44:30Z and asserts the ms conversion is 1_788_378_270_000 (correct: 2026-09-02T19:44:30.000Z). docs/provider-interfaces/opencode_go.md §2.2 also updated to note the parser tolerates second-resolution timestamps. Order is right (fractional first per contract), static let formatters initialized once.
#3 do/catch log+rethrow — decline accepted
Verified KimiSupplier.swift:23-37 uses the identical pattern, with the same URL+body-preview shape at .public. The argument is correct: inlining would diverge from the established supplier idiom and lose the endpoint-URL context the parser can't supply. Keeping it as-is is the right call.
#4 local:// keychain prefix — decline accepted
The migration-cost argument is sound — this string is the kSecAttrAccount in the user keychain and the persisted apiKeyRef in instances.json, so a rename would silently orphan the shared entry on upgrade (three instances losing their pasted key). The updated doc comment on KeychainService.swift:151-157 now spells out the role clearly, which addresses the "may mislead future readers" concern at the documentation level. Net win at zero migration risk. Good call.
#5 inline URL(string:)! — decline accepted, my finding was wrong
I went back and grep'd the five supplier files — every single one uses the same inline URL(string: "<compile-time constant>")! pattern:
CopilotSupplier.swift:14DeepSeekSupplier.swift:14KimiSupplier.swift:14MiniMaxSupplier.swift:14OpenCodeSupplier.swift:31
So my original "extract to a static let" suggestion would actually have been a deviation from the codebase idiom, not an alignment. Thanks for catching that. Apologies for the noise.
Nothing new introduced by the fix
- The parser still doesn't add
statustoParsedWindow— that was a "future-proofing" suggestion I floated as an alternative, and going with the enforcement route is fine (the doc is the source of truth now). - No new TODOs, no doc/comment drift, no orphaned references.
Approved
LGTM. Once the chain is retargeted to main after #23 merges, this is good to go.
Why
OpenCode shipped an official usage endpoint —
GET https://opencode.ai/zen/go/v1/usage(anomalyco/opencode PR #16513, merged as2b8a5969e9+ response simplified ind470434746). Verified live 2026-09-02 with a real Zen API key (HTTP 200).The server meters usage centrally and only counts plan (lite) consumption (
billingSource === "lite"gate inhandler.ts), which fixes the local-SQLite path's two structural defects:What changed
OpenCodeSupplierrewritten as an HTTP supplier (NetworkClient+ Bearer Zen API key). An empty key fails fast with a configure-in-Settings error instead of firing a doomed 401.OpenCodeResponseParserparses the new shape ({usage: {rolling, weekly, monthly}}, each{status, percent, resetsAt}). The rawData key contract is unchanged (5h/weekly/monthlypercent +<dim>:end_time), so RefreshService/menu-bar/snapshot tests are untouched.:used/:limitkeys are gone — the API reports no dollar amounts.$used / $limitdisplay and the entireoverageUSDplumbing (RefreshService→MetricSnapshot→UsageCardView) are removed.OpenCodeGoLimits, the wholeShell/module and its tests.OpenCodeWorkspaceResolver's/usr/bin/greplog scan (See-details deep link), not usage querying — docs updated accordingly (provider interface doc rewritten, plus ARCHITECTURE / PRD / READMEs / AGENTS / kimi investigation).Also included:
d0495c0fix(opencode): repair workspace resolver format-contract sample — a pre-existing bug on the base branch (since473f4c8):knownGoodSamplelacked the trailing/thatidRegex's lookahead requires, so the debug-only assert trapped deterministically and the XCTest host app crashed at launch before any test ran.Test plan
OpenCodeResponseParserTests— real 2026-09-02 fixture, rate-limited, clamping, malformed-input cases — and updatedRefreshServiceMappingTests)Stacked on #23; retarget to
mainafter the chain merges.