Skip to content

feat(opencode): add opencode as a routed surface with correct usage accounting and BYOK metrics - #60

Merged
woodenconsulting merged 9 commits into
Intevity:mainfrom
jable145:feat/opencode-byok-support
Oct 7, 2026
Merged

woodenconsulting merged 9 commits into
Intevity:mainfrom
jable145:feat/opencode-byok-support

Conversation

@jable145

@jable145 jable145 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Add opencode as a first-class Sentinel surface: route it through the proxy, account for its usage correctly, and surface API-key (BYOK) spend in the Metrics tab.

opencode previously worked through Sentinel only by hand-editing config, and its traffic was either invisible or mis-attributed. This PR closes both gaps and adds the enable/disable card, the docs, and the tests.

What's in it

opencode surface (enable / disable / detect)

  • New opencode-config.ts writes provider.anthropic.options.baseURL into ~/.config/opencode/opencode.jsonc, preserving comments and every unrelated key.
  • OpencodeSurfaceCard + useOpencodeConfig mirror the existing desktop-surface card.
  • The routed check requires the /v1 path, not just host:port. A base URL on Sentinel's host without /v1 cannot work — the AI SDK appends /messages and Anthropic 404s it — so reporting that as "routed" would show green on a config that fails every request.

Usage accounting for clients that never emit OTEL

  • usage_events previously got no row for claude-cli-UA traffic that didn't emit OTEL. The old code predicted OTEL from the User-Agent; both the spawned claude --print child (exits before its exporter's first flush) and the opencode-claude-auth plugin (no OTEL at all) broke that prediction, so neither writer ran.
  • Replaced the prediction with an observed fact: the proxy stages a priced row keyed on Anthropic's request-id response header. If OTEL later claims that request-id it wins and the staged row is dropped; otherwise a 90s sweeper commits the proxy-priced row.
  • Dedupe is enforced in SQLite (partial UNIQUE index on request_id + INSERT OR IGNORE), not in application logic, so a race cannot double-count.

BYOK (API key) as a Metrics scope

  • Proxy traffic arriving with an x-api-key and no Claude identity is attributed to the reserved BYOK_ACCOUNT_ID; the client's key is forwarded untouched (Sentinel never substitutes a pooled token for it).
  • New get_byok_state IPC gates the picker row on real data — the "API key" scope only appears once BYOK usage exists.
  • metricsScope.ts extracts the previously inline, untested option-building and scope-mapping logic out of App.tsx so it can be unit-tested.
  • BYOK can never become the implicit default view, and renders below the user's real accounts (PoolOption.trailing — the picker draws pool options and accounts as two separate groups, so array order alone is not enough).
  • "All accounts" deliberately stays an enrolled-accounts total; BYOK is a separate scope, not a member.

Pricing

  • Corrected Opus 4.6+ rates and added the Claude 5 models.

Fix: scripts/install-app.sh could rm -rf a running app

  • The guard matched MacOS/Sentinel, but Tauri names the executable lowercase sentinel. The pattern never matched, so the "is it still running?" check was a silent no-op. Caught and verified live — it now correctly aborts.

Verification

  • pnpm test — 205 files, 4105 passed / 2 skipped. Coverage 96.93 stmts / 93.09 branch / 98.12 funcs / 96.93 lines, all four thresholds clear.
  • pnpm typecheck, pnpm run format:check, pnpm exec eslint packages/daemon/src packages/shared/src, scripts/commit-lint.mjs, scripts/tailwind-classes.mjs, scripts/check-detector-regex.mjs, scripts/generate-ico.mjs --verify — all clean.
  • pnpm mock:budget — ok, no change to .mock-budget.json. New tests use the fake-Anthropic harness and the env seams (SENTINEL_TEST_DB_FILE, startTestDaemon, startProxyWithFake), not mocks.

Manually verified end to end against a real API key: one opencode run on the anthropic-api provider produced two POST /v1/messages → 200 (account: byok) and exactly two usage_events rows under account_id='byok' with cost and request_id populated, zero duplicate request_ids table-wide, and pooled subscription rows interleaved untouched — both billing paths coexisting in one opencode install.

Notes for the reviewer

  • documentation/OPENCODE_INTEGRATION_PLAN.md introduces a new top-level documentation/ directory. It's an engineering findings doc — the two verified subscription plugin paths, a "ruled out, do not re-derive" section covering four closed routes into Meridian/opencode-with-claude, and the cache-cost finding. Useful context, but easy to drop or relocate if you'd rather not add the directory; say the word and I'll move it.
  • The anthropic-api provider recipe documented in connect-opencode.mdx uses a base URL that Sentinel's activate/deactivate does not manage (it only touches provider.anthropic), so those requests fail outright if the daemon is down. That caveat is called out in the docs.
  • Out of scope, deliberately: BYOK on the Usage tab (API-key responses carry no unified-* quota windows, and UsageView's account fallback would misattribute), and spend/budget/alerts for BYOK (it isn't an enrolled account).

jable145 and others added 8 commits September 5, 2026 12:47
- add BYOK passthrough: a client that authenticates with its own `x-api-key` and is not a Claude Code surface reaches Anthropic on that credential, with every observer still running
- strip a client-supplied `x-api-key` whenever Sentinel injects a pooled token, so upstream can never honour the wrong credential
- attribute BYOK traffic to a reserved key so a foreign credential's rate-limit windows and spend never land on a pooled account
- record `usage_events` from the proxy for clients that emit no OTEL, fixing blank cost and token figures for Claude Desktop as well as opencode
- price whole requests from response usage, leaving cost null for models absent from the price table rather than guessing a rate
- add opencode as a third surface with install detection, an activation card, and a Settings toggle
- write `provider.anthropic.options.baseURL` into opencode's global config, preserving every other key and refusing to rewrite a file whose comments a JSON round-trip would destroy
- report `plugin-override` when opencode-with-claude rewrites the base URL at startup, instead of showing a routed state that is not true
- advertise bridged MCP servers in an existing `AGENTS.md`, and never create one, since its presence suppresses `~/.claude/CLAUDE.md` for opencode
- accept `x-api-key` in the fake Anthropic listener, matching the real API's two auth forms
- document the setup, what BYOK does and does not include, and why subscription pooling is not offered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- price Opus 4.6, 4.7, and 4.8 at $5/$25 per MTok; a bare `claude-opus-4` prefix ahead of them matched every 4.6+ model at the legacy $15/$75, overstating cache-TTL and Optimize cost figures threefold
- add Claude Opus 5, Sonnet 5, Fable 5, and Mythos 5 to the price table, so current models are priced rather than falling through to the unknown-model path
- order the table most-specific-first and document that the ordering is load-bearing for prefix matching
- price Sonnet 5 at its standard $3/$15 rather than the promotional rate, and say why in the table

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…outed

- treat a Sentinel host-and-port base URL without the `/v1` path as not-yet-routed: the AI SDK appends `/messages`, so the request 404s, and reporting it as active hid the card behind a green state on a config that cannot work
- keep deactivation keyed on host and port so a URL missing `/v1` is still recognized as Sentinel's to clean up
- document the case in the connect-opencode troubleshooting section
- cover reading a config through block comments, which the parse path had never exercised

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…age rows deduped on request-id

- stage proxy-observed usage for claude-cli-UA requests keyed on Anthropic's request-id instead of predicting OTEL will report it
- claim (discard) the staged row when the OTEL api_request for the same request-id arrives; commit unclaimed rows after a 90s grace via the new pending-usage sweeper
- add a partial UNIQUE index on usage_events.request_id so proxy-vs-OTEL write races land exactly one row in every ordering
- persist request_id on OTEL-authored usage rows and broadcast metrics_updated only when an insert actually landed
- sweep on daemon startup and on demand in get_usage_summary / get_metrics_summary so restarts and UI reads never strand staged rows
- fixes blank cost/token figures for opencode subscription plugins (opencode-claude-auth, spawned claude --print children); real CLI sessions with OTEL disabled now produce proxy-priced rows after the grace window
- document the verified opencode-claude-auth subscription path in connect-opencode.mdx and the internal integration plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- add an "API key" row to the Metrics scope picker, shown once any usage exists under the reserved BYOK attribution key
- gate the row on a new get_byok_state IPC (cheap limit-1 probe of usage_events, refreshed on metrics_updated broadcasts)
- keep BYOK strictly opt-in: the row renders last and every default-selection fallback skips it
- move BYOK_ACCOUNT_ID to @sentinel/shared so app and daemon share one definition (re-exported from proxy.ts)
- extract Metrics scope-picker logic from App.tsx into packages/app/src/lib/metricsScope.ts with unit tests
- document the dual-provider opencode recipe (subscription plugin + anthropic-api entry side by side) in connect-opencode.mdx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll running

- correct the running-app pgrep pattern to the lowercase `sentinel` binary name, so the guard stops the rm -rf instead of silently never matching

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… above them

- add PoolOption.trailing so secondary scopes draw after the account list, since the picker renders pool options and accounts as two separate groups
- assert the trailing flag rather than array position, which passed while the row drew in the wrong group
- record the real-key BYOK verification and the opencode cache-write cost profile in the integration plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge changes

- reflow imports, SQL prepare chains, and markdown tables so `pnpm run format:check` passes
…DE_CONFIG

- clear XDG_CONFIG_HOME, XDG_DATA_HOME, and OPENCODE_CONFIG for the daemon's lifetime and restore them on cleanup, so SENTINEL_TEST_HOME actually owns opencode config resolution
- stops the opencode surface tests reading and writing the real ~/.config/opencode/opencode.json on any machine that exports XDG_CONFIG_HOME, including every GitHub Linux runner

@joshau joshau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this. It's a big, careful piece of work: the surface follows the claude-desktop-config.ts pattern, the sweeper mirrors rate-limit-sweeper.ts, tests go through the fake-Anthropic harness rather than mocks, and CI is green. The x-api-key strip when injecting a pooled Bearer token and the install-app.sh pgrep fix are good catches in their own right.

I've left inline comments where each issue lives. I checked the opencode behaviour against opencode's own source (anomalyco/opencode@dev) rather than its docs, and the links are in the comments. Summary of what needs to change before this can merge:

1. Don't document or endorse subscription use through third-party plugins

Anthropic's terms restrict Pro/Max credentials to official clients, and the guide itself says so. Sentinel's docs and code comments shouldn't then describe opencode-claude-auth as "verified working" with pooling and switching. Please remove the "Subscription plans via community plugins" section and the related references: the Aside, the pooling table row, "Running both side by side", the proxy.ts usage-block comment, and the plan doc. Detecting a plugin that overrides the base URL (plugin-override) is fine to keep. That's a factual routing diagnostic, not an endorsement.

2. Permission rules don't apply to opencode tool calls

The "Permission rules ✅" claim is incorrect. opencode's tool ids are lowercase (bash, edit, write, read, glob, grep, webfetch, websearch) and its path argument is filePath. It also has apply_patch, which has no Sentinel equivalent, and its MCP tools are named server_tool. The evaluator matches tool names exactly and case-sensitively and dispatches on Bash / PATH_TOOLS / WEB_TOOLS, so only * rules ever fire. Details are on the docs table row. Please mark it ❌ (or "wildcard rules only") here. A tested name/argument mapping can follow in its own PR.

3. Split the usage-accounting change into its own PR

The proxy writing usage_events, the pending_usage_events staging table, the request_id column plus partial UNIQUE index, the OTEL claim, and the pending-usage sweeper together fix a real gap. But it's a gap for every client without OTEL (Claude Desktop, claude --print children), not just opencode. It changes the accounting semantics on the hottest path and adds a schema migration, so it deserves its own review, changelog entry and rollback story. Please move it into a separate PR. This one can land without it: BYOK rows still show up in the request log.

4. Where opencode reads config, and which environment the daemon sees

From opencode's config/config.ts, sources load in this order, later ones overriding earlier (deep merge):

  1. global config.json, then opencode.json, then opencode.jsonc
  2. OPENCODE_CONFIG
  3. project opencode.json[c], found walking up from the working directory
  4. .opencode/ directories and OPENCODE_CONFIG_DIR
  5. OPENCODE_CONFIG_CONTENT, last

The global directory comes from xdg-basedir, which is $XDG_CONFIG_HOME || ~/.config on every OS (it never uses %APPDATA% or ~/Library). OPENCODE_CONFIG_DIR replaces it. The PR:

  • gets the .jsonc-over-.json choice and the OPENCODE_CONFIG handling right;
  • misses OPENCODE_CONFIG_DIR;
  • can report "active" when a project file or OPENCODE_CONFIG_CONTENT shadows the global write;
  • adds a Windows %LOCALAPPDATA% install marker that nothing in opencode backs.

Separately, the daemon inherits the Tauri app's environment, not the user's shell. On macOS in particular (Finder or login launch goes through launchd), XDG_CONFIG_HOME / OPENCODE_CONFIG / OPENCODE_CONFIG_DIR exported in .zshrc are invisible to it. Comments on opencodeConfigDir / opencodeConfigPath.

5. JSONC handling

opencode parses its config as JSONC with allowTrailingComma: true and writes it with jsonc-parser's modify / applyEdits. The PR strips comments and then calls JSON.parse, so a file with a trailing comma is misreported as malformed, and any commented file is refused as unwritable. That contradicts the PR description ("preserving comments"). Options are on readConfig.

6. DRY: two copies of opencodeConfigDir()

agents-md-inject.ts duplicates opencodeConfigDir() and resolveHome() from opencode-config.ts. Please import the one from opencode-config.ts so the fix for point 4 lands in one place.

7. documentation/OPENCODE_INTEGRATION_PLAN.md

The repo has no top-level documentation/ directory. User docs live in packages/site and developer notes in CLAUDE.md. Please drop the file. Anything durable (for example the "why no pooling" reasoning) can move into CLAUDE.md or a code comment.

8. A user's existing gateway URL is overwritten and lost

In the foreign-base-url state (LiteLLM, a corporate gateway, a Bedrock or Vertex facade), Enable replaces the URL, keeps no backup, and Disable doesn't restore it. Sentinel can't chain to it either, because its upstream is fixed to api.anthropic.com. Details are on activateOpencode and the card.

9. Pricing table

Checked against Anthropic's pricing page: Opus 4.5 is missing and falls through to $15/$75, and Opus 5.5 and Sonnet 5.5 are caught by the -5 prefixes at the wrong rates. See the inline comment.

Smaller nits are inline (a prepared statement inside a loop, and the fake's x-api-key fallback).


<Aside type="caution" title="opencode is bring-your-own-key">
opencode authenticates with **your own Anthropic API key**. Sentinel does **not** hand it a pooled
subscription account — see [Why no account pooling](#why-no-account-pooling) below. Community

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Terms (point 1). Please drop the sentence pointing at community plugins that "bring their own subscription auth" and the link to that section. Anthropic's terms restrict Pro/Max credentials to official clients, and this guide says as much further down. Having Sentinel's own docs then describe a plugin as "verified to work alongside Sentinel" reads as an endorsement, whatever the disclaimer says.

The rest of the Aside (opencode is bring-your-own-key, and why) is accurate and worth keeping.

| -------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Request log | ✅ |
| Security scanning (secrets, PII, prompt injection) | ✅ |
| Permission rules (allow / deny / ask) | ✅ — minus session-scoped grants |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This claim is incorrect (point 2). I checked opencode's tool definitions against Sentinel's evaluator:

Sentinel expects opencode sends Source
Bash + command bash + command tool/shell/id.ts: the id stays "bash" "for compatibility" until 2.0
Edit / Write / Read + file_path edit / write / read + filePath packages/opencode/src/tool/
Glob / Grep glob / grep (pattern, path) packages/opencode/src/tool/
WebFetch / WebSearch webfetch / websearch (url, query) packages/opencode/src/tool/
none apply_patch + patchText, which replaces edit/write for gpt-* models tool/registry.ts ~L297
mcp__server__tool server_tool (single underscore) mcp/catalog.ts ~L117

On the Sentinel side, toolNameMatches in security/permissions/evaluator.ts compares names exactly and case-sensitively (plus the mcp__ prefix), and dispatch only special-cases Bash, PATH_TOOLS (Read/Edit/Write/…) and WEB_TOOLS. So for opencode traffic, only * wildcard rules match. A Bash(rm -rf *) deny rule doesn't fire on opencode's bash tool call. For a guardrails feature, that's a dangerous thing to get wrong in the docs.

Suggest marking this ❌ (or "wildcard rules only") and following up with a mapping layer in its own PR: lowercase ids to Sentinel names, filePath to file_path, and a parser for apply_patch targets. MCP can't be mapped exactly, because server_tool has no separator you can reliably split on. Secret/PII scanning is content-based, so the ✅ above does hold.

| Security scanning (secrets, PII, prompt injection) | ✅ |
| Permission rules (allow / deny / ask) | ✅ — minus session-scoped grants |
| Cache-TTL handling | ✅ |
| Multi-account pooling and switching | ❌ with an API key — works via the `opencode-claude-auth` community plugin (see below) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Terms (point 1). Please make this row just "❌ (bring-your-own-key)". Pointing readers at opencode-claude-auth as the way to get pooling is the endorsement we need to avoid.

honestly and never manufactures a client identity it wasn't given. So on Sentinel's own terms,
opencode gets the observability and guardrail half, on your own key.

## Subscription plans via community plugins

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Terms (point 1). Please remove this section (through "Running both side by side"). The disclaimer is there, but the content is still a how-to for using subscription credentials in a non-official client: "verified working", the install line, and "pooling and switching apply". Sentinel shouldn't document that.

What's worth keeping is the routing diagnostic. Some plugins overwrite provider.anthropic.options.baseURL at startup, so traffic bypasses Sentinel, and the card detects this. That fits in Troubleshooting ("The card says opencode is bypassing Sentinel") without naming the plugins as subscription options. The Troubleshooting entry at the bottom links back here, so it needs updating too.

* client wanting Sentinel's observability brings its own key instead — see
* {@link isByokRequest}.
*/
export function clientPresentsClaudeIdentity(headers: IncomingMessage['headers']): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Terms (point 1), for awareness rather than a code change. This gate decides who gets pooled subscription tokens, and it trusts a client-supplied claude-cli/ user-agent (or the oauth beta flag). That's how opencode-claude-auth gets pooled: it sends a Claude Code UA. I don't think we can close that from the UA alone, since the real CLI sends claude-cli/<ver> (external, cli) too (see the comment near L676). The docstring here says "never manufactures the identity", and that's the right line.

So what I'm asking: no tests, docs or comments should treat a non-official client passing this gate as a supported path. The comment at L1504 names opencode-claude-auth as an expected caller, and proxy.byok.integration.test.ts / proxy.usage-staging.integration.test.ts should be checked for the same framing.

@@ -0,0 +1,222 @@
# opencode integration — findings and open work

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please drop this file (point 7). The repo has no top-level documentation/ directory. User-facing docs live in packages/site and developer guidance in CLAUDE.md, and this reads as working notes. It also includes the subscription-plugin analysis ("routes into Meridian" and so on) that point 1 asks us not to publish.

If there's durable design reasoning here worth keeping (why BYOK instead of pooling, the /v1 baseURL requirement), a short section in CLAUDE.md or a module doc comment in opencode-config.ts is the right home.

const PRICE_TABLE: ReadonlyArray<readonly [string, number, number]> = [
['claude-fable-5', 10, 50],
['claude-mythos-5', 10, 50],
['claude-opus-5', 5, 25],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude-opus-5 also matches claude-opus-5-5. Per Anthropic's pricing page, Opus 5.5 is $4 / $20, not $5 / $25. First-hit prefix matching means it needs its own row above this one: ['claude-opus-5-5', 4, 20].

The same applies to Sonnet (see L40). And cache-read pricing differs for the newest models (Opus 5.5 hits are 0.05× input, Fable/Mythos 5.1 are 0.025×). If computeCacheCosts assumes 0.1× everywhere, that's worth a follow-up, but it's out of scope here.

['claude-opus-4-8', 5, 25],
['claude-opus-4-7', 5, 25],
['claude-opus-4-6', 5, 25],
['claude-opus-4', 15, 75],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Opus 4.5 is missing and falls through to this row. claude-opus-4-5-* isn't in the list, so it matches claude-opus-4 and is priced at $15/$75, 3× its real $5 / $25 (pricing page; price cut announced with the 4.5 launch). That's exactly the failure the doc comment above warns about. Please add ['claude-opus-4-5', 5, 25] above this row, correct the comment ("claude-opus-4-5 and later are $5/$25"), and add a test case for it.

['claude-opus-4-6', 5, 25],
['claude-opus-4', 15, 75],
['claude-opus-3', 15, 75],
['claude-sonnet-5', 3, 15],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sonnet 5.5 is caught by this prefix. The pricing page lists Sonnet 5.5 at $2 / $10, so claude-sonnet-5-5 needs its own row above this one.

On Sonnet 5 itself: the page still shows $2 / $10 with a footnote. The comment says the promo ended 2026-08-31, so please double-check the footnote's current terms. If $3/$15 is the standard rate, the current choice and reasoning are fine; I just couldn't confirm the end date from the page.

}
const keyHeader = req.headers['x-api-key'];
const apiKey = Array.isArray(keyHeader) ? keyHeader[0] : keyHeader;
if (apiKey) return tokens.get(apiKey) ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: an unknown Bearer token now falls through to x-api-key, so a request carrying both a bad Bearer and a valid key authenticates. That's more lenient than a strict "Bearer wins" check, and could mask a proxy bug where the pooled token injection silently fails but the client's key rescues the request. Now that the proxy deletes x-api-key when injecting a Bearer, this path shouldn't be exercised, so returning null when a Bearer is present but unknown would make the fake assert that invariant for free.

@woodenconsulting
woodenconsulting merged commit 0df81f5 into Intevity:main Oct 7, 2026
6 checks passed
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.

3 participants