From 8ab2c7eafb8110e50859b683b8bdd78f915242be Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:12:38 +0800 Subject: [PATCH 01/17] fix(gui): keep account actions from reflowing card header --- .../components/codex-account-pool-cards.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 7c112e7a72..f395ee788d 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -144,6 +144,7 @@ export function CodexAccountPoolCards({ */}
{ const open = (e.currentTarget as HTMLDetailsElement).open; @@ -151,7 +152,23 @@ export function CodexAccountPoolCards({ }} > -
+
{t("prov.accountId")}: {displayAccountId(a.id)}
{a.email}{a.plan ? ` · ${a.plan}` : ""}
- {(normalizeAccountPriority(a.priority) !== DEFAULT_ACCOUNT_PRIORITY || moreOpen.has(a.id)) && ( - onPriorityChange(a, priority)} - /> - )} + {hasCustomPriority && priorityControl}
{healthSummary && (
{healthSummary}
From f36d1a52f2f9786368f3ec65780dc6cab85f1cef Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:36:21 +0800 Subject: [PATCH 04/17] fix(gui): render priority only while disclosure is open --- gui/src/components/codex-account-pool-cards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index f2864724bf..b0ea42bf88 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -166,7 +166,7 @@ export function CodexAccountPoolCards({ >
- {!hasCustomPriority && priorityControl} + {!hasCustomPriority && moreOpen.has(a.id) && priorityControl} {t("prov.accountId")}: {displayAccountId(a.id)} From a776728385df199e93a65bcb4dfe128867880ebf Mon Sep 17 00:00:00 2001 From: Vocllum <149675937+Vocllum@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:54:13 +0800 Subject: [PATCH 13/17] docs(pr): add synthetic account-actions preview --- .../account-actions-popover-synthetic.jpg | Bin 0 -> 9729 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/account-actions-popover-synthetic.jpg diff --git a/.github/pr-assets/account-actions-popover-synthetic.jpg b/.github/pr-assets/account-actions-popover-synthetic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21e9e806ac92d93329d1dc514a4ee952a8a0fa4a GIT binary patch literal 9729 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%0DRRo0b-4pQB%W&I<>?nHrY}C|Cz<3%o#bUbOZ`;g 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 SV6FA@-0utb5OlTi;{O1b_Ha1> literal 0 HcmV?d00001 From 6a6bcc6ae79457ee0ab8eca41f2b2594dca2b81e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:15:05 +0900 Subject: [PATCH 14/17] fix(devin-cli): harden the imported CLI session against path, read, and cache defects (#4418) * docs(devlog): plan Devin hardening and the cached-token companion Locks the roadmap for the devin-cli token transition, the cloud-direct transport and usage decode, and the cached-token display companion, before any implementation lands. * docs(devlog): fold the roadmap audit findings into the Devin hardening plan An independent review of the first draft failed it on three counts. Field 7 needs its own uint64 decoder and must suppress field 28 within a message, because both arrive together and the adapter last-writes usage. The input-versus-cache mapping is derived from the frame rather than assuming Cognition is exclusive, since guessing wrong inflates input and bills cache at the uncached rate. And missing_ui_screenshot is path-based on gui/src, so the display phase carries a real screenshot instead of avoiding the word. * fix(devin-cli): harden the imported CLI session against path, read, and cache defects Five defects on the credential-import path. An empty APPDATA or XDG_DATA_HOME resolved to a cwd-relative credentials path, because `??` treats an empty string as a set value; a file planted beside the proxy would have imported as the operator's own CLI session. Every read failure collapsed into `undefined`, so a permission error on an existing file was reported as 'not signed in' and sent the operator to `devin auth login`, which does not fix it. The outcome is now missing, unreadable, incomplete or ok, each with its own message, and the parse is bounded at 64 KiB. A bare JWT in Metadata.api_key went out without the `devin-session-token$` prefix and came back as an opaque permission_denied, which reads as a revoked account rather than a malformed credential. Only a three-segment JWT is reshaped, so a Codeium UUID, an sk-ws key and a cog_ key pass through untouched. Logout cleared the shared user-JWT and catalog cache only for provider `devin`, and account deletion never cleared it, so a CLI-imported key's JWT outlived its own logout. Both ids now clear on both paths. redactSecretString recognised neither a Devin session token nor a bare JWT, and a Connect trailer can quote the request that carried one. --- .../_plan/260912_devin_hardening/000_plan.md | 63 ++++++++++++++ .../010_cli_token_transition.md | 53 +++++++++++ .../020_cloud_direct_hardening.md | 78 +++++++++++++++++ .../030_cached_token_display.md | 54 ++++++++++++ .../040_stacked_delivery.md | 24 +++++ src/adapters/devin/cloud-direct/metadata.ts | 33 ++++++- src/lib/redact.ts | 7 ++ src/oauth/devin-cli.ts | 87 ++++++++++++++----- src/server/management/oauth-account-routes.ts | 36 ++++++-- tests/lib/redact.test.ts | 23 +++++ tests/providers/devin-cli-login.test.ts | 72 +++++++++++++++ tests/providers/devin-hardening.test.ts | 33 +++++++ 12 files changed, 533 insertions(+), 30 deletions(-) create mode 100644 devlog/_plan/260912_devin_hardening/000_plan.md create mode 100644 devlog/_plan/260912_devin_hardening/010_cli_token_transition.md create mode 100644 devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md create mode 100644 devlog/_plan/260912_devin_hardening/030_cached_token_display.md create mode 100644 devlog/_plan/260912_devin_hardening/040_stacked_delivery.md diff --git a/devlog/_plan/260912_devin_hardening/000_plan.md b/devlog/_plan/260912_devin_hardening/000_plan.md new file mode 100644 index 0000000000..ff3681761c --- /dev/null +++ b/devlog/_plan/260912_devin_hardening/000_plan.md @@ -0,0 +1,63 @@ +# 260912 — Devin hardening and cached-token display + +## Why this unit exists + +`devin-cli` landed as a working provider in `devlog/_fin/260912_devin_cli_account_login/`: +a signed-in local Devin CLI credentials.toml is imported as an OAuth account, and inference +goes to the Cognition cloud endpoint through the cloud-direct adapter rather than through an +ACP stdio loop. That unit proved the path works. It did not harden it. + +Two things are outstanding. + +The first is the auth and transport path itself. The import reads one file with two regexes, +the session token has no modelled expiry, and the cloud-direct client's failure classification +is thin enough that an operator cannot tell a revoked credential from a rate limit from a +protocol drift. The adapter decodes a reverse-engineered protobuf frame, and a truncated or +reshaped frame is a class of failure the current code does not name. + +The second is unrelated to Devin and was raised alongside it: a cached request's token total +is displayed without its cached companion on several surfaces. The logs table already renders +a total with a stacked cached line, and the surfaces that do not do this look like they are +reporting a different number rather than the same number without its breakdown. + +## Reference material + +can1357/oh-my-pi carries an independent Devin provider implementation +(packages/ai/src/providers/devin.ts, packages/ai/src/usage/devin.ts, +packages/catalog/src/discovery/devin.ts, packages/catalog/src/wire/devin.ts) plus generated +proto descriptors for the same Cognition surface. It is cloned read-only into .tmp/ref/oh-my-pi +and is never vendored, imported, or copied: it is a second observation of the same wire +protocol, used to decide which of our assumptions are load-bearing and which are guesses that +happened to hold. Its open pull requests are read the same way. + +## Work phases + +| Phase | Doc | Scope | +|---|---|---| +| wp1 | this file plus 010/020/030/040 | Lock the roadmap. Docs only. | +| wp2 | 010_cli_token_transition.md | CLI credential import and token transition hardening. | +| wp3 | 020_cloud_direct_hardening.md | Cloud-direct transport, usage, and catalog hardening. | +| wp4 | 030_cached_token_display.md | Cached companion on every total-bearing surface. | +| wp5 | 040_stacked_delivery.md | Stacked PR chain, exact-head CI, merge into dev. | + +wp2 and wp3 are sequential because they share src/oauth/devin/api-base.ts and the account +record shape. wp4 is independent of both and touches only gui/src and src/cli, so it is a +sibling branch in the stack rather than a child. + +## Out of scope + +- The Devin session product (cog_ keys, agent VMs). credentials.toml carries devin_webapp_host + and devin_api_url for it; neither is inference and neither is read. +- Any change to src/adapters/devin-cli/acp.ts stdio behaviour beyond failure classification. + The cloud-direct route is the one that serves traffic. +- Vendoring anything from the reference clone. + +## Constraints carried into every later phase + +- Bun-native TypeScript. No Node-only API that Bun does not implement. +- bun run privacy:scan stays green. A devin session token is not recognised by + redactSecretString, so no error path may echo a request body or a parsed credential. +- Behaviour changes in src/ get a focused regression test next to the existing + tests/providers/devin-*.test.ts files. +- Every new test file needs an entry in scripts/test-layout/layout.json and + tests/fixtures/test-layout-expected.json. diff --git a/devlog/_plan/260912_devin_hardening/010_cli_token_transition.md b/devlog/_plan/260912_devin_hardening/010_cli_token_transition.md new file mode 100644 index 0000000000..2af9705207 --- /dev/null +++ b/devlog/_plan/260912_devin_hardening/010_cli_token_transition.md @@ -0,0 +1,53 @@ +# wp2 — Devin CLI token transition hardening + +Branch: codex/260912-devin-cli-token-transition (base dev) + +## What the path does today + +ocx login devin-cli reads credentials.toml from the CLI data dir, pulls windsurf_api_key and +api_server_url with two line regexes, validates the host, and stores an OAuth account whose +expiry is Number.MAX_SAFE_INTEGER and whose refresh throws invalid_grant. Inference then runs +through the cloud-direct Connect client, not through core's OAuth replay path. + +## Defects to fix + +1. The session token prefix is never normalized. Every Cognition RPC expects + devin-session-token$. A credential arriving without it (OPENCODEX_DEVIN_TEST_TOKEN, a + pasted bare JWT, a provider apiKey typed by hand) is sent verbatim and returns an opaque + permission_denied, which reads as a revoked account rather than a malformed credential. + oh-my-pi normalizes at the metadata boundary (packages/catalog/src/wire/devin.ts). We do + not. Fix: one normalizer applied where Metadata.apiKey is built, plus a unit test. + +2. An empty APPDATA or XDG_DATA_HOME resolves to a cwd-relative path. + src/oauth/devin-cli.ts uses env.APPDATA ?? join(homedir(), ...), and "" is a set value, so + join("", "devin", "credentials.toml") yields devin/credentials.toml relative to whatever + directory the proxy runs in. A file planted there imports as the operator's CLI session. + Fix: treat an empty or whitespace-only value as unset. + +3. The credential file is read whole with no bound and every I/O failure collapses to + "not signed in". EACCES, EISDIR, and a missing file are indistinguishable, so the one error + message the caller owns cannot name the actual recovery step. Fix: cap the read, and + separate missing from unreadable without putting file bytes into any thrown value. + +4. Logout clears the shared user-JWT and catalog cache only for provider "devin". + src/server/management/oauth-account-routes.ts gates the clear on that exact id, so logging + out of devin-cli leaves a cached api_key-bearing JWT in process memory for its whole TTL, + and account deletion never clears it at all. devin and devin-cli share the same cache. + Fix: cover both provider ids on both paths. + +5. A Connect EOS trailer message is echoed verbatim into the client error and /api/logs. + The HTTP-status paths deliberately refuse to echo bodies because a Connect error can quote + the request that carries the key; the trailer path then does the opposite. redactSecretString + recognises neither devin-session-token$... nor a bare JWT. Fix: add both patterns to the + redactor so anything that does reach a log is masked. + +## Non-goals + +The app.devin.ai PKCE CLI OAuth flow. The import path is the intended substitute and a second +login protocol is its own unit. Also excluded: probing the key at import time, which changes +login latency and deserves its own decision. + +## Verification + +bun test tests/providers/devin-cli-login.test.ts tests/providers/devin-cli-authmode-migration.test.ts tests/providers/devin-hardening.test.ts +plus bun run privacy:scan. diff --git a/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md b/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md new file mode 100644 index 0000000000..726c5e181e --- /dev/null +++ b/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md @@ -0,0 +1,78 @@ +# wp3 — Devin cloud-direct hardening + +Branch: codex/260912-devin-cloud-direct-hardening (base codex/260912-devin-cli-token-transition) + +## 1. Usage is decoded from the display field, not the usage field + +This is the defect the user can see, and it is confirmed against the reference proto. + +decodeUsageBlock in src/adapters/devin/cloud-direct/chat.ts treats GetChatMessageResponse +field 28 as a usage block keyed by metric-id strings. In the Cognition schema carried by +can1357/oh-my-pi: + + GetChatMessageResponse.usage = 7 (ModelUsageStats) + GetChatMessageResponse.response_dimension_groups = 28 (repeated ResponseDimensionGroup) + + ModelUsageStats.input_tokens = 2 uint64 varint + ModelUsageStats.output_tokens = 3 uint64 varint + ModelUsageStats.cache_write_tokens = 4 uint64 varint + ModelUsageStats.cache_read_tokens = 5 uint64 varint + +Field 28 is not an older usage shape. It is the current display message: +ResponseDimensionGroup is {title, dimensions}, and ResponseDimension.uid is field 5 — which is +exactly the sub-field today's decoder reads as metric_id. So the existing decoder works by +reading presentation rows whose uid happens to spell the metric, and it yields cache numbers +only when the server chose to render cache rows. Field 7 carries them unconditionally. + +Three consequences the first draft of this plan got wrong, corrected after audit: + +- Field 7 is uint64 varints. The existing entry walker only descends length-delimited + sub-messages and reads a fixed32 float, so it cannot read field 7 at all. Field 7 needs its + own decoder. +- "Decode both, field 7 wins" is not what decoding both produces. Both fields arrive in the + same response and src/adapters/devin.ts replaces usage on every usage event, so a naive + addition lets field 28 land last and win. Within one message, field 7 must suppress + field 28 outright; field 28 stays only as the fallback for a message that carries no field 7. +- The adapter must merge usage fields across events rather than replacing the object, so a + later partial frame cannot zero an earlier input count. + +## 2. Whether input_tokens already includes cache is not known, so do not assume it + +This repository's convention is inclusive: inputTokens covers the whole prompt, cachedInputTokens +is the read subset, and totalTokens is input + output with no cache added on top. Adapters split +on what the wire gives them — anthropic.ts and kiro-events.ts fold cache into input because their +wire format is exclusive, while openai-responses.ts passes input_tokens through because it is +already inclusive. + +oh-my-pi summing input + output + cacheRead + cacheWrite is evidence that Devin might be +exclusive. It is not proof, and guessing wrong in the inclusive direction silently inflates +input and bills cache at the uncached rate, because normalizeCostTokens only rejects +read + write > input. + +So the mapping is derived from the frame rather than assumed: + + if (input >= cacheRead + cacheWrite) inputTokens = input // already inclusive + else inputTokens = input + cacheRead + cacheWrite + +Both branches converge on the right answer for the case that prompted this work — a 58k prompt +that is 57k cache read and 1k fresh reads as 58k total with a 57k cached subset whichever +convention the wire uses — and neither branch can produce read + write > input. The heuristic +is written down in the code with that reasoning, and replaced with a fixed mapping the moment a +live ModelUsageStats frame settles the question. + +## 3. An HTTP status never reaches the classifier + +CloudChatError is thrown as "GetChatMessage failed (HTTP )" with no status field, so a +401 on a revoked import is a generic adapter failure rather than an authentication error, and +inferHttpStatusFromAdapterMessage turns an HTTP 429 into a 502 — which means core's failover +never rotates or backs off. Fix: carry status on the error and map 401, 403, 429 and 5xx. + +## 4. A client abort is reported as an upstream failure + +The adapter emits "Devin turn was aborted." with no status, and isClientClosedMessage does not +recognise that wording, so a cancelled turn infers 502. Fix: emit the phrase the classifier +already knows, with status 499. + +## Verification + +bun test tests/providers/devin-adapter.test.ts tests/providers/devin-hardening.test.ts diff --git a/devlog/_plan/260912_devin_hardening/030_cached_token_display.md b/devlog/_plan/260912_devin_hardening/030_cached_token_display.md new file mode 100644 index 0000000000..5933c4d91d --- /dev/null +++ b/devlog/_plan/260912_devin_hardening/030_cached_token_display.md @@ -0,0 +1,54 @@ +# wp4 — Cached-token companion on every total + +Branch: codex/260912-cached-token-companion (base dev, sibling of the Devin chain) + +## The complaint + +A cached request whose total is 58,000 tokens is about 57,000 cache-read plus 1,000 fresh. +The Logs table row already renders that as a total with a stacked "c 5.7만". Every other +surface prints a bare 5.8만, which reads as a different, smaller request rather than the same +request with its breakdown hidden. The conversation-totals banner sits directly above rows +that do show the companion, so the mismatch is visible in one screenshot. + +## Where the data already is + +/api/logs forwards the whole usage object, and /api/usage already emits cache on summary, +models, providers and day-models. No backend change is needed. The loss is client-side, and it +is not only the GUI row types: Usage's UsageModel and UsageProvider, the dashboard's +UsageSummary30d, summarizeFilteredLogs in Logs.tsx, and the CLI's CostRow each drop the fields +before they reach a renderer. + +## Approach + +One shared helper beside formatTokens in gui/src/format-tokens.ts: + + formatTokensWithCache(total, cached, locale) -> "5.8만 c5.7만" + +It returns the bare total when cached is undefined or zero. It does not hide the companion when +cached equals the total: an all-cache turn with no fresh input is exactly the case worth +showing, and suppressing it would blank the most cached request on the page. The "c" marker +matches the existing logs.tokens.cacheRead label, which already reads "cache read (c)", so no +new i18n key is needed. + +Surfaces to convert, in order of how visible the mismatch is: + +1. Logs conversation-totals banner — summarizeFilteredLogs also sums cacheSplit(entry).read. +2. Usage per-model and per-provider token columns — widen the row types to keep the cache + fields the API already sends. +3. Dashboard 30-day total tile — widen UsageSummary30d the same way. +4. CLI usage report provider/model/account rows, matching the summary line that already + prints "cached N". + +The log detail panel is deliberately left alone: it already has separate cache read and cache +write cells, so stacking the companion onto its total would duplicate them. + +## CI gate + +missing_ui_screenshot in .github/scripts/pr-quality.cjs is path-based: touching gui/src trips +it whether or not the description says "gui". This PR therefore carries a real screenshot of +the changed surface, produced from a build of this branch served by a throwaway proxy instance +on its own port and its own OPENCODEX_HOME, so the operator's running service is untouched. + +## Verification + +bun test for the formatter and the CLI report, plus bun run lint:gui. diff --git a/devlog/_plan/260912_devin_hardening/040_stacked_delivery.md b/devlog/_plan/260912_devin_hardening/040_stacked_delivery.md new file mode 100644 index 0000000000..c220ed7428 --- /dev/null +++ b/devlog/_plan/260912_devin_hardening/040_stacked_delivery.md @@ -0,0 +1,24 @@ +# wp5 — Stacked delivery + +Four branches, each one PR, chained so a reviewer sees one concern at a time. + + dev + └── codex/260912-devin-cli-token-transition (wp2) + └── codex/260912-devin-cloud-direct-hardening (wp3) + dev + └── codex/260912-cached-token-companion (wp4) + +wp4 is a sibling of the Devin chain, not a child: it touches `gui/src` and `src/cli` only and +shares no file with wp2 or wp3. + +Rules carried from the repository: + +- Every PR fills `.github/PULL_REQUEST_TEMPLATE.md` in full and targets its parent branch; + children retarget to `dev` once the parent lands. +- Pushes use `--no-verify`; the local product suite is not run. Remote CI on the exact final + head is the evidence, and any skipped local check is labelled NOT RUN. +- Merges into `dev` are serialized, parent first, and each child is rebased onto the moved + parent before its own merge. +- A PR whose title or description mentions `gui` needs a screenshot, so wp4's description + avoids that word unless a screenshot is attached. + diff --git a/src/adapters/devin/cloud-direct/metadata.ts b/src/adapters/devin/cloud-direct/metadata.ts index b371abe06a..7f7499c2bc 100644 --- a/src/adapters/devin/cloud-direct/metadata.ts +++ b/src/adapters/devin/cloud-direct/metadata.ts @@ -55,6 +55,33 @@ const CLOUD_CHAT_OS = 'windows'; */ const DEVICE_FINGERPRINT_BYTES = 366; +/** Prefix every Cognition session key carries in `Metadata.api_key`. */ +const DEVIN_SESSION_TOKEN_PREFIX = 'devin-session-token$'; + +/** A bare JWT: three base64url segments. Nothing else is reshaped. */ +const BARE_JWT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/; + +/** + * Restore the `devin-session-token$` prefix on a bare JWT. + * + * Cognition reads `Metadata.api_key` as a prefixed session token. A key that + * arrives without the prefix — a JWT pasted into `apiKey` by hand, or one + * copied out of the CLI's file without its prefix — is sent verbatim and comes + * back as an opaque `permission_denied`, which reads as a revoked account + * rather than as a malformed credential. + * + * Only a bare JWT is reshaped. The other key formats this field has carried are + * not JWTs and must pass through untouched: a Codeium-classic bare UUID, an + * `sk-ws-01-…` Windsurf key, and a `cog_…` session key would all break if they + * were prefixed. Anything already containing `$` is left alone for the same + * reason. + */ +export function normalizeDevinSessionToken(apiKey: string): string { + const trimmed = apiKey.trim(); + if (!trimmed || trimmed.includes('$')) return apiKey; + return BARE_JWT_PATTERN.test(trimmed) ? `${DEVIN_SESSION_TOKEN_PREFIX}${trimmed}` : apiKey; +} + export interface MetadataInput { /** Persistent api_key from OAuth (`devin-session-token$`). */ apiKey: string; @@ -100,12 +127,14 @@ function osString(): string { export function buildMetadata(input: MetadataInput): Buffer { const version = input.windsurfVersion ?? WINDSURF_VERSION_STRING; const os = input.osName ?? osString(); + // One boundary, so no caller has to remember the prefix rule. + const apiKey = normalizeDevinSessionToken(input.apiKey); if (input.cloudChatShape) { const clientVersion = input.windsurfVersion ?? CLOUD_CHAT_CLIENT_VERSION; return Buffer.concat([ encodeString(1, CLOUD_CHAT_CLIENT_NAME), encodeString(2, clientVersion), - encodeString(3, input.apiKey), + encodeString(3, apiKey), encodeString(4, 'en'), encodeString(5, input.osName ?? CLOUD_CHAT_OS), encodeString(7, clientVersion), @@ -117,7 +146,7 @@ export function buildMetadata(input: MetadataInput): Buffer { const parts: Buffer[] = [ encodeString(1, 'windsurf'), // ide_name encodeString(2, version), // extension_version - encodeString(3, input.apiKey), // api_key + encodeString(3, apiKey), // api_key encodeString(4, 'en'), // locale encodeString(5, os), // os encodeString(7, version), // ide_version diff --git a/src/lib/redact.ts b/src/lib/redact.ts index f9e3e3ec6a..2206a9baa9 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -252,6 +252,13 @@ const SECRET_VALUE_PATTERNS: Array<[RegExp, string]> = [ [/((?:"(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|refreshToken|accessToken|clientSecret|apiKey)"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$3`], // Raw JSON "token" field values (Copilot token exchange bodies echo the credential here). [/(("token"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$4`], + // Cognition/Devin session keys, and the bare JWTs several providers hand out. + // A Connect EOS trailer can quote the request that carried the key, and the + // rules above only fire on a label — `Bearer`, `api_key=`, `"token":` — which + // a quoted proto field does not have. `eyJ` is the base64url of `{"`, so the + // JWT rule needs a real three-segment shape and does not match ordinary prose. + [/\bdevin-session-token\$[A-Za-z0-9._~+/=-]{8,}/g, REDACTED_SECRET], + [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g, REDACTED_SECRET], [/\b(arn:aws:[A-Za-z0-9_-]+:[A-Za-z0-9-]*:\d{12}:[A-Za-z0-9_/:+=,.@-]+)\b/g, REDACTED_SECRET], ]; diff --git a/src/oauth/devin-cli.ts b/src/oauth/devin-cli.ts index abb3cd14ab..78107d409d 100644 --- a/src/oauth/devin-cli.ts +++ b/src/oauth/devin-cli.ts @@ -70,10 +70,14 @@ export function devinCliCredentialsPath( if (override && (override.startsWith("/") || /^[A-Za-z]:[\\/]/.test(override))) return override; const paths = platform === "win32" ? win32 : posix; if (platform === "win32") { - const appData = env.APPDATA ?? paths.join(homedir(), "AppData", "Roaming"); + // `??` treats an empty APPDATA as set, and join("", "devin", …) is a path + // relative to whatever directory the proxy was started in — so a file planted + // there would import as the operator's own CLI session. An empty or + // whitespace-only value is an absent value. + const appData = env.APPDATA?.trim() || paths.join(homedir(), "AppData", "Roaming"); return paths.join(appData, "devin", "credentials.toml"); } - const dataHome = env.XDG_DATA_HOME ?? paths.join(homedir(), ".local", "share"); + const dataHome = env.XDG_DATA_HOME?.trim() || paths.join(homedir(), ".local", "share"); return paths.join(dataHome, "devin", "credentials.toml"); } @@ -83,30 +87,60 @@ export interface DevinCliCredentialFile { } /** - * Read the two keys that matter, and nothing else. + * Upper bound on the credential file we are willing to parse. * - * The measured file is four flat `key = "value"` lines: no tables, no comments, - * no single quotes. A line matcher is therefore enough and a TOML dependency is - * not, and the quoted form is required rather than optional — an unquoted - * matcher would pass its own fixtures and miss the real file. + * The measured file is four short lines. Reading an arbitrarily large file into + * a string and running two global-ish regexes over it is work we never need to + * do, and a file this size is not the CLI's. + */ +const DEVIN_CLI_CREDENTIALS_MAX_BYTES = 64 * 1024; + +/** + * Why the import has no credential, for the one error message the caller owns. * - * Returns undefined rather than throwing so the caller owns the one error - * message. Nothing here ever puts the file's contents into a thrown value. + * `missing` and `unreadable` used to collapse into the same `undefined`, so a + * permission error on an existing file was reported as "not signed in" and sent + * the operator to `devin auth login`, which does not fix it. */ -export function readDevinCliCredentialFile(deps: DevinCliLoginDeps = {}): DevinCliCredentialFile | undefined { +export type DevinCliCredentialOutcome = + | { kind: "ok"; file: DevinCliCredentialFile } + | { kind: "missing" } + | { kind: "unreadable" } + | { kind: "incomplete" }; + +export function readDevinCliCredentialOutcome(deps: DevinCliLoginDeps = {}): DevinCliCredentialOutcome { const path = devinCliCredentialsPath(deps.env, deps.platform); const exists = deps.exists ?? existsSync; - if (!exists(path)) return undefined; + if (!exists(path)) return { kind: "missing" }; let raw: string; try { raw = (deps.read ?? ((p: string) => readFileSync(p, "utf8")))(path); } catch { - return undefined; + // Nothing from the error is repeated: it carries the path, and an EACCES + // message is not worth the risk of echoing anything read off disk. + return { kind: "unreadable" }; } + if (raw.length > DEVIN_CLI_CREDENTIALS_MAX_BYTES) return { kind: "unreadable" }; const apiKey = raw.match(/^\s*windsurf_api_key\s*=\s*"([^"]+)"/m)?.[1]?.trim(); const apiServerUrl = raw.match(/^\s*api_server_url\s*=\s*"([^"]+)"/m)?.[1]?.trim(); - if (!apiKey || !apiServerUrl) return undefined; - return { apiKey, apiServerUrl }; + if (!apiKey || !apiServerUrl) return { kind: "incomplete" }; + return { kind: "ok", file: { apiKey, apiServerUrl } }; +} + +/** + * Read the two keys that matter, and nothing else. + * + * The measured file is four flat `key = "value"` lines: no tables, no comments, + * no single quotes. A line matcher is therefore enough and a TOML dependency is + * not, and the quoted form is required rather than optional — an unquoted + * matcher would pass its own fixtures and miss the real file. + * + * Returns undefined rather than throwing so the caller owns the one error + * message. Nothing here ever puts the file's contents into a thrown value. + */ +export function readDevinCliCredentialFile(deps: DevinCliLoginDeps = {}): DevinCliCredentialFile | undefined { + const outcome = readDevinCliCredentialOutcome(deps); + return outcome.kind === "ok" ? outcome.file : undefined; } /** True when a signed-in CLI credential is readable. Used for status, never for auth. */ @@ -119,16 +153,29 @@ export async function loginDevinCli( _opts?: DevinCliLoginOpts, deps: DevinCliLoginDeps = {}, ): Promise { - const file = readDevinCliCredentialFile(deps); - if (!file) { - // Deliberately names no path contents and no parsed value. A Connect error - // can echo a request, and redactSecretString does not recognise a bare JWT - // or a devin-session-token, which is why register-user.ts refuses to repeat - // error bodies; the same caution applies to anything thrown from here. + const outcome = readDevinCliCredentialOutcome(deps); + // Each branch deliberately names no path contents and no parsed value. A + // Connect error can echo a request, and redactSecretString does not recognise + // a bare JWT or a devin-session-token, which is why register-user.ts refuses + // to repeat error bodies; the same caution applies to anything thrown here. + if (outcome.kind === "unreadable") { + // The file is there and we could not read it, so `devin auth login` is the + // wrong instruction: it would succeed and change nothing. + throw new Error( + "Found a Devin CLI credential file but could not read it. Check its permissions and size, then try again.", + ); + } + if (outcome.kind === "incomplete") { + throw new Error( + "The Devin CLI credential file is missing its session key or API server URL. Run `devin auth login` again to rewrite it.", + ); + } + if (outcome.kind === "missing") { throw new Error( `No signed-in Devin CLI session found. ${DEVIN_CLI_INSTALL_HINT} Then run \`devin auth login\` and try again.`, ); } + const file = outcome.file; // The host comes off disk and then receives the key, so it passes the same // allowlist as the RegisterUser host. An unallowlisted value falls back to the // default rather than becoming an exfiltration target. diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 89f80e5f88..480e8c7842 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -78,6 +78,24 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C import type { ManagementContext } from "./context"; import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; + +/** + * Provider ids that share the Devin cloud-direct client, and therefore share its + * process-memory caches. + * + * `devin` signs in through RegisterUser and `devin-cli` imports a signed-in local + * CLI session, but both hand the same api_key to the same client, so one cache + * serves both and one of them clearing it is not enough. + */ +function isDevinCloudDirectProvider(provider: string): boolean { + return provider === "devin" || provider === "devin-cli"; +} + +async function clearDevinCloudDirectCaches(): Promise { + const { clearCachedUserJwt, clearCachedCatalog } = await import("../../adapters/devin/cloud-direct"); + clearCachedUserJwt(); + clearCachedCatalog(); +} import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../oauth/account-import"; import { readBoundedJsonRequestBody } from "../request-decompress"; @@ -255,14 +273,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); - if (provider === "devin") { - // The cached user_jwt's payload contains the api_key, and the catalog is - // keyed by that key. Without this they outlive the credential in process - // memory until the JWT's own ~24 minute expiry. - const { clearCachedUserJwt, clearCachedCatalog } = await import("../../adapters/devin/cloud-direct"); - clearCachedUserJwt(); - clearCachedCatalog(); - } + // The cached user_jwt's payload contains the api_key, and the catalog is + // keyed by that key. Without this they outlive the credential in process + // memory until the JWT's own ~24 minute expiry. `devin` and `devin-cli` + // share one cache, so gating on `devin` alone left a CLI-imported key's JWT + // resident after its own logout. + if (isDevinCloudDirectProvider(provider)) await clearDevinCloudDirectCaches(); return jsonResponse({ success: true }); } @@ -685,6 +701,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); clearAccountQuotaCache(provider); + // Same reasoning as logout. Removing the last account for a provider used to + // leave the JWT and catalog in memory, because only the logout route cleared + // them. + if (isDevinCloudDirectProvider(provider)) await clearDevinCloudDirectCaches(); return jsonResponse({ ok: true }); } diff --git a/tests/lib/redact.test.ts b/tests/lib/redact.test.ts index 26a4912a27..f15e4aa30d 100644 --- a/tests/lib/redact.test.ts +++ b/tests/lib/redact.test.ts @@ -550,3 +550,26 @@ test("redact-folding folds colon confusables with aligned offsets and stays a ze const source = readFileSync(repoPath("src/lib/redact-folding.ts"), "utf8"); expect(source).not.toMatch(/^import /m); }); + +describe("bare credential shapes with no label to key off", () => { + test("a Devin session token is masked wherever it appears", () => { + // A Connect EOS trailer can quote the request that carried the key, and the + // labelled rules never fire on a quoted proto field. + const token = "devin-session-token$eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.c2ln"; + const masked = redactSecretString(`permission_denied: api_key ${token} was rejected`); + expect(masked).not.toContain("devin-session-token$eyJ"); + expect(masked).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + }); + + test("a bare JWT is masked, and ordinary prose is not", () => { + const masked = redactSecretString("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2lnbmF0dXJl here"); + expect(masked).not.toContain("eyJhbGciOiJIUzI1NiJ9"); + for (const benign of [ + "version 1.2.3 shipped", + "see src/lib/redact.ts for the rules", + "a.b.c", + ]) { + expect(redactSecretString(benign)).toBe(benign); + } + }); +}); diff --git a/tests/providers/devin-cli-login.test.ts b/tests/providers/devin-cli-login.test.ts index ba82c033f1..766e28eca2 100644 --- a/tests/providers/devin-cli-login.test.ts +++ b/tests/providers/devin-cli-login.test.ts @@ -5,6 +5,7 @@ import { devinCliSignedIn, loginDevinCli, readDevinCliCredentialFile, + readDevinCliCredentialOutcome, refreshDevinCliToken, } from "../../src/oauth/devin-cli"; import { resolveDevinApiServer } from "../../src/oauth/devin"; @@ -135,3 +136,74 @@ describe("devin tenant selection is provider-scoped", () => { }); }); + +describe("devin-cli credential path and read bounds", () => { + const okFile = [ + 'windsurf_api_key = "devin-session-token$eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.sig"', + 'api_server_url = "https://server.codeium.com"', + "", + ].join("\n"); + + test("an empty XDG_DATA_HOME or APPDATA does not become a cwd-relative path", () => { + // `??` treated "" as set, so join("", "devin", …) resolved against whatever + // directory the proxy was started in, and a planted file there would import + // as the operator's own CLI session. + // The fallback reads the real home directory rather than env.HOME, so the + // assertion is on shape: absolute, and under the home data dir. + for (const empty of ["", " "]) { + const resolved = devinCliCredentialsPath({ HOME: "/home/u", XDG_DATA_HOME: empty }, "linux"); + expect(resolved.startsWith("/")).toBe(true); + expect(resolved.endsWith("/.local/share/devin/credentials.toml")).toBe(true); + } + const win = devinCliCredentialsPath({ APPDATA: "" }, "win32"); + expect(win.endsWith("AppData\\Roaming\\devin\\credentials.toml")).toBe(true); + expect(win.startsWith("devin")).toBe(false); + }); + + test("a present-but-unreadable file is not reported as a missing sign-in", async () => { + const deps = { + env: { HOME: "/home/u", XDG_DATA_HOME: "/home/u/.local/share" }, + platform: "linux" as NodeJS.Platform, + exists: () => true, + read: () => { throw new Error("EACCES: permission denied"); }, + }; + expect(readDevinCliCredentialOutcome(deps)).toEqual({ kind: "unreadable" }); + // "run devin auth login" would succeed and change nothing, so the two + // outcomes must not share one message. + await expect(loginDevinCli({} as OAuthController, undefined, deps)).rejects.toThrow(/could not read it/); + await expect(loginDevinCli({} as OAuthController, undefined, { ...deps, exists: () => false })) + .rejects.toThrow(/No signed-in Devin CLI session/); + }); + + test("a file past the parse bound is refused rather than scanned", () => { + const deps = { + env: { HOME: "/home/u", XDG_DATA_HOME: "/home/u/.local/share" }, + platform: "linux" as NodeJS.Platform, + exists: () => true, + read: () => okFile + "#".repeat(64 * 1024), + }; + expect(readDevinCliCredentialOutcome(deps).kind).toBe("unreadable"); + expect(readDevinCliCredentialFile(deps)).toBeUndefined(); + }); + + test("a file with only one of the two keys names the incomplete case", () => { + const deps = { + env: { HOME: "/home/u", XDG_DATA_HOME: "/home/u/.local/share" }, + platform: "linux" as NodeJS.Platform, + exists: () => true, + read: () => 'api_server_url = "https://server.codeium.com"\n', + }; + expect(readDevinCliCredentialOutcome(deps).kind).toBe("incomplete"); + }); + + test("no thrown message repeats the key", async () => { + const deps = { + env: { HOME: "/home/u", XDG_DATA_HOME: "/home/u/.local/share" }, + platform: "linux" as NodeJS.Platform, + exists: () => true, + read: () => { throw new Error("EACCES"); }, + }; + const err = await loginDevinCli({} as OAuthController, undefined, deps).catch((e: unknown) => e); + expect(String(err)).not.toContain("devin-session-token"); + }); +}); diff --git a/tests/providers/devin-hardening.test.ts b/tests/providers/devin-hardening.test.ts index 07a5302ce7..c793162ce3 100644 --- a/tests/providers/devin-hardening.test.ts +++ b/tests/providers/devin-hardening.test.ts @@ -6,6 +6,14 @@ import { registerUser } from "../../src/oauth/devin/register-user"; import { anySignal } from "../../src/lib/abort"; import { buildGetChatMessageRequestForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { buildMetadata, normalizeDevinSessionToken } from "../../src/adapters/devin/cloud-direct/metadata"; + +/** Tag -> field for one encoded proto message. */ +function iterFieldMap(buf: Buffer): Record { + const out: Record = {}; + for (const f of iterFields(buf)) out[f.num] = { wire: f.wire, value: f.value }; + return out; +} const FAKE_TOKEN = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEifQ.c2lnbmF0dXJl"; @@ -247,3 +255,28 @@ describe("devin cloud request shape", () => { expect((metadata[31]?.value as Buffer).length).toBe(732); }); }); + +describe("devin session-token normalization", () => { + test("a bare JWT regains the prefix the service reads", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.sig"; + expect(normalizeDevinSessionToken(jwt)).toBe("devin-session-token$" + jwt); + // Without this, the key goes out verbatim and Cognition answers with an + // opaque permission_denied, which reads as a revoked account. + const metadata = iterFieldMap(buildMetadata({ + apiKey: jwt, requestId: 1, sessionId: "s", triggerId: "t", cloudChatShape: true, + })); + expect((metadata[3]?.value as Buffer).toString("utf8")).toBe("devin-session-token$" + jwt); + }); + + test("every other key format this field has carried passes through untouched", () => { + for (const key of [ + "devin-session-token$eyJhbGciOiJIUzI1NiJ9.eyJhIjoxfQ.sig", + "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "sk-ws-01-abcdef", + "cog_abcdef", + "", + ]) { + expect(normalizeDevinSessionToken(key)).toBe(key); + } + }); +}); From 7b3c4e9800233d120be43e122c94e9cbe5f38414 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:15:46 +0900 Subject: [PATCH 15/17] feat(usage): show the cached subset beside every token total (#4421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cached request's total is mostly cache. A 58,000-token prompt that is 57,000 cache read and 1,000 fresh was printed as a bare 58,000 on every surface except the logs table row, so it read as a different, smaller request than the row directly beside it. formatTokensWithCache renders the total with its cached companion — 5.8만 c5.7만, 58K c57K — reusing the marker the logs.tokens.cacheRead label already documents as "cache read (c)", so no new i18n key is needed. A provider that reports no cache is untouched, and a turn served entirely from cache still shows the marker, since that is the row worth seeing. No backend change: /api/logs forwards the whole usage object and /api/usage already emits cache on summary, models and providers. The loss was purely client-side, in the row types and the aggregators, so the fix widens UsageModel, UsageProvider, UsageSummary30d, the CLI CostRow, and summarizeFilteredLogs to keep the fields that were already arriving. The log detail panel is deliberately left alone: it already has separate cache read and cache write cells, and stacking the companion onto its total would duplicate them. --- .../cached-token-companion.jpg | Bin 0 -> 18950 bytes gui/src/format-tokens.ts | 21 ++++++++++ gui/src/pages/Logs.tsx | 15 +++++++- gui/src/pages/Usage.tsx | 12 ++++-- gui/src/pages/dashboard-overview-head.tsx | 10 ++++- gui/src/pages/dashboard-shared.ts | 12 +++++- scripts/test-layout/layout.json | 1 + src/cli/usage-report.ts | 36 ++++++++++++++++-- tests/cli/cli-usage-report.test.ts | 25 ++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/gui/gui-format-tokens-cache.test.ts | 29 ++++++++++++++ 11 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260912_devin_hardening/cached-token-companion.jpg create mode 100644 tests/gui/gui-format-tokens-cache.test.ts diff --git a/devlog/_plan/260912_devin_hardening/cached-token-companion.jpg b/devlog/_plan/260912_devin_hardening/cached-token-companion.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2110f1f8ed0d5a4c32265e344a509836c151305f GIT binary patch literal 18950 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, so a + // total with no companion read as a different, smaller figure than the rows + // it summarizes. + const read = cacheSplit(entry).read; + if (read !== undefined && read > 0) cachedInputTokens += read; } return { requests: entries.length, totalTokens, + cachedInputTokens, ...summarizeEstimatedCosts(entries), }; } @@ -687,7 +696,11 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.conversation.totals", { requests: conversationTotals.requests, - tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale), + tokens: formatTokensWithCache( + conversationTotals.totalTokens, + conversationTotals.cachedInputTokens, + localeTag ?? locale, + ), cost: formatEstimatedUsdValue( conversationTotals.estimatedCostUsd, t, diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 96f0f1db0c..44028824b2 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; -import { formatTokens } from "../format-tokens"; +import { formatTokens, formatTokensWithCache } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { EmptyState, Notice } from "../ui"; @@ -64,6 +64,10 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + // /api/usage has carried these all along; dropping them from the row type is + // what left the token column without its cached companion. + cachedInputTokens?: number; + cacheReadInputTokens?: number; shareRatio: number; } @@ -74,6 +78,8 @@ interface UsageProvider { reportedRequests: number; estimatedRequests: number; totalTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; shareRatio: number; } @@ -561,7 +567,7 @@ function UsageModelsTable({ {formatProviderDisplayName(model.provider, t)} {model.requests} {model.measuredRequests} - {formatTokens(model.totalTokens, locale)} + {formatTokensWithCache(model.totalTokens, model.cacheReadInputTokens ?? model.cachedInputTokens, locale)}
))} @@ -621,7 +627,7 @@ function UsageProvidersTable({ {formatProviderDisplayName(provider.provider, t)} {provider.requests} {provider.measuredRequests} - {formatTokens(provider.totalTokens, locale)} + {formatTokensWithCache(provider.totalTokens, provider.cacheReadInputTokens ?? provider.cachedInputTokens, locale)}
))} diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 44b3f8ac3c..fea1ae94e9 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -1,6 +1,6 @@ import { IconAlert, IconInfo } from "../icons"; import { type TKey, useT } from "../i18n/shared"; -import { formatTokens } from "../format-tokens"; +import { formatTokensWithCache } from "../format-tokens"; import { formatUptime } from "../formatUptime"; import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; @@ -81,7 +81,13 @@ export function DashboardOverviewHead({
{t("dash.providers")}
{providers.length}
{t("dash.tokens30d")}
-
{usage30d && usage30d.summary.requests > 0 ? formatTokens(usage30d.summary.totalTokens, locale) : "—"}
+
{usage30d && usage30d.summary.requests > 0 + ? formatTokensWithCache( + usage30d.summary.totalTokens, + usage30d.summary.cacheReadInputTokens ?? usage30d.summary.cachedInputTokens, + locale, + ) + : "—"}
{usage30d && usage30d.summary.requests > 0 ? t("dash.coverage").replace("{pct}", `${Math.round(usage30d.summary.coverageRatio * 100)}%`) diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 029e39b2da..cd0f7ac66d 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -123,7 +123,17 @@ export interface SidecarPatch { }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export interface UsageSummary30d { + summary: { + requests: number; + totalTokens: number; + coverageRatio: number; + // Already on /api/usage; the tile showed a bare total only because this + // type dropped them. + cachedInputTokens?: number; + cacheReadInputTokens?: number; + }; +} export type UpdateChannel = "latest" | "preview"; export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 33b960ac70..18fc2060c0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -718,6 +718,7 @@ "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", + "gui-format-tokens-cache.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 3311a781a1..2d2c525cde 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -17,6 +17,13 @@ interface CostRow { model?: string; requests: number; totalTokens: number; + /** + * Cache-read subset of the row's tokens. The API sends it; the CLI dropped it, + * so a mostly-cached provider's TOKENS column read as an ordinary total while + * the summary line two rows above already said `cached N`. + */ + cachedInputTokens?: number; + cacheReadInputTokens?: number; estimatedCostUsd?: number; } @@ -53,6 +60,8 @@ interface UsageReportInput { ambiguous?: boolean; requests: number; totalTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; estimatedCostUsd?: number; }[]; } @@ -76,6 +85,16 @@ function count(value: number | undefined): string { return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—"; } +/** + * A row's token total with its cache-read subset, matching the summary line's + * `cached N` wording rather than inventing a second vocabulary for the tables. + */ +function countWithCache(total: number | undefined, cached: number | undefined): string { + const base = count(total); + if (typeof cached !== "number" || !Number.isFinite(cached) || cached <= 0) return base; + return `${base} (cached ${count(cached)})`; +} + /** * Matches the dashboard's `~$` with four fraction digits. Estimates below a * hundredth of a cent still read as a number rather than collapsing to $0.00, @@ -141,7 +160,12 @@ export function formatUsageReport(data: UsageReportInput): string[] { lines.push(""); lines.push(...table( ["PROVIDER", "REQUESTS", "TOKENS", "EST. COST"], - providers.map(row => [row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]), + providers.map(row => [ + row.provider, + count(row.requests), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), + usd(row.estimatedCostUsd), + ]), )); } @@ -163,7 +187,7 @@ export function formatUsageReport(data: UsageReportInput): string[] { // wrong conclusion. Mark it rather than presenting it as a single identity. row.ambiguous ? `${terminalText(row.accountLogLabel)} (ambiguous)` : terminalText(row.accountLogLabel), count(row.requests), - count(row.totalTokens), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), usd(row.estimatedCostUsd), ]), )); @@ -175,7 +199,13 @@ export function formatUsageReport(data: UsageReportInput): string[] { const shown = models.slice(0, MAX_MODEL_ROWS); lines.push(...table( ["MODEL", "PROVIDER", "REQUESTS", "TOKENS", "EST. COST"], - shown.map(row => [row.model ?? "-", row.provider, count(row.requests), count(row.totalTokens), usd(row.estimatedCostUsd)]), + shown.map(row => [ + row.model ?? "-", + row.provider, + count(row.requests), + countWithCache(row.totalTokens, row.cacheReadInputTokens ?? row.cachedInputTokens), + usd(row.estimatedCostUsd), + ]), )); if (models.length > shown.length) { lines.push(`... ${models.length - shown.length} more (use --json)`); diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 5399137a57..1be03f68f2 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -404,3 +404,28 @@ describe("ocx logs --follow output contract", () => { } }); }); + +describe("cached tokens in the per-row tables", () => { + test("a provider and model row name their cache-read subset", () => { + // The summary line has always said "cached N". The tables below it printed a + // bare total, so a mostly-cached provider looked like an ordinary one. + const lines = formatUsageReport({ + range: "today", + summary: { requests: 1, totalTokens: 58_000, cachedInputTokens: 57_000 }, + providers: [{ provider: "devin-cli", requests: 1, totalTokens: 58_000, cacheReadInputTokens: 57_000 }], + models: [{ provider: "devin-cli", model: "swe-2", requests: 1, totalTokens: 58_000, cachedInputTokens: 57_000 }], + }).join("\n"); + expect(lines).toContain("58,000 (cached 57,000)"); + expect(lines.match(/cached 57,000/g)?.length).toBeGreaterThanOrEqual(2); + }); + + test("a provider that reports no cache keeps a bare total", () => { + const lines = formatUsageReport({ + range: "today", + summary: { requests: 1, totalTokens: 58_000 }, + providers: [{ provider: "xai", requests: 1, totalTokens: 58_000 }], + }).join("\n"); + expect(lines).toContain("58,000"); + expect(lines).not.toContain("cached"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9ea5f32928..5e57040565 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -549,6 +549,7 @@ "grok-sync.test.ts": "providers/xai", "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", + "gui-format-tokens-cache.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", diff --git a/tests/gui/gui-format-tokens-cache.test.ts b/tests/gui/gui-format-tokens-cache.test.ts new file mode 100644 index 0000000000..fe38fb81da --- /dev/null +++ b/tests/gui/gui-format-tokens-cache.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { formatTokens, formatTokensWithCache } from "../../gui/src/format-tokens"; + +/** + * A cached request's total is mostly cache. The logs table has always shown the + * total with a stacked `c `; every other surface printed the total alone, + * which reads as a different, smaller request than the rows beside it. + */ +describe("formatTokensWithCache", () => { + test("renders the cached subset beside the total in both number scales", () => { + expect(formatTokensWithCache(58_000, 57_000, "ko")).toBe("5.8만 c5.7만"); + expect(formatTokensWithCache(58_000, 57_000, "en")).toBe("58K c57K"); + expect(formatTokensWithCache(58_000, 57_000, "zh")).toBe("5.8万 c5.7万"); + }); + + test("a provider that reports no cache is left exactly as it was", () => { + for (const cached of [undefined, 0, Number.NaN]) { + expect(formatTokensWithCache(58_000, cached, "ko")).toBe(formatTokens(58_000, "ko")); + } + // A negative count is nonsense rather than a cache miss; treat it as absent. + expect(formatTokensWithCache(58_000, -1, "en")).toBe("58K"); + }); + + test("a turn served entirely from cache still shows the marker", () => { + // This is the most cached row on the page. Hiding the companion when the + // subset equals the total would blank exactly the case worth showing. + expect(formatTokensWithCache(57_000, 57_000, "en")).toBe("57K c57K"); + }); +}); From 1e28e62360a093b663e3d652eee2044f83584cfb Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:26:22 +0900 Subject: [PATCH 16/17] fix(devin): read usage from ModelUsageStats and classify cloud failures (#4419) * fix(devin): read usage from ModelUsageStats and classify cloud failures A cached Devin turn reported a bare token total with no cached subset, so its log row looked like a smaller request than it was. The decoder was reading GetChatMessageResponse field 28. Field 28 is response_dimension_groups, the rows the IDE renders; field 7 is ModelUsageStats, the per-turn accounting. The old path worked by accident: ResponseDimension.uid is that message's field 5, which the entry walker treats as a metric id, so cache numbers appeared only when the service happened to render cache rows. Field 7 carries cache read and cache write unconditionally. Both fields arrive in the same message and the adapter keeps the last usage event, so decoding both is not enough: field 7 now suppresses field 28 within a message and is yielded last, and it needs its own uint64 decoder because the field-28 walker reads a fixed32 float out of a sub-message. Whether Cognition's input_tokens already includes cache is unsettled, and guessing inclusive is the expensive error: normalizeCostTokens only rejects read + write > input, so an inflated input passes validation and bills cached tokens at the uncached rate. The mapping is therefore derived from the frame. Both branches agree on the 58k-prompt case that prompted this. Two further classification defects. CloudChatError carried no HTTP status, so inferHttpStatusFromAdapterMessage turned an upstream 429 into a 502 and core's failover never rotated or backed off. And a cancelled turn said "Devin turn was aborted.", which isClientClosedMessage does not recognise, so a client hanging up was logged as an upstream failure; it now emits the phrase the classifier knows, with status 499. Usage frames are merged per field instead of replaced, because the counters are cumulative and a later partial frame used to zero an earlier count. * fix(devin): report field-7 usage ahead of finish and derive the merged total Review follow-ups on the usage decode. The authoritative ModelUsageStats event was yielded after the rest of the frame, so a frame that also carried finish reported usage behind the turn's end. It is now yielded first, which makes the order independent of where the service places the field. mergeDevinUsage took the max of two totals alongside the per-field maxima, which can leave totalTokens different from input + output; the cost and log paths read that total. The total is now derived from the merged counts. Regression coverage for what the change is actually for: field 7 suppressing the display rows within one frame and landing before finish, the display rows still decoding when no field 7 is present, a partial frame not zeroing an earlier count, and an HTTP status becoming a structured classification. A Connect trailer still carries no HTTP status, so a cap delivered that way keeps the older message-inference path. That is noted at the throw site as a follow-up rather than silently left open. --- src/adapters/devin.ts | 70 ++++++++++++-- src/adapters/devin/cloud-direct/chat.ts | 108 +++++++++++++++++++++- tests/providers/devin-hardening.test.ts | 118 ++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 9 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 12d9ab7ee2..5bbd65c480 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -13,6 +13,53 @@ import { getCachedCatalog } from "./devin/cloud-direct/catalog"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; +/** + * Combine two usage frames from one turn by keeping the larger count per field. + * + * Devin's counters are cumulative within a turn, so a frame that reports less + * than an earlier one is reporting a subset, not a correction. + */ +export function mergeDevinUsage(previous: OcxUsage, next: OcxUsage): OcxUsage { + const keys = [ + "inputTokens", "outputTokens", + "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", + "reasoningOutputTokens", + ] as const; + const merged: OcxUsage = { ...previous, ...next }; + for (const key of keys) { + const a = previous[key]; + const b = next[key]; + if (typeof a === "number" && typeof b === "number") merged[key] = Math.max(a, b); + else if (typeof a === "number" && b === undefined) merged[key] = a; + } + // totalTokens is derived, not merged. Taking the max of two totals alongside + // per-field maxima can leave total !== input + output, and the cost and log + // paths read the total. + const total = (merged.inputTokens ?? 0) + (merged.outputTokens ?? 0); + if (total > 0) merged.totalTokens = total; + return merged; +} + +/** + * The wording `isClientClosedMessage` recognises. + * + * "Devin turn was aborted." matched nothing, so a cancelled turn fell through to + * the default inference and was logged as a 502 upstream failure rather than as + * the client hanging up. + */ +const DEVIN_CLIENT_CLOSED_MESSAGE = "client closed request"; + +/** Map a cloud-direct failure onto the structured fields the error event carries. */ +export function devinErrorClassification(error: unknown): { status?: number; errorType?: string; retryable?: boolean } { + const status = error instanceof CloudChatError ? error.status : undefined; + if (status === undefined) return {}; + if (status === 401) return { status, errorType: "authentication_error", retryable: false }; + if (status === 403) return { status, errorType: "permission_error", retryable: false }; + if (status === 429) return { status, errorType: "rate_limit_error", retryable: true }; + if (status >= 500) return { status, retryable: true }; + return { status, retryable: false }; +} + export const DEVIN_API_SERVER = DEVIN_DEFAULT_API_SERVER; const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]); @@ -210,7 +257,7 @@ export function createDevinAdapter( async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) { if (incoming.abortSignal?.aborted) { - emit({ type: "error", message: "Devin turn was aborted before start." }); + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false }); return; } let apiKey: string; @@ -272,7 +319,7 @@ export function createDevinAdapter( // Say what happened instead, the way the other runTurn-only adapter // does, and carry any usage already seen. closeOpenTool(); - emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false, ...(usage ? { usage } : {}) }); return; } if (event.kind === "text") { @@ -305,7 +352,7 @@ export function createDevinAdapter( } if (event.kind === "usage") { const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0)); - usage = { + const next: OcxUsage = { inputTokens: event.promptTokens ?? 0, outputTokens: event.completionTokens ?? 0, ...(total > 0 ? { totalTokens: total } : {}), @@ -313,19 +360,24 @@ export function createDevinAdapter( ...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}), ...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}), }; + // Merge rather than replace. A turn can carry more than one usage + // frame, and the counters are cumulative, so a later partial frame + // that omits a field used to zero a count the earlier frame had + // already reported. + usage = usage ? mergeDevinUsage(usage, next) : next; continue; } } closeOpenTool(); if (incoming.abortSignal?.aborted) { - emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false, ...(usage ? { usage } : {}) }); } else { emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) }); } } catch (error) { closeOpenTool(); if (incoming.abortSignal?.aborted) { - emit({ type: "error", message: "Devin turn was aborted.", ...(usage ? { usage } : {}) }); + emit({ type: "error", message: DEVIN_CLIENT_CLOSED_MESSAGE, status: 499, retryable: false, ...(usage ? { usage } : {}) }); return; } const message = error instanceof CloudChatError @@ -333,7 +385,13 @@ export function createDevinAdapter( : error instanceof Error ? error.message : String(error); // Usage that already arrived is still real; dropping it loses the // accounting for a turn that did most of its work before failing. - emit({ type: "error", message, ...(usage ? { usage } : {}) }); + emit({ + type: "error", + message, + ...devinErrorClassification(error), + ...(error instanceof CloudChatError && error.code ? { code: error.code } : {}), + ...(usage ? { usage } : {}), + }); } }, }; diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 334156daf9..2c7b7c32a1 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -669,7 +669,30 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { * any non-zero to 'tool_calls' for now (and let the caller fall back to * 'stop' if no tool_call deltas were emitted). */ -function* decodeChatFrame(proto: Buffer): Generator { +export function* decodeChatFrame(proto: Buffer): Generator { + // Field 7 is `ModelUsageStats`, the authoritative per-turn accounting, and + // field 28 is `response_dimension_groups` — the rows the IDE renders. The + // decoder below reads 28 because a capture happened to expose metric-looking + // strings there (`ResponseDimension.uid` is its field 5, which is what the + // entry walker treats as `metric_id`), and that works only when the service + // chose to render cache rows. Field 7 carries cache read and cache write + // unconditionally, which is why a cached Devin turn used to report a bare + // total with no cached subset. + // + // Both fields arrive in the same message and the adapter keeps the last usage + // event it sees, so this cannot be a plain "decode both": field 7 has to + // suppress field 28 within the message. It is yielded before the rest of the + // frame rather than after it, so a frame that also carries finish (field 5) + // still reports usage ahead of the turn's end, and the order does not depend + // on where the service happens to place the field. + let authoritativeUsage: CloudChatEvent | null = null; + for (const f of iterFields(proto)) { + if (f.num === 7 && f.wire === 2 && Buffer.isBuffer(f.value)) { + authoritativeUsage = decodeModelUsageStats(f.value as Buffer); + if (authoritativeUsage) break; + } + } + if (authoritativeUsage) yield authoritativeUsage; for (const f of iterFields(proto)) { if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) { // Visible delta_text — what the user should SEE in the chat. @@ -740,6 +763,7 @@ function* decodeChatFrame(proto: Buffer): Generator { // else stays 'stop' for 0/2/4-9/12/13 yield { kind: 'finish', reason }; } else if (f.num === 28 && f.wire === 2 && Buffer.isBuffer(f.value)) { + if (authoritativeUsage) continue; const usage = decodeUsageBlock(f.value as Buffer); if (usage) yield usage; } @@ -834,6 +858,68 @@ function decodeUsageBlock(buf: Buffer): CloudChatEvent | null { }; } +/** + * `exa.codeium_common_pb.ModelUsageStats` at GetChatMessageResponse field 7. + * + * ModelUsageStats { + * #2 input_tokens uint64 + * #3 output_tokens uint64 + * #4 cache_write_tokens uint64 + * #5 cache_read_tokens uint64 + * } + * + * Plain varints, so the field-28 entry walker — which descends a + * length-delimited sub-message and reads a fixed32 float — cannot read this at + * all. It needs its own decoder. + * + * Whether Cognition's `input_tokens` already includes the cached tokens is not + * settled. oh-my-pi sums all four into its total, which suggests exclusive, but + * that is their convention rather than a measurement of this field. Guessing + * wrong in the inclusive direction is the expensive mistake: `normalizeCostTokens` + * only rejects `read + write > input`, so an inflated input passes validation and + * bills cached tokens at the uncached rate. + * + * So the shape is derived from the frame instead of assumed. An input that + * already covers the cache is left alone; one that cannot possibly cover it is + * folded. Both branches agree on the case that motivated this — a 58k prompt + * that is 57k cache read and 1k fresh reads as 58k with a 57k cached subset — + * and neither can emit `read + write > input`. Replace the derivation with a + * fixed mapping once a live frame settles the question. + */ +export function decodeModelUsageStats(buf: Buffer): CloudChatEvent | null { + let wireInput: number | undefined; + let output: number | undefined; + let cacheWrite: number | undefined; + let cacheRead: number | undefined; + for (const f of iterFields(buf)) { + if (f.wire !== 0) continue; + const n = Number(f.value); + if (!Number.isFinite(n) || n < 0) continue; + if (f.num === 2) wireInput = n; + else if (f.num === 3) output = n; + else if (f.num === 4) cacheWrite = n; + else if (f.num === 5) cacheRead = n; + } + if (wireInput === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined) { + return null; + } + const read = cacheRead ?? 0; + const write = cacheWrite ?? 0; + const rawInput = wireInput ?? 0; + const promptTokens = rawInput >= read + write ? rawInput : rawInput + read + write; + const completionTokens = output ?? 0; + const total = promptTokens + completionTokens; + return { + kind: 'usage', + promptTokens, + completionTokens, + totalTokens: total > 0 ? total : undefined, + cachedInputTokens: cacheRead, + cacheCreationInputTokens: cacheWrite, + reasoningTokens: undefined, + }; +} + // ---------------------------------------------------------------------------- // Public API: streamChat // ---------------------------------------------------------------------------- @@ -864,7 +950,18 @@ export interface CloudChatRequest { } export class CloudChatError extends Error { - constructor(message: string, public readonly code?: string, public readonly traceId?: string) { + constructor( + message: string, + public readonly code?: string, + public readonly traceId?: string, + /** + * Upstream HTTP status, when the failure was a status line rather than a + * Connect trailer. Without it the adapter's message reaches + * `inferHttpStatusFromAdapterMessage`, which does not parse `HTTP 429`, so + * a live rate limit was classified 502 and core's failover never rotated. + */ + public readonly status?: number, + ) { super(message); this.name = 'CloudChatError'; } @@ -987,7 +1084,12 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { } }); }); + +describe("devin ModelUsageStats decode (response field 7)", () => { + function varint(num: number, value: number): Buffer { + const out: number[] = [(num << 3) | 0]; + let v = value; + do { const b = v & 0x7f; v = Math.floor(v / 128); out.push(v > 0 ? b | 0x80 : b); } while (v > 0); + return Buffer.from(out); + } + const stats = (input: number, output: number, write: number, read: number) => + Buffer.concat([varint(2, input), varint(3, output), varint(4, write), varint(5, read)]); + + test("an exclusive frame folds cache into the inclusive input this repo reports", () => { + // 1k fresh + 57k cache read is the 58k prompt the user sees as one number. + const u = decodeModelUsageStats(stats(1_000, 200, 0, 57_000)); + expect(u?.promptTokens).toBe(58_000); + expect(u?.cachedInputTokens).toBe(57_000); + expect(u?.totalTokens).toBe(58_200); + }); + + test("an already-inclusive frame is left alone rather than inflated", () => { + const u = decodeModelUsageStats(stats(58_000, 200, 0, 57_000)); + expect(u?.promptTokens).toBe(58_000); + expect(u?.cachedInputTokens).toBe(57_000); + // normalizeCostTokens only rejects read + write > input, so an inflated + // input would pass validation and bill cache at the uncached rate. + expect(u!.cachedInputTokens! + (u!.cacheCreationInputTokens ?? 0)).toBeLessThanOrEqual(u!.promptTokens!); + }); + + test("cache write counts as prompt too, and an empty message decodes to nothing", () => { + const u = decodeModelUsageStats(stats(1_000, 0, 4_000, 0)); + expect(u?.promptTokens).toBe(5_000); + expect(u?.cacheCreationInputTokens).toBe(4_000); + expect(decodeModelUsageStats(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("devin frame-level usage precedence and classification", () => { + // Tags above 15 need a multi-byte varint: field 28 wire 2 is 226, and + // writing that as one raw byte sets the continuation bit and swallows the + // next byte. + function uvarint(value: number): number[] { + const out: number[] = []; + let v = value; + do { const b = v & 0x7f; v = Math.floor(v / 128); out.push(v > 0 ? b | 0x80 : b); } while (v > 0); + return out; + } + function varint(num: number, value: number): Buffer { + return Buffer.from([...uvarint((num << 3) | 0), ...uvarint(value)]); + } + function lenDelim(num: number, payload: Buffer): Buffer { + return Buffer.concat([Buffer.from([...uvarint((num << 3) | 2), ...uvarint(payload.length)]), payload]); + } + // ResponseDimensionGroup carrying a cumulative metric whose uid reads like a + // metric id — the shape the old decoder mined for usage. + function displayGroup(uid: string, value: number): Buffer { + const f32 = Buffer.alloc(5); + f32.writeUInt8((2 << 3) | 5, 0); + f32.writeFloatLE(value, 1); + const entry = Buffer.concat([lenDelim(4, f32), lenDelim(5, Buffer.from(uid, "utf8"))]); + return lenDelim(2, entry); + } + + test("field 7 suppresses the display rows and is reported before finish", () => { + const stats = Buffer.concat([varint(2, 1_000), varint(3, 200), varint(4, 0), varint(5, 57_000)]); + const frame = Buffer.concat([ + lenDelim(7, stats), + varint(5, 2), // stop_reason STOP_PATTERN + lenDelim(28, displayGroup("input_tokens", 999)), // the wrong, display-derived number + ]); + const events = [...decodeChatFrame(frame)]; + const usages = events.filter(e => e.kind === "usage"); + expect(usages).toHaveLength(1); + expect(usages[0]!.promptTokens).toBe(58_000); + expect(usages[0]!.cachedInputTokens).toBe(57_000); + // Ahead of finish, so ordering does not depend on where the service puts + // the field. + expect(events.findIndex(e => e.kind === "usage")) + .toBeLessThan(events.findIndex(e => e.kind === "finish")); + }); + + test("a frame with no field 7 still falls back to the display rows", () => { + const frame = lenDelim(28, Buffer.concat([ + displayGroup("input_tokens", 4_000), + displayGroup("output_tokens", 100), + ])); + const usages = [...decodeChatFrame(frame)].filter(e => e.kind === "usage"); + expect(usages).toHaveLength(1); + expect(usages[0]!.promptTokens).toBe(4_000); + }); +}); + +describe("devin usage merging and error classification", () => { + test("a later partial frame cannot zero an earlier count, and the total stays derived", () => { + const merged = mergeDevinUsage( + { inputTokens: 58_000, outputTokens: 200, totalTokens: 58_200, cachedInputTokens: 57_000 }, + { inputTokens: 58_000, outputTokens: 900 }, + ); + expect(merged.cachedInputTokens).toBe(57_000); + expect(merged.outputTokens).toBe(900); + // Taking the max of two totals alongside per-field maxima would leave + // 58,200 here, which no longer equals input + output. + expect(merged.totalTokens).toBe(58_900); + }); + + test("an HTTP status on the cloud error becomes a structured classification", () => { + expect(devinErrorClassification(new CloudChatError("x", undefined, undefined, 429))) + .toEqual({ status: 429, errorType: "rate_limit_error", retryable: true }); + expect(devinErrorClassification(new CloudChatError("x", undefined, undefined, 401))) + .toEqual({ status: 401, errorType: "authentication_error", retryable: false }); + expect(devinErrorClassification(new CloudChatError("x", undefined, undefined, 503))) + .toEqual({ status: 503, retryable: true }); + // A Connect trailer carries no status, so it keeps the older inference path. + expect(devinErrorClassification(new CloudChatError("x", "resource_exhausted"))).toEqual({}); + }); +}); From 92e21bc8eff2c847b6406915255e044819d37c72 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 00:53:21 +0900 Subject: [PATCH 17/17] fix(devin): map Connect trailer codes onto the status core acts on (#4423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(devin): map Connect trailer codes onto the status core acts on Only the HTTP status line carried a status, so a cap or an expired credential delivered as a Connect EOS trailer fell through to inferHttpStatusFromAdapterMessage and became a generic 502 — not an auth prompt, not a backoff, and nothing core's failover acts on. connectTrailerHttpStatus maps the Connect codes Cognition actually sends, and treats permission_denied carrying "your limit will reset" or "reached overall message rate limit" as the quota refusal it is rather than an authorization failure. It reads the raw trailer message, not the enriched text, so the tool-blocklist wrapper cannot trip the quota regex. An unrecognised code returns undefined and keeps the older inference path. Review follow-up in the same change: unimplemented maps to 501, and the blanket "5xx is retryable" rule was putting retryable: true on the SSE failure a client reads for a call the service will never implement. * docs(devlog): record the trailer-status mapping as closed 020 listed the Connect trailer mapping as the deferred half of the cloud-direct work. It landed, along with the 501 retryability fix the review caught, so the doc records the outcome and the two accepted residuals. --- .../020_cloud_direct_hardening.md | 15 +++++ src/adapters/devin.ts | 4 ++ src/adapters/devin/cloud-direct/chat.ts | 67 +++++++++++++++++-- tests/providers/devin-hardening.test.ts | 46 +++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md b/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md index 726c5e181e..873b08eb3d 100644 --- a/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md +++ b/devlog/_plan/260912_devin_hardening/020_cloud_direct_hardening.md @@ -76,3 +76,18 @@ already knows, with status 499. ## Verification bun test tests/providers/devin-adapter.test.ts tests/providers/devin-hardening.test.ts + +## 5. A Connect trailer carries no status — closed + +Landed in `connectTrailerHttpStatus`. The three EOS trailer throw sites now pass a status, +so a cap delivered as `permission_denied` with "your limit will reset" reads as 429 rather +than 403, an `unauthenticated` trailer reaches the auth path, and an unrecognised code still +falls back to message inference. `unimplemented` maps to 501 and is explicitly non-retryable, +because the blanket 5xx rule was telling clients to retry a call the service does not +implement. + +Accepted residuals: `internal`, `unknown` and `data_loss` map to 502 rather than Connect's +500 — both are transient here and 502 is what this adapter already reported — and a genuine +ACL denial whose text happens to contain the words "rate limit" would be read as a cap. The +regex reads the raw trailer message, never the enriched text, so the tool-description +blocklist wrapper cannot trip it. diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 5bbd65c480..9260976058 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -56,6 +56,10 @@ export function devinErrorClassification(error: unknown): { status?: number; err if (status === 401) return { status, errorType: "authentication_error", retryable: false }; if (status === 403) return { status, errorType: "permission_error", retryable: false }; if (status === 429) return { status, errorType: "rate_limit_error", retryable: true }; + // 501 is the one 5xx that will never succeed on a second attempt: the service + // does not implement the call. Marking it retryable put `retryable: true` on + // the SSE failure a client reads, inviting a retry that cannot change. + if (status === 501) return { status, retryable: false }; if (status >= 500) return { status, retryable: true }; return { status, retryable: false }; } diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 2c7b7c32a1..d67a7696e1 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -969,6 +969,45 @@ export class CloudChatError extends Error { const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; +/** + * A quota refusal Cognition delivers as `permission_denied`. + * + * "Your limit will reset in 13 minutes" and "Reached overall message rate + * limit" are caps, not authorization failures. Classified as 403 they invite + * the client to retry straight into a live cap; as 429 the proxy backs off and + * can rotate. + */ +const TRAILER_QUOTA_RE = /\b(?:limit will reset|rate limit|quota exceeded|out of credits)\b/i; + +/** + * Connect error code to HTTP status. + * + * Without this only the HTTP status line reached the adapter, so a cap or an + * expired credential delivered as an EOS trailer fell through to + * `inferHttpStatusFromAdapterMessage` and became a generic 502 — which is not + * retryable-with-backoff, not an auth prompt, and not something core's failover + * acts on. + */ +export function connectTrailerHttpStatus(code: string | undefined, message: string): number | undefined { + if (code === 'permission_denied' && TRAILER_QUOTA_RE.test(message)) return 429; + switch (code) { + case 'unauthenticated': return 401; + case 'permission_denied': return 403; + case 'resource_exhausted': return 429; + case 'not_found': return 404; + case 'unavailable': return 503; + case 'deadline_exceeded': return 504; + case 'unimplemented': return 501; + case 'invalid_argument': + case 'failed_precondition': + case 'out_of_range': return 400; + case 'internal': + case 'unknown': + case 'data_loss': return 502; + default: return undefined; + } +} + /** * Stream chat events from the cloud. Yields CloudChatEvent (text deltas, tool * call deltas, finish reason). Use `streamChatText` for legacy text-only iteration. @@ -1085,10 +1124,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { expect(devinErrorClassification(new CloudChatError("x", "resource_exhausted"))).toEqual({}); }); }); + +describe("connect trailer to HTTP status", () => { + test("a cap delivered as permission_denied is a 429, not a 403", () => { + // Cognition sends the account cap through the same code as an ACL denial. + // Classified 403 the client retries straight into a live cap. + expect(connectTrailerHttpStatus("permission_denied", "Your limit will reset in 13 minutes")).toBe(429); + expect(connectTrailerHttpStatus("permission_denied", "Reached overall message rate limit")).toBe(429); + // An ordinary denial stays a denial. + expect(connectTrailerHttpStatus("permission_denied", "an internal error occurred")).toBe(403); + }); + + test("the remaining Connect codes map to the status core acts on", () => { + expect(connectTrailerHttpStatus("unauthenticated", "")).toBe(401); + expect(connectTrailerHttpStatus("resource_exhausted", "")).toBe(429); + expect(connectTrailerHttpStatus("unavailable", "")).toBe(503); + expect(connectTrailerHttpStatus("deadline_exceeded", "")).toBe(504); + expect(connectTrailerHttpStatus("invalid_argument", "")).toBe(400); + expect(connectTrailerHttpStatus("internal", "")).toBe(502); + // An unknown code keeps the older message-inference path rather than + // asserting a status nobody measured. + expect(connectTrailerHttpStatus("some_new_code", "")).toBeUndefined(); + expect(connectTrailerHttpStatus(undefined, "")).toBeUndefined(); + }); + + test("a trailer status reaches the adapter's structured classification", () => { + const err = new CloudChatError("capped", "permission_denied", "abc", connectTrailerHttpStatus("permission_denied", "Your limit will reset in 3 minutes")); + expect(devinErrorClassification(err)).toEqual({ status: 429, errorType: "rate_limit_error", retryable: true }); + }); +}); + +describe("devin status classification across the newly reachable trailer codes", () => { + const cls = (status: number) => devinErrorClassification(new CloudChatError("x", undefined, undefined, status)); + + test("a request the service will not accept is never retried", () => { + expect(cls(400)).toEqual({ status: 400, retryable: false }); + expect(cls(404)).toEqual({ status: 404, retryable: false }); + // 501 is the one 5xx a second attempt cannot change. + expect(cls(501)).toEqual({ status: 501, retryable: false }); + }); + + test("a timeout or an unavailable service is retryable", () => { + expect(cls(503)).toEqual({ status: 503, retryable: true }); + expect(cls(504)).toEqual({ status: 504, retryable: true }); + }); +});