feat: add offline mode support for cost calculations by ryoppippi · Pull Request #79 · ccusage/ccusage · GitHub
Skip to content

feat: add offline mode support for cost calculations#79

Merged
ryoppippi merged 6 commits into
mainfrom
offline-mode
Jun 16, 2025
Merged

feat: add offline mode support for cost calculations#79
ryoppippi merged 6 commits into
mainfrom
offline-mode

Conversation

@ryoppippi

@ryoppippi ryoppippi commented Jun 16, 2025

Copy link
Copy Markdown
Member

Summary

This PR adds comprehensive offline mode support to enable cost calculations without network connectivity to the LiteLLM API. The implementation uses a build-time macro system to pre-fetch and embed pricing data directly into the binary.

Key Features

  • Build-time Pricing Cache: Automatically fetches and caches all Claude model pricing data during build
  • Offline CLI Flag: Adds --offline flag support to all commands (daily, monthly, session)
  • Graceful Degradation: Falls back to offline mode when network is unavailable
  • Full Test Coverage: Comprehensive tests for both online and offline modes

Technical Implementation

  • Macro System: Uses unplugin-macro to fetch LiteLLM pricing data at build time
  • Dual Mode Support: PricingFetcher supports both online (API) and offline (cached) modes
  • Consistent Interface: No changes to existing CLI usage patterns
  • Error Handling: Robust fallback behavior when offline data is unavailable

Usage

# Use offline mode (no network required)
ccusage daily --offline
ccusage monthly --offline
ccusage session --offline

# Online mode (default, requires network)
ccusage daily
ccusage monthly
ccusage session

Test Coverage

  • Online/offline mode consistency validation
  • Pre-fetched pricing data verification
  • End-to-end CLI integration tests
  • Error handling for edge cases

This change maintains full backward compatibility while adding the flexibility to work without network connectivity.

Summary by CodeRabbit

  • New Features

    • Added an offline mode option to the CLI, allowing users to use cached data instead of fetching from the API.
    • Offline mode is now available for daily, monthly, and session usage commands.
    • Pricing data for Claude models can now be prefetched and used in offline mode.
  • Bug Fixes

    • Updated model naming conventions in cost calculation and pricing retrieval tests.
  • Tests

    • Added tests to verify offline mode functionality and ensure cost calculation works when using cached data.
  • Chores

    • Introduced new dependencies and updated build configuration for macro support.

@coderabbitai

coderabbitai Bot commented Jun 16, 2025

Copy link
Copy Markdown

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2025

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/ryoppippi/ccusage@79

commit: 930aa29

@socket-security

socket-security Bot commented Jun 16, 2025

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedunplugin-macros@​0.17.07610010093100

View full report

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary of Changes

Hello @ryoppippi, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a new offline mode for cost calculations within the application. By leveraging build-time data pre-fetching, it allows users to perform cost analyses using cached pricing information, eliminating the need for real-time API calls. This enhancement improves performance and ensures functionality even without an active internet connection, providing a more robust and efficient user experience.

Highlights

  • Offline Mode CLI Flag: A new --offline (or -O) flag has been added to the daily, monthly, and session commands, enabling users to explicitly request cost calculations using cached pricing data.
  • Build-Time Pricing Data Pre-fetching: The application now utilizes unplugin-macros to fetch and embed the latest Claude model pricing data from LiteLLM directly into the build artifact. This ensures that pricing information is available locally for offline use.
  • Enhanced PricingFetcher: The PricingFetcher class has been updated to accept an offline parameter, allowing it to intelligently switch between fetching live pricing data and utilizing the pre-fetched, embedded data.
  • Robust Offline Functionality: Comprehensive error handling is implemented for offline scenarios, providing graceful fallbacks and clear warning messages if pre-fetched data is unavailable, ensuring the application continues to function.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments to provide feedback.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an offline mode for cost calculations by pre-fetching pricing data at build time using a macro. The changes are well-implemented, with good error handling and test coverage for the new functionality. One minor suggestion is provided to enhance the clarity of build-time error messages.

Comment thread src/macro.internal.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The error message for a failed pricing data fetch at build time could be more informative by including the URL that was being fetched.

throw new Error(`Failed to fetch pricing data from ${LITELLM_PRICING_URL}: ${response.statusText}`);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🔭 Outside diff range comments (2)
src/pricing-fetcher.ts (1)

98-106: Missing new Claude-4 naming variant leads to false negatives

getModelPricing()’s heuristic list does not include the new claude-4-* naming that the tests (and likely users) now pass in:

`claude-4-${modelName}`,

Without it, look-ups such as "claude-sonnet-4-20250514""claude-4-sonnet-20250514" will fail.

Either extend the variations array or normalise both strings before comparison. Quick patch:

   const variations = [
     modelName,
     `anthropic/${modelName}`,
     `claude-3-5-${modelName}`,
     `claude-3-${modelName}`,
+    `claude-4-${modelName}`,
     `claude-${modelName}`,
   ];

Make sure to add unit tests covering both old and new orders.

src/pricing-fetcher.test.ts (1)

81-92: Tests use the old model naming and will now fail

The implementation and other tests were migrated to "claude-4-sonnet-20250514" but these two still reference "claude-sonnet-4-20250514":

-const pricing = await fetcher.getModelPricing('claude-sonnet-4-20250514');
+const pricing = await fetcher.getModelPricing('claude-4-sonnet-20250514');

and

-const pricing = await fetcher.getModelPricing('claude-sonnet-4');
+const pricing = await fetcher.getModelPricing('claude-4-sonnet');

Update them (and the test titles) or add the missing variant handling in PricingFetcher.

♻️ Duplicate comments (2)
src/commands/daily.ts (1)

31-32: Mirror the type-safety fix here as well

Same ts/no-unsafe-member-access finding for ctx.values.offline.
Apply whichever solution you pick for session.ts to stay consistent.

src/data-loader.ts (1)

607-608: Duplicate compile issue – see previous comment

The same nullable-using pattern appears here; refactor as described above.

🧹 Nitpick comments (6)
src/consts.internal.ts (1)

1-2: Minor: add a literal-type assertion for stronger immutability

Exporting the URL verbatim works, but appending as const prevents accidental re-assignment or widening by consumers.

-export const LITELLM_PRICING_URL
-	= 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
+export const LITELLM_PRICING_URL =
+	'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' as const;
src/commands/session.ts (1)

31-32: Type-safety warning surfaced by ESLint

ctx is inferred as any, giving ts/no-unsafe-* errors every time we touch ctx.values. The new offline access just adds another occurrence.

Consider one of:

 const sessionCommand = define<{
   values: typeof sharedArgs extends { args: infer A } ? v.Output<A> : never
 }>( {

or simply suppress for now:

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
offline: (ctx.values as Record<string, unknown>).offline as boolean,

This keeps the rule from becoming noisy across new lines.

src/commands/monthly.ts (1)

31-31: ctx.values is any – propagate proper typing to avoid no-unsafe-* ESLint noise

ctx comes from gunshi.define, so you can extend the generic parameter to describe values (e.g. { offline?: boolean }).
Doing so eliminates the unsafe‐access lint errors and guards against typos at call-sites.

tsdown.config.ts (2)

2-2: Import is fine but unused in file scope

Macros is referenced only inside the plugins array – keep the import but switch to import Macros from … with assert { type: 'macro' } is unnecessary; however ESLint may flag the current unused-identifier if you later refactor. Consider moving to inline const { default: Macros } = await import(...) or add an eslint ignore.


24-28: Plugin array mutates compile pipeline – add fallback handling for build failures

If the macro fetch fails (e.g. due to network outage during CI) the whole build will abort. Provide onError/try … catch inside the plugin options to fall back to empty pricing so that offline builds remain deterministic.

src/data-loader.ts (1)

463-468: offline option added – good, but mark it false by default

Declare offline?: boolean in LoadOptions as you did, and document that false is the default so callers know network fetch remains enabled unless explicitly disabled.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 932b9ec and ba396ae5fa1195215b437b029ebf97116f1570c6.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • package.json (1 hunks)
  • src/commands/daily.ts (1 hunks)
  • src/commands/monthly.ts (1 hunks)
  • src/commands/session.ts (1 hunks)
  • src/consts.internal.ts (1 hunks)
  • src/data-loader.test.ts (16 hunks)
  • src/data-loader.ts (3 hunks)
  • src/macro.internal.ts (1 hunks)
  • src/pricing-fetcher.test.ts (6 hunks)
  • src/pricing-fetcher.ts (3 hunks)
  • src/shared-args.internal.ts (1 hunks)
  • tsdown.config.ts (2 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
src/macro.internal.ts (2)
src/consts.internal.ts (1)
  • LITELLM_PRICING_URL (1-2)
src/pricing-fetcher.ts (2)
  • ModelPricing (13-13)
  • ModelPricingSchema (6-11)
src/data-loader.ts (1)
src/pricing-fetcher.ts (1)
  • PricingFetcher (15-183)
src/pricing-fetcher.ts (2)
src/macro.internal.ts (1)
  • prefetchClaudeData (32-32)
src/logger.ts (1)
  • logger (5-5)
🪛 ESLint
src/commands/monthly.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/commands/session.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/commands/daily.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/macro.internal.ts

[error] 12-12: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 12-12: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 13-13: Unexpected any value in conditional. An explicit comparison or type conversion is required.

(ts/strict-boolean-expressions)


[error] 13-13: Unsafe member access .ok on an error typed value.

(ts/no-unsafe-member-access)


[error] 14-14: Unsafe member access .statusText on an error typed value.

(ts/no-unsafe-member-access)


[error] 18-18: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 18-18: Unsafe member access .json on an error typed value.

(ts/no-unsafe-member-access)


[error] 25-25: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 25-25: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 25-25: Unsafe member access .safeParse on an error typed value.

(ts/no-unsafe-member-access)


[error] 26-26: Unexpected any value in conditional. An explicit comparison or type conversion is required.

(ts/strict-boolean-expressions)


[error] 26-26: Unsafe member access .success on an error typed value.

(ts/no-unsafe-member-access)


[error] 27-27: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 27-27: Unsafe member access .output on an error typed value.

(ts/no-unsafe-member-access)

tsdown.config.ts

[error] 25-25: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)

src/data-loader.test.ts

[error] 1906-1906: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1907-1907: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe member access .toBe on an error typed value.

(ts/no-unsafe-member-access)

src/pricing-fetcher.test.ts

[error] 256-256: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 257-257: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 270-270: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 270-270: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 270-270: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 274-274: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 274-274: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)


[error] 278-278: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 294-294: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 294-294: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)

src/pricing-fetcher.ts

[error] 6-6: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 6-6: Unsafe member access .object on an error typed value.

(ts/no-unsafe-member-access)


[error] 46-46: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 46-46: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)

🔇 Additional comments (4)
package.json (1)

72-73: Confirm build‐time integration of the new macro plugin

unplugin-macros is a pure build-time dependency, so placing it under devDependencies is correct.
Please double-check that:

  1. tsdown.config.ts (or the bundler config in use) actually registers the plugin – otherwise the macro import will be left untouched at runtime and offline mode will break.
  2. CI caches have been invalidated so the new plugin is installed during the next pipeline run.

No code change needed if both are already covered.

src/shared-args.internal.ts (1)

66-72: Flag name & shortcut look good – verify collision

The new offline arg is well-defined (boolean, negatable, default false).
Quick sanity checks:

  • -O is not already consumed by another command or global flag.
  • Help output shows both --offline and --no-offline.

If those are clear, 👍.

src/pricing-fetcher.ts (1)

4-4: Import with `with { type: 'macro' } may break at test/runtime

with { type: 'macro' } relies on the bundler / macro plugin executing before Bun runs the test suite.
When the macro transform is not wired-up (e.g. raw bun test without Vite/Rollup), Bun will attempt to evaluate the file directly and fail to recognise this syntax, causing a SyntaxError: import attributes not yet supported.

Please verify that:

  1. unplugin-macros is actually executed in the Bun test runner pipeline.
  2. The compiled artefact that reaches the test/production runtime no longer contains the with import attribute.

If this is not guaranteed, fall back to a plain import and gate the macro under a build-time process.env.BUILD_TIME check, or ship a pre-generated JSON artefact instead.

src/data-loader.test.ts (1)

1195-1195: Consistent model naming convention updated
All test cases now reference the new prefix 'claude-4-sonnet-20250514', aligning with your macro-generated data. Please verify that the PricingFetcher and any related fixtures/mocks also expect and use this updated naming format.

Also applies to: 1264-1264, 1335-1335, 1412-1412, 1440-1440, 1470-1470, 1508-1508, 1575-1575, 1606-1606, 1636-1636

Comment thread src/data-loader.ts Outdated
Comment on lines 491 to 492

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

using on a nullable value triggers TS 5.4 compile-time error

using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);

null is not Disposable. TypeScript’s using syntax forbids nullable initialisers and this will not compile.

Quick fix:

-let fetcher: PricingFetcher | undefined;
-if (mode !== 'display') {
-  fetcher = new PricingFetcher(options?.offline);
-}
-try {
-  // …
-} finally {
-  fetcher?.[Symbol.dispose]();
-}

Apply the same pattern in loadSessionData.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/data-loader.ts around lines 491 to 492, the use of 'using' with a
nullable value (null) causes a TypeScript 5.4 compile error because 'using'
requires a non-null Disposable. To fix this, avoid assigning null to the 'using'
variable; instead, conditionally create the PricingFetcher only when mode is not
'display' and ensure the variable is always a Disposable. Apply the same pattern
in the loadSessionData function to prevent similar errors.

Comment thread src/macro.internal.ts Outdated
Comment on lines 12 to 15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Un-guarded network fetch can break the build

Any transient 503 will throw and fail the macro. Wrap the fetch in a retry or at least swallow the error and export an empty object so that offline builds keep working.

🧰 Tools
🪛 ESLint

[error] 12-12: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 12-12: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 13-13: Unexpected any value in conditional. An explicit comparison or type conversion is required.

(ts/strict-boolean-expressions)


[error] 13-13: Unsafe member access .ok on an error typed value.

(ts/no-unsafe-member-access)


[error] 14-14: Unsafe member access .statusText on an error typed value.

(ts/no-unsafe-member-access)

🤖 Prompt for AI Agents
In src/macro.internal.ts around lines 12 to 15, the fetch call to
LITELLM_PRICING_URL is unguarded and will throw an error on transient failures
like HTTP 503, breaking the build. To fix this, wrap the fetch call in a retry
mechanism or catch any errors and return an empty object instead, ensuring that
offline or transient failure scenarios do not cause the macro to fail the build.

Comment thread src/macro.internal.ts Outdated
Comment thread src/pricing-fetcher.ts Outdated
Comment on lines 36 to 33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Offline branch skips schema-validation and can silently return an empty map

prefetchClaudeData is trusted wholesale:

const pricing = new Map(Object.entries(prefetchClaudeData));

Two issues:

  1. No validation – any shape mismatch bypasses ModelPricingSchema, whereas the online path is validated.
  2. If the macro returns {} (e.g. network failure at build-time) you still resolve successfully and downstream tests crash because the map is empty rather than throwing.

Proposed fix:

-const pricing = new Map(Object.entries(prefetchClaudeData));
+const validated = new Map<string, ModelPricing>();
+for (const [model, raw] of Object.entries(prefetchClaudeData ?? {})) {
+  const parsed = v.safeParse(ModelPricingSchema, raw);
+  if (parsed.success) validated.set(model, parsed.output);
+}
+
+if (validated.size === 0) {
+  throw new Error('No pre-fetched pricing data available');
+}
+
+this.cachedPricing = validated;
+return validated;

This keeps behaviour symmetric with the online path and surfaces a clear error when the macro did not provide data.

🧰 Tools
🪛 ESLint

[error] 46-46: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 46-46: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)

🤖 Prompt for AI Agents
In src/pricing-fetcher.ts around lines 36 to 50, the offline mode uses
prefetchClaudeData without validating it against ModelPricingSchema, unlike the
online path, which causes silent failures if the data shape is incorrect or
empty. To fix this, validate prefetchClaudeData using ModelPricingSchema before
creating the Map, and if validation fails or the data is empty, throw an error
or handle it explicitly to prevent returning an empty map silently.

Comment thread src/pricing-fetcher.test.ts Outdated
Comment on lines 256 to 259

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Skipping tests via try…catch masks real failures

Catching any error inside the test still runs the expect assertions after the failure point – ESLint rightfully flags “unsafe call of an error typed value”.
Prefer Bun’s explicit skip:

-it('should use pre-fetched data in offline mode when available', async () => {
-  using fetcher = new PricingFetcher(true); // offline mode
-  try {
-    const pricing = await fetcher.fetchModelPricing();
-
-  } catch {
-    console.warn('Offline mode test skipped: macro data not available');
-  }
-});
+it.skip('should use pre-fetched data in offline mode when available (macro missing)', async () => {
+  using fetcher = new PricingFetcher(true);
+  const pricing = await fetcher.fetchModelPricing();
+
+});

You can programmatically decide to skip:

const pricing = await fetcher.fetchModelPricing();
if (pricing.size === 0) return test.skip();

This avoids swallowing legitimate errors and removes the ESLint warnings.

🧰 Tools
🪛 ESLint

[error] 256-256: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 257-257: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 264-264: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 270-270: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 270-270: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 270-270: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 274-274: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 274-274: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)


[error] 278-278: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 290-290: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 294-294: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 294-294: Unsafe member access .warn on an error typed value.

(ts/no-unsafe-member-access)

🤖 Prompt for AI Agents
In src/pricing-fetcher.test.ts around lines 256 to 296, the tests use try-catch
blocks to skip tests when data is unavailable, which masks real failures and
causes ESLint warnings. Remove the try-catch blocks and instead check the
condition after fetching data; if the data is missing or empty, call test.skip()
to explicitly skip the test. This approach prevents swallowing errors and
resolves ESLint unsafe call warnings.

Comment thread src/data-loader.test.ts Outdated
Comment on lines 1906 to 1919

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve offline-mode test specificity
The added offline-mode suite currently only checks that loadDailyUsageData({ offline: true, mode: 'calculate' }) returns an array without throwing. To fully validate offline behavior, mock or spy on your network/fetch logic (e.g., PricingFetcher.fetchPricing) and assert that no HTTP calls occur and cached data is used when offline: true.

🧰 Tools
🪛 ESLint

[error] 1906-1906: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1907-1907: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe member access .toBe on an error typed value.

(ts/no-unsafe-member-access)

🤖 Prompt for AI Agents
In src/data-loader.test.ts around lines 1906 to 1917, the offline mode test only
verifies that loadDailyUsageData returns an array without throwing when called
with offline: true. To improve this, mock or spy on the network or fetch
function used internally (such as PricingFetcher.fetchPricing) and add
assertions to confirm that no HTTP calls are made during the test, ensuring
cached data is used when offline is true.

- Add macro system for build-time pricing data fetching from LiteLLM
- Implement automatic caching of all claude- model pricing data
- Add --offline CLI flag to shared arguments for all commands
- Create constants file for internal configuration
- Enable offline usage without network dependency on LiteLLM API
- Support both online and offline cost calculation modes

This provides the foundation for offline mode functionality where
pricing data is pre-fetched at build time and embedded in the binary.
- Add offline flag support to LoadOptions interface in data-loader
- Implement offline mode in PricingFetcher using pre-fetched macro data
- Add caching logic for offline pricing data to avoid repeated object creation
- Return cached pricing data when offline flag is enabled
- Provide graceful fallback when macro data is unavailable in offline mode
- Add proper error handling for offline mode edge cases

The pricing fetcher now supports both online (LiteLLM API) and offline
(build-time cached) modes, enabling usage without network connectivity.
- Pass offline flag from CLI arguments to data loading functions in all commands
- Enable macro processing for pricing-fetcher in tsdown build configuration
- Connect --offline flag support to daily, monthly, and session commands
- Allow macro expansion during build process for offline pricing data

This completes the integration of offline mode throughout the CLI,
enabling users to run all commands with --offline flag support.

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🔭 Outside diff range comments (2)
src/commands/daily.ts (1)

25-32: ctx.values is still typed as any; ESLint errors will persist

The new offline prop itself is fine, but the repetitive ts/no-unsafe-* errors stem from ctx being implicitly any.
Define a proper generic when calling define() (or cast ctx), so all ctx.values.* properties are strongly-typed and you don’t have to suppress/disable the rule in every command.

-export const dailyCommand = define({
+export const dailyCommand = define<{              // 🎯 strong typing
+  offline?: boolean;
+  // …other flags
+}>({

(Do the same for the other commands.)

src/pricing-fetcher.test.ts (1)

80-84: Model name mismatch – old string remains.

Most tests were migrated to the new prefix (claude-4-…) but this assertion still requests 'claude-sonnet-4-20250514'.
Update for consistency to avoid false negatives:

-const pricing = await fetcher.getModelPricing('claude-sonnet-4-20250514');
+const pricing = await fetcher.getModelPricing('claude-4-sonnet-20250514');
♻️ Duplicate comments (8)
src/commands/monthly.ts (1)

25-32: Same typing issue as in dailyCommand – address via a generic on define<>() or a dedicated interface for ctx.values, otherwise the ts/no-unsafe-* warnings stay.

src/commands/session.ts (1)

25-32: Same typing issue as in dailyCommand – introduce a typed context to remove any usage.

src/macro.internal.ts (3)

18-21: Error message omits the URL

Include LITELLM_PRICING_URL so users immediately see which endpoint failed.

-throw new Error(`Failed to fetch pricing data: ${response.statusText}`);
+throw new Error(
+  `Failed to fetch pricing data from ${LITELLM_PRICING_URL}: ${response.statusText}`,
+);

6-8: Circular dependency with pricing-fetcher.ts is still unresolved

Importing ModelPricingSchema from pricing-fetcher.ts while that file imports prefetchClaudePricing → runtime undefined risk.

-import { type ModelPricing, ModelPricingSchema } from './pricing-fetcher.ts';
+import { type ModelPricing, ModelPricingSchema } from './model-pricing.schema.ts'; // move schema

Extract the schema to a standalone module and have both files import from there.


18-23: Unguarded fetch will fail hard during transient outages

A single 503 will abort the build. Wrap the request in a minimal retry or, at the very least, swallow the error and return {} to keep offline builds working.

-const response = await fetch(LITELLM_PRICING_URL);
-if (!response.ok) {
-  throw new Error(`Failed to fetch pricing data: ${response.statusText}`);
-}
+let response: Response | undefined;
+for (let attempt = 0; attempt < 3; attempt++) {
+  try {
+    response = await fetch(LITELLM_PRICING_URL);
+    if (response.ok) break;
+  } catch { /* ignore */ }
+  await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
+}
+if (!response?.ok) return {};      // graceful fallback
src/data-loader.test.ts (1)

1906-1917: Offline-mode test still only asserts “no throw”.

Previous review already asked to spy/mock the fetch path (e.g. PricingFetcher.fetchModelPricing) and assert no network activity. That feedback still applies.

src/pricing-fetcher.ts (1)

36-41: Offline branch still returns an un-validated, possibly empty map.

This was pointed out in a previous review and hasn’t been addressed.
prefetchClaudePricing() may yield {} (e.g. build-time fetch failed) yet you cache & return it silently, breaking cost calculations downstream.

Recommend validating and throwing when zero items are loaded:

-const pricing = new Map(Object.entries(await prefetchClaudePricing()));
-this.cachedPricing = pricing;
-return pricing;
+const raw = await prefetchClaudePricing();
+const pricing = new Map(Object.entries(raw));
+if (pricing.size === 0) {
+  throw new Error('Offline mode: no pre-fetched pricing data available');
+}
+this.cachedPricing = pricing;
+return pricing;
src/pricing-fetcher.test.ts (1)

232-261: Offline-mode tests will fail when macro data is absent.

prefetchClaudePricing can legitimately return an empty record (network down during build).
If that happens, these tests should be skipped, not fail.

-const pricing = await fetcher.fetchModelPricing();
-// Should have Claude models from pre-fetched data
-expect(pricing.size).toBeGreaterThan(0);
+const pricing = await fetcher.fetchModelPricing();
+if (pricing.size === 0) {
+  // No macro data – skip to avoid false red.
+  return test.skip('pre-fetched pricing unavailable');
+}
+expect(pricing.size).toBeGreaterThan(0);

Apply the same guard to the cost-calculation case.

🧹 Nitpick comments (1)
tsdown.config.ts (1)

24-28: Plugin include glob is too narrow – macro may be missed

prefetchClaudePricing is defined in src/macro.internal.ts but could later be invoked from files outside the current include list (e.g. a new helper). Consider widening the pattern or using the default include so the macro keeps functioning if its call-sites expand.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ba396ae5fa1195215b437b029ebf97116f1570c6 and 8e53c52a76ef17802edd225976c0d27a4a68b7e1.

📒 Files selected for processing (11)
  • src/commands/daily.ts (1 hunks)
  • src/commands/monthly.ts (1 hunks)
  • src/commands/session.ts (1 hunks)
  • src/consts.internal.ts (1 hunks)
  • src/data-loader.test.ts (16 hunks)
  • src/data-loader.ts (3 hunks)
  • src/macro.internal.ts (1 hunks)
  • src/pricing-fetcher.test.ts (3 hunks)
  • src/pricing-fetcher.ts (3 hunks)
  • src/shared-args.internal.ts (1 hunks)
  • tsdown.config.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/data-loader.ts
  • src/consts.internal.ts
  • src/shared-args.internal.ts
🧰 Additional context used
🧬 Code Graph Analysis (4)
src/data-loader.test.ts (1)
src/data-loader.ts (1)
  • loadDailyUsageData (470-584)
src/macro.internal.ts (2)
src/pricing-fetcher.ts (2)
  • ModelPricing (13-13)
  • ModelPricingSchema (6-11)
src/consts.internal.ts (1)
  • LITELLM_PRICING_URL (1-2)
src/pricing-fetcher.test.ts (1)
src/pricing-fetcher.ts (1)
  • PricingFetcher (15-174)
src/pricing-fetcher.ts (1)
src/macro.internal.ts (1)
  • prefetchClaudePricing (17-38)
🪛 ESLint
src/commands/daily.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/commands/monthly.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/commands/session.ts

[error] 31-31: Unsafe assignment of an any value.

(ts/no-unsafe-assignment)


[error] 31-31: Unsafe member access .values on an any value.

(ts/no-unsafe-member-access)

src/data-loader.test.ts

[error] 1906-1906: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1907-1907: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 1916-1916: Unsafe member access .toBe on an error typed value.

(ts/no-unsafe-member-access)

src/macro.internal.ts

[error] 18-18: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 18-18: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 19-19: Unexpected any value in conditional. An explicit comparison or type conversion is required.

(ts/strict-boolean-expressions)


[error] 19-19: Unsafe member access .ok on an error typed value.

(ts/no-unsafe-member-access)


[error] 20-20: Unsafe member access .statusText on an error typed value.

(ts/no-unsafe-member-access)


[error] 23-23: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 23-23: Unsafe member access .json on an error typed value.

(ts/no-unsafe-member-access)


[error] 30-30: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 30-30: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 30-30: Unsafe member access .safeParse on an error typed value.

(ts/no-unsafe-member-access)


[error] 31-31: Unexpected any value in conditional. An explicit comparison or type conversion is required.

(ts/strict-boolean-expressions)


[error] 31-31: Unsafe member access .success on an error typed value.

(ts/no-unsafe-member-access)


[error] 32-32: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 32-32: Unsafe member access .output on an error typed value.

(ts/no-unsafe-member-access)

src/pricing-fetcher.test.ts

[error] 108-108: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 111-111: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 111-111: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 111-111: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 112-112: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 112-112: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 112-112: Unsafe member access .input_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 112-112: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 113-113: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 113-113: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 113-113: Unsafe member access .output_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 113-113: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 120-120: This assertion is unnecessary since the receiver accepts the original type of the expression.

(ts/no-unnecessary-type-assertion)


[error] 123-123: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 123-123: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 123-123: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 126-126: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 129-129: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 138-138: This assertion is unnecessary since the receiver accepts the original type of the expression.

(ts/no-unnecessary-type-assertion)


[error] 142-142: Unsafe member access .input_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 143-143: Unsafe member access .output_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 144-144: Unsafe member access .cache_creation_input_token_cost on an error typed value.

(ts/no-unsafe-member-access)


[error] 145-145: Unsafe member access .cache_read_input_token_cost on an error typed value.

(ts/no-unsafe-member-access)


[error] 147-147: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 147-147: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 147-147: Unsafe member access .toBeCloseTo on an error typed value.

(ts/no-unsafe-member-access)


[error] 148-148: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 148-148: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 148-148: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 151-151: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 154-154: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 157-157: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 157-157: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 157-157: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 158-158: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 158-158: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 158-158: Unsafe member access .input_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 158-158: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 159-159: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 159-159: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 159-159: Unsafe member access .output_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 159-159: Unsafe member access .not on an error typed value.

(ts/no-unsafe-member-access)


[error] 166-166: This assertion is unnecessary since the receiver accepts the original type of the expression.

(ts/no-unnecessary-type-assertion)


[error] 169-169: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 169-169: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 169-169: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 172-172: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 175-175: Unsafe assignment of an error typed value.

(ts/no-unsafe-assignment)


[error] 184-184: This assertion is unnecessary since the receiver accepts the original type of the expression.

(ts/no-unnecessary-type-assertion)


[error] 188-188: Unsafe member access .input_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 189-189: Unsafe member access .output_cost_per_token on an error typed value.

(ts/no-unsafe-member-access)


[error] 190-190: Unsafe member access .cache_creation_input_token_cost on an error typed value.

(ts/no-unsafe-member-access)


[error] 191-191: Unsafe member access .cache_read_input_token_cost on an error typed value.

(ts/no-unsafe-member-access)


[error] 232-232: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 233-233: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 239-239: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 239-239: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 239-239: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 245-245: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 245-245: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 245-245: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)


[error] 248-248: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 259-259: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 259-259: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 259-259: Unsafe member access .toBeGreaterThan on an error typed value.

(ts/no-unsafe-member-access)

src/pricing-fetcher.ts

[error] 6-6: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)


[error] 6-6: Unsafe member access .object on an error typed value.

(ts/no-unsafe-member-access)

tsdown.config.ts

[error] 25-25: Unsafe call of a(n) error type typed value.

(ts/no-unsafe-call)

Comment thread src/data-loader.test.ts Outdated
@ryoppippi ryoppippi requested a review from Copilot June 16, 2025 19:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull Request Overview

This PR enables offline mode for cost calculations by embedding model pricing data at build time and adding a CLI flag to toggle cached data usage.

  • Adds a macro plugin to prefetch and embed pricing data during build
  • Introduces ModelPricingSchema and centralizes the pricing URL constant
  • Implements --offline flag throughout CLI, updates PricingFetcher to support offline mode, and adds corresponding tests

Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tsdown.config.ts Configures the unplugin-macros rollup plugin for build-time pricing fetch
src/types.internal.ts Defines ModelPricingSchema and ModelPricing type
src/shared-args.internal.ts Adds offline boolean flag to shared CLI arguments
src/pricing-fetcher.ts Updates PricingFetcher to accept an offline flag and use prefetch macro
src/pricing-fetcher.test.ts Adapts tests for new model naming and adds offline-mode test cases
src/macro.internal.ts Implements prefetchClaudePricing() macro to fetch and validate pricing
src/data-loader.ts Propagates offline option into data-loading functions
src/data-loader.test.ts Updates model naming in tests and adds offline-mode load test
src/consts.internal.ts Extracts pricing URL constant
src/commands/{daily,monthly,session}.ts Passes offline flag into command handlers
package.json Adds unplugin-macros dependency
Comments suppressed due to low confidence (2)

src/pricing-fetcher.test.ts:235

  • The test calls fetchModelPricing(), but the PricingFetcher class provides getModelPricing() (and no fetchModelPricing method). Update the test to use getModelPricing(modelName) or add an alias that returns all pricing data.
const pricing = await fetcher.fetchModelPricing();

src/pricing-fetcher.test.ts:1

  • The imported ModelPricing type is not used anywhere in this test file; consider removing it to clean up unused imports.
import type { ModelPricing } from './types.internal.ts';

…uality

- Add offline mode integration tests for data-loader functions
- Add extensive offline mode tests for PricingFetcher class
- Test consistency between online and offline cost calculations
- Update model names to use correct Claude 4 format (claude-sonnet-4-20250514)
- Remove unnecessary conditional branches and try-catch blocks from tests
- Simplify test code while maintaining coverage and error handling
- Add validation for pre-fetched pricing data in offline mode
- Ensure offline functionality works end-to-end across the application

Tests now provide comprehensive coverage of offline mode functionality
while being more maintainable and predictable in their execution.
…ro modules

- Move ModelPricing type and ModelPricingSchema from pricing-fetcher.ts to types.internal.ts
- Update imports in pricing-fetcher.ts, macro.internal.ts, and pricing-fetcher.test.ts
- Fix ESLint import ordering issues in test file
- Eliminate circular dependency where pricing-fetcher imported from macro.internal via macro import,
  while macro.internal imported types from pricing-fetcher

This change improves module architecture by centralizing shared types in the types module,
preventing the circular import chain that could cause build or runtime issues.
@ryoppippi

Copy link
Copy Markdown
Member Author

@ryoppippi ryoppippi merged commit dbbc0dc into main Jun 16, 2025
9 of 10 checks passed
@ryoppippi ryoppippi deleted the offline-mode branch June 16, 2025 19:14
ryoppippi added a commit that referenced this pull request Jun 22, 2025
feat: add offline mode support for cost calculations
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants