Skip to content

feat(opencode): switch usage query to official zen usage API - #24

Open
linletian wants to merge 3 commits into
fix/issue20-services-tracking-togglefrom
feature/opencode-go-usage-api
Open

feat(opencode): switch usage query to official zen usage API#24
linletian wants to merge 3 commits into
fix/issue20-services-tracking-togglefrom
feature/opencode-go-usage-api

Conversation

@linletian

Copy link
Copy Markdown
Owner

Why

OpenCode shipped an official usage endpoint — GET https://opencode.ai/zen/go/v1/usage (anomalyco/opencode PR #16513, merged as 2b8a5969e9 + response simplified in d470434746). 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 in handler.ts), which fixes the local-SQLite path's two structural defects:

  • Multi-device drift — the local DB only sees the current machine's sessions
  • Plan/balance mixing — balance top-up spending was summed into the Monthly window with no way to tell it apart

What changed

  • OpenCodeSupplier rewritten 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.
  • OpenCodeResponseParser parses the new shape ({usage: {rolling, weekly, monthly}}, each {status, percent, resetsAt}). The rawData key contract is unchanged (5h/weekly/monthly percent + <dim>:end_time), so RefreshService/menu-bar/snapshot tests are untouched. :used/:limit keys are gone — the API reports no dollar amounts.
  • Display: OpenCode joins the generic percent-only branch (like MiniMax/Kimi). The now-dead $used / $limit display and the entire overageUSD plumbing (RefreshServiceMetricSnapshotUsageCardView) are removed.
  • API key: OpenCode instances get a regular key field in the editor (paste a Zen API key from opencode.ai → workspace → API keys). All OpenCode instances keep sharing one keychain entry, so each refresh cycle still issues a single request. On upgrade, the empty shared entry surfaces a clear configure-key error on the card — paste once, all three instances recover.
  • Deleted local path: SQL templates, the three window-reset algorithms, OpenCodeGoLimits, the whole Shell/ module and its tests.
  • Sandbox stays disabled, but the sole remaining reason is now OpenCodeWorkspaceResolver's /usr/bin/grep log scan (See-details deep link), not usage querying — docs updated accordingly (provider interface doc rewritten, plus ARCHITECTURE / PRD / READMEs / AGENTS / kimi investigation).

Also included: d0495c0 fix(opencode): repair workspace resolver format-contract sample — a pre-existing bug on the base branch (since 473f4c8): knownGoodSample lacked the trailing / that idRegex's lookahead requires, so the debug-only assert trapped deterministically and the XCTest host app crashed at launch before any test ran.

Test plan

  • Release build: 0 errors
  • Full suite: 394/394 passed (incl. rewritten OpenCodeResponseParserTests — real 2026-09-02 fixture, rate-limited, clamping, malformed-input cases — and updated RefreshServiceMappingTests)
  • Live API smoke test with real key: rolling 0% / weekly 79% / monthly 55%, matching the web dashboard
  • Manual after deploy: paste Zen API key in Settings (shared across the three OpenCode instances)

Stacked on #23; retarget to main after the chain merges.

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 linletian left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: when status == "rate-limited", force percent = 100 regardless of the reported value, and add a negative test (e.g. status=rate-limited, percent=42percent == 100).
  • Soften the doc to "as reported by the server" and pipe status through ParsedWindow so 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 / :limit references cleared from source and tests (grep over the tree returns no business matches).
  • Shell/ module fully removed from project.pbxproj (build files, file refs, group folder, and ShellProcessRunnerTests.swift all gone).
  • Three OpenCode instances share KeychainService.openCodePlaceholderRef; RefreshService groups by apiKeyRef and 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 / monthly percent + <dim>:end_time ms) aligns with MetricConfig.key for clean 1:N mapping — no upstream changes needed beyond the supplier.
  • Endpoint.exposesFailureBodyInLog = true is 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.knownGoodSample trailing-/ fix correctly matches idRegex lookahead; the new comment on the constant is accurate.
  • OpenCodeSupplier.fetchUsage URL 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
@linletian

Copy link
Copy Markdown
Owner Author

Review disposition (analysis first, fixes in 463de65, 396/396 tests green):

#1 rate-limited not enforced — accurate, fixed. parseWindow now forces percent=100 when status == "rate-limited" instead of trusting the payload. The doc claim and the code now agree; added testParseRateLimitedForcesHundredOnMismatch (rate-limited + percent:42 → 100) as a schema-drift guard.

#2 ISO8601 no-fraction fallback — accurate, fixed. resetsAt now falls back to a plain withInternetDateTime formatter when the fractional parse fails, so a bare 2026-09-07T00:00:00Z no longer breaks all three windows at once. Covered by testParseResetsAtWithoutFractionalSeconds.

#3 do/catch is log+rethrow — declined (intentional). The shape mirrors KimiSupplier.fetchUsage's parse-failure block (KimiSupplier.swift:23-37) deliberately — same diagnostic contract (URL + body preview at .public, then rethrow). Moving the log into the parser would drop the endpoint-URL context the parser doesn't have, and inlining would diverge from the established supplier idiom.

#4 local:// prefix — declined (migration cost). The string is not just a constant: it's the kSecAttrAccount in every existing install's keychain and the persisted apiKeyRef in instances.json. Renaming would orphan the shared entry on upgrade (three instances silently losing the pasted key) for a cosmetic gain. The doc comment on the symbol now spells out its role instead.

#5 inline URL(string:)! — inaccurate. KimiSupplier.swift:14 uses the identical inline pattern, as do the MiniMax/DeepSeek examples in ARCHITECTURE §6.4 — an inline force-unwrapped compile-time constant URL is the codebase idiom, so there's nothing to align.

@linletian linletian left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:14
  • DeepSeekSupplier.swift:14
  • KimiSupplier.swift:14
  • MiniMaxSupplier.swift:14
  • OpenCodeSupplier.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 status to ParsedWindow — 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant