Skip to content
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Opt-in prompt caching** — `Netra.prompts.getPrompt()` accepts `useCache` and `cacheTtl`. When `useCache` is true, responses are served from an in-memory TTL cache (default TTL: `PROMPT_CACHE_TTL_SECONDS` = 60). Caching is off by default.
- **Models API** — `Netra.models.getModelPricing()` fetches model pricing (optional `name` filter) with the same opt-in cache pattern (`useCache`, `cacheTtl`; default TTL: `MODEL_PRICING_CACHE_TTL_SECONDS` = 300).
- **Cache lifecycle** — `Netra.shutdown()` clears prompts and models in-memory caches. `clearCache()` is also available on each client.
- **Exported cache constants** — `PROMPT_CACHE_TTL_SECONDS` and `MODEL_PRICING_CACHE_TTL_SECONDS` are public exports.

### Changed

- **Prompt cache TTL** — Default TTL is the module constant `PROMPT_CACHE_TTL_SECONDS` (60). Override per call with `cacheTtl`. Removed unused `cacheTtlSeconds` init config and `NETRA_CACHE_TTL_SECONDS` env var.

## [1.1.0-beta.1] - 2026-06-09

### Fixed
Expand Down
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- 🔧 **Multi-Provider Support**: Works with OpenAI, Google GenAI, Mistral, Anthropic, and more
- 📈 **Session Management**: Track user sessions and custom attributes
- 🌐 **Automatic Instrumentation**: Zero-code instrumentation for popular frameworks and libraries
- ⚡ **Opt-in Read Caching**: In-memory TTL caching for read-heavy SDK calls (e.g. prompt fetches)
- ⚡ **Opt-in Read Caching**: In-memory TTL caching for read-heavy SDK calls (`getPrompt`, `getModelPricing`)

## 📦 Installation

Expand Down Expand Up @@ -189,20 +189,19 @@ async function generateContent(prompt: string) {

Fetch prompt versions from Prompt Studio via `Netra.prompts.getPrompt()`. Caching is **opt-in per call** — omit `useCache` (or set it to `false`) to always hit the API.

Set a global default TTL at init with `cacheTtlSeconds` (default: **60** seconds), or override TTL for a single call with `cacheTtl`.
Default TTL is **60 seconds** (`PROMPT_CACHE_TTL_SECONDS`). Override TTL for a single call with `cacheTtl`.

```typescript
import { Netra } from "netra-sdk";

await Netra.init({
appName: "my-ai-app",
cacheTtlSeconds: 60, // optional; env: NETRA_CACHE_TTL_SECONDS
});

// Always fetches from the API (default)
const prompt = await Netra.prompts.getPrompt({ name: "my-prompt" });

// Cached for 60s (global default TTL)
// Cached for 60s (default TTL)
const cached = await Netra.prompts.getPrompt({
name: "my-prompt",
useCache: true,
Expand All @@ -219,6 +218,40 @@ const shortLived = await Netra.prompts.getPrompt({

> **Note**: Cached prompts may be stale for up to the TTL after dashboard edits. Use `useCache: false` when you need the latest version immediately. `Netra.shutdown()` clears in-memory caches.

## 💰 Models API

Fetch model pricing via `Netra.models.getModelPricing()`. Caching is **opt-in per call** — omit `useCache` (or set it to `false`) to always hit the API.

Default TTL is **300 seconds** (`MODEL_PRICING_CACHE_TTL_SECONDS`). Override TTL for a single call with `cacheTtl`.

```typescript
import { Netra } from "netra-sdk";

await Netra.init({
appName: "my-ai-app",
});

// Always fetches from the API (default)
const pricing = await Netra.models.getModelPricing();

// Optional name filter
const gptPricing = await Netra.models.getModelPricing({ name: "gpt-4o" });

// Cached for 300s (default TTL)
const cached = await Netra.models.getModelPricing({
useCache: true,
});

// Cached for 60s for this call only
const shortLived = await Netra.models.getModelPricing({
name: "gpt-4o",
useCache: true,
cacheTtl: 60,
});
```

> **Note**: Cached pricing may be stale for up to the TTL after dashboard edits. Use `useCache: false` when you need the latest values immediately. `Netra.shutdown()` clears in-memory caches.

## 🔧 Environment Variables

You can configure the SDK using environment variables:
Expand All @@ -229,7 +262,6 @@ You can configure the SDK using environment variables:
| `NETRA_APP_NAME` | Name of your application |
| `NETRA_ENV` | Environment (e.g., prod, dev) |
| `NETRA_TRACE_CONTENT` | Capture prompt/completion content (default: true) |
| `NETRA_CACHE_TTL_SECONDS` | Default TTL in seconds for opt-in SDK read caches (default: 60) |

## 🤝 License

Expand Down
10 changes: 9 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,13 @@ export type {
} from "./dashboard";

// Prompts API
export { Prompts } from "./prompts";
export { Prompts, PROMPT_CACHE_TTL_SECONDS } from "./prompts";
export type { GetPromptParams, PromptResponse } from "./prompts";

// Models API
export { Models, MODEL_PRICING_CACHE_TTL_SECONDS } from "./models";
export type {
GetModelPricingParams,
ModelPrice,
ModelPricing,
} from "./models";
136 changes: 136 additions & 0 deletions src/api/models/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Config } from "../../config";
import { Models } from "./api";
import { ModelsHttpClient } from "./client";
import { ModelPricing } from "./models";

const samplePricing: ModelPricing[] = [
{
name: "gpt-4",
projectId: null,
matchPattern: "gpt-4*",
prices: [
{
usageType: "input",
minUnits: 0,
maxUnits: 1000,
price: 0.03,
unitValue: 1000,
},
],
},
];

describe("Models.getModelPricing caching", () => {
let models: Models;
let getModelPricing: ReturnType<typeof vi.fn>;

beforeEach(() => {
const config = new Config();
models = new Models(config);
getModelPricing = vi.fn();
(models as unknown as { client: ModelsHttpClient }).client = {
getModelPricing,
} as unknown as ModelsHttpClient;
});

afterEach(() => {
vi.useRealTimers();
});

it("calls HTTP on every request when useCache is omitted", async () => {
getModelPricing.mockResolvedValue(samplePricing);

await models.getModelPricing();
await models.getModelPricing();

expect(getModelPricing).toHaveBeenCalledTimes(2);
});

it("serves from cache on second call with same name when useCache is true", async () => {
getModelPricing.mockResolvedValue(samplePricing);

const first = await models.getModelPricing({
name: "gpt-4",
useCache: true,
});
const second = await models.getModelPricing({
name: "gpt-4",
useCache: true,
});

expect(getModelPricing).toHaveBeenCalledTimes(1);
expect(getModelPricing).toHaveBeenCalledWith("gpt-4");
expect(first).toEqual(samplePricing);
expect(second).toEqual(samplePricing);
});

it("keeps separate cache entries for different name and all", async () => {
const allPricing: ModelPricing[] = [];
getModelPricing
.mockResolvedValueOnce(samplePricing)
.mockResolvedValueOnce(allPricing);

const named = await models.getModelPricing({
name: "gpt-4",
useCache: true,
});
const all = await models.getModelPricing({ useCache: true });

expect(getModelPricing).toHaveBeenCalledTimes(2);
expect(getModelPricing).toHaveBeenNthCalledWith(1, "gpt-4");
expect(getModelPricing).toHaveBeenNthCalledWith(2, undefined);
expect(named).toEqual(samplePricing);
expect(all).toEqual(allPricing);
});

it("does not cache null API responses", async () => {
getModelPricing.mockResolvedValue(null);

await models.getModelPricing({ useCache: true });
await models.getModelPricing({ useCache: true });

expect(getModelPricing).toHaveBeenCalledTimes(2);
});

it("caches empty arrays as successful responses", async () => {
getModelPricing.mockResolvedValue([]);

await models.getModelPricing({ useCache: true });
await models.getModelPricing({ useCache: true });

expect(getModelPricing).toHaveBeenCalledTimes(1);
});

it("ignores cache when useCache is false even if cacheTtl is set", async () => {
getModelPricing.mockResolvedValue(samplePricing);

await models.getModelPricing({ useCache: false, cacheTtl: 30 });
await models.getModelPricing({ useCache: false, cacheTtl: 30 });

expect(getModelPricing).toHaveBeenCalledTimes(2);
});

it("expires per-call cacheTtl before the models default TTL", async () => {
vi.useFakeTimers();
getModelPricing.mockResolvedValue(samplePricing);

await models.getModelPricing({ useCache: true, cacheTtl: 1 });
expect(getModelPricing).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(1001);

await models.getModelPricing({ useCache: true, cacheTtl: 1 });
expect(getModelPricing).toHaveBeenCalledTimes(2);
});

it("hits HTTP again after clearCache when useCache is true", async () => {
getModelPricing.mockResolvedValue(samplePricing);

await models.getModelPricing({ useCache: true });
models.clearCache();
await models.getModelPricing({ useCache: true });

expect(getModelPricing).toHaveBeenCalledTimes(2);
});
});
54 changes: 54 additions & 0 deletions src/api/models/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { TTLCache } from "../../cache";
import { Config } from "../../config";
import { ModelsHttpClient } from "./client";
import {
GetModelPricingParams,
MODEL_PRICING_CACHE_TTL_SECONDS,
ModelPricing,
} from "./models";

export class Models {
private config: Config;
private client: ModelsHttpClient;
private cache: TTLCache<ModelPricing[]>;

constructor(config: Config) {
this.config = config;
this.client = new ModelsHttpClient(config);
this.cache = new TTLCache<ModelPricing[]>(MODEL_PRICING_CACHE_TTL_SECONDS);
}

/** Clear all cached model pricing entries. */
clearCache(): void {
this.cache.clear();
}

/**
* Fetch model pricing from the backend.
*
* @param params.name - Optional model name filter
* @param params.useCache - When true, read/write the in-memory cache (default: false)
* @param params.cacheTtl - Per-call cache TTL in seconds (default: MODEL_PRICING_CACHE_TTL_SECONDS)
*/
async getModelPricing(
params: GetModelPricingParams = {},
): Promise<ModelPricing[] | null> {
const useCache = params.useCache === true;
const cacheKey = `model:pricing:${params.name ?? "all"}`;

if (useCache) {
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
}

const data = await this.client.getModelPricing(params.name);

if (data !== null && useCache) {
this.cache.set(cacheKey, data, params.cacheTtl);
}

return data;
}
}
Loading