Skip to content

perf(pricing): memoize normalizeModelName#67

Merged
alexgreensh merged 1 commit into
alexgreensh:mainfrom
danikdanik:perf/memoize-normalize-model-name
Jun 16, 2026
Merged

perf(pricing): memoize normalizeModelName#67
alexgreensh merged 1 commit into
alexgreensh:mainfrom
danikdanik:perf/memoize-normalize-model-name

Conversation

@danikdanik

Copy link
Copy Markdown
Contributor

Why

normalizeModelName() maps a model id (e.g. anthropic/claude-sonnet-4-...) to a pricing key by running up to ~60 sequential String.includes() checks (Anthropic -> GPT-5 family -> GPT-4 -> reasoning -> Gemini -> DeepSeek -> Qwen -> ...). It sits on hot paths and is called many thousands of times per audit:

  • once per usage bucket in parseSession, and again inside calculateCost;
  • once per turn in parseSessionTurns;
  • and across the savings / quality / dashboard accumulation loops.

Almost every call is on a small, repeating set of model-id strings, so the same ~60-branch scan is redone over and over.

What

Memoize the result. normalizeModelName() now consults a module-level Map cache keyed on the raw model id; repeat lookups become O(1) instead of a linear scan. The matching logic is unchanged.

How

  • Extracted the existing matching logic verbatim into a private computeNormalizedModelName(modelId).
  • normalizeModelName() checks the cache with Map.has() (not a falsy check, so a cached null — for empty or <...> ids — is distinguished from a miss), computes on a miss, and stores the result.
  • The cache is bounded (cap 4096; once full, new ids are still computed correctly, just not cached) so a pathological stream of unique ids can't grow it unbounded. The realistic key space is a few dozen ids, so the cap is never approached.
  • Wired _normalizeCache.clear() into the existing resetPricingCache(), mirroring the file's existing _mergedPricing cache idiom.

Validation

  • npm run build (tsc) passes; dist/ regenerated and committed in lockstep with src.
  • Output verified byte-identical before/after across ~70 model strings: provider prefixes (anthropic/, openrouter/openai/, anthropic:), mixed case, every GPT-5 / GPT-4 / Gemini / DeepSeek / Qwen / Mistral / Grok / local variant, and null-producing inputs ("", "<...>").
  • Idempotency confirmed (repeat call == first call) and resetPricingCache() confirmed to clear the cache without changing results.
  • normalizeModelName is a pure function of its argument (no dependency on pricing config or env), so caching is safe regardless of config reload.
  • Public API surface unchanged: no .d.ts signature change; computeNormalizedModelName is private.

Scope

Narrow by design. A parallel normalizeModelName exists in opencode/src/pricing.ts without this cache; mirroring it there is a possible follow-up (lower urgency — OpenCode reprices per session, not per turn).

normalizeModelName runs a ~60-branch linear scan of String.includes checks to
map a model id to a pricing key, and it sits on hot paths: once per usage bucket
and again inside calculateCost during session parsing, plus per turn and across
the savings / quality / dashboard accumulation loops. A 30-day audit calls it
many thousands of times, almost always on a small set of repeating model-id
strings.

Add a module-level Map cache keyed on the raw model id so repeat lookups are
O(1). The matching logic is unchanged (extracted verbatim into a private
computeNormalizedModelName); output is verified byte-identical across ~70 model
strings including provider prefixes, every GPT-5/Gemini variant, and null cases.
The cache is bounded (cap 4096) and cleared by resetPricingCache().
alexgreensh added a commit that referenced this pull request Jun 16, 2026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexgreensh alexgreensh merged commit e9d92cb into alexgreensh:main Jun 16, 2026
1 check passed
alexgreensh added a commit that referenced this pull request Jun 16, 2026
Torture-room follow-up to PRs #62-#67. The O_NOFOLLOW symlink-refusal hardening
landed in continuity.ts and smart-compact.ts but two sibling write paths in
checkpoint-policy.ts were missed (4-agent convergence in the gauntlet):
  - persistState() policy-state write (was bare fs.writeFileSync)
  - appendCheckpointEvent() telemetry append (was openSync('a'))

Changes:
- Extract the duplicated writeFileNoFollow() into a single openclaw/src/fs-utils.ts
  (was copy-pasted in continuity.ts + smart-compact.ts) and add appendFileNoFollow().
- Apply O_NOFOLLOW to persistState and appendCheckpointEvent.
- Make appendCheckpointManifest best-effort so an O_NOFOLLOW ELOOP can't crash a
  checkpoint capture (an unregistered artifact is a harmless orphan).

Verified: tsc clean, runtime smoke test confirms write/append correctness,
0600 mode, and ELOOP symlink refusal with target file left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alexgreensh added a commit that referenced this pull request Jun 16, 2026
Merges contributor PRs #62-#67 (openclaw port): negative/non-finite token
clamp, streamed first-prompt read, bounded session maps, O_NOFOLLOW checkpoint
hardening, single-read costly-prompts, memoized model normalization. Plus a
torture-room follow-up completing O_NOFOLLOW coverage in checkpoint-policy.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 16, 2026
@alexgreensh

Copy link
Copy Markdown
Owner

Merged in v5.11.13 — clean null-aware memoization (nice use of .has() over a falsy check). Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants