diff --git a/CHANGELOG.md b/CHANGELOG.md index 441524d..f587592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 079be41..8a2b4a3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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, @@ -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: @@ -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 diff --git a/src/api/index.ts b/src/api/index.ts index 6dc1451..249df25 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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"; diff --git a/src/api/models/api.test.ts b/src/api/models/api.test.ts new file mode 100644 index 0000000..3acf8ff --- /dev/null +++ b/src/api/models/api.test.ts @@ -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; + + 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); + }); +}); diff --git a/src/api/models/api.ts b/src/api/models/api.ts new file mode 100644 index 0000000..f7ee692 --- /dev/null +++ b/src/api/models/api.ts @@ -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; + + constructor(config: Config) { + this.config = config; + this.client = new ModelsHttpClient(config); + this.cache = new TTLCache(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 { + 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; + } +} diff --git a/src/api/models/client.test.ts b/src/api/models/client.test.ts new file mode 100644 index 0000000..3fe917d --- /dev/null +++ b/src/api/models/client.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Config } from "../../config"; +import { Logger } from "../../logger"; +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("ModelsHttpClient.getModelPricing", () => { + let client: ModelsHttpClient; + let get: ReturnType; + let isInitialized: ReturnType; + let logError: ReturnType; + + beforeEach(() => { + client = new ModelsHttpClient(new Config()); + get = vi.spyOn(client, "get"); + isInitialized = vi.spyOn(client, "isInitialized").mockReturnValue(true); + logError = vi.spyOn(Logger, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("when the request succeeds", () => { + it("returns the pricing array from an enveloped API response", async () => { + get.mockResolvedValue({ + ok: true, + status: 200, + data: { data: samplePricing }, + }); + + const result = await client.getModelPricing(); + + expect(result).toEqual(samplePricing); + expect(get).toHaveBeenCalledWith("/sdk/models", undefined); + }); + + it("calls GET /sdk/models with the name query param when name is provided", async () => { + get.mockResolvedValue({ + ok: true, + status: 200, + data: { data: samplePricing }, + }); + + await client.getModelPricing("gpt-4"); + + expect(get).toHaveBeenCalledWith("/sdk/models", { name: "gpt-4" }); + }); + + it("calls GET /sdk/models with no query params when name is omitted", async () => { + get.mockResolvedValue({ + ok: true, + status: 200, + data: { data: samplePricing }, + }); + + await client.getModelPricing(); + + expect(get).toHaveBeenCalledWith("/sdk/models", undefined); + }); + + it("returns an empty array when the API returns an empty list", async () => { + get.mockResolvedValue({ + ok: true, + status: 200, + data: { data: [] }, + }); + + const result = await client.getModelPricing(); + + expect(result).toEqual([]); + expect(logError).not.toHaveBeenCalled(); + }); + }); + + describe("when the client is not initialized", () => { + it("returns null without calling GET", async () => { + isInitialized.mockReturnValue(false); + + const result = await client.getModelPricing("gpt-4"); + + expect(result).toBeNull(); + expect(get).not.toHaveBeenCalled(); + expect(logError).toHaveBeenCalledWith( + "netra.models: Models client is not initialized; cannot fetch model pricing", + ); + }); + }); + + describe("when the HTTP response fails", () => { + it("returns null and logs the API error message when response is not ok", async () => { + get.mockResolvedValue({ + ok: false, + status: 500, + data: { error: { message: "backend unavailable" } }, + }); + + const result = await client.getModelPricing(); + + expect(result).toBeNull(); + expect(logError).toHaveBeenCalledWith( + "netra.models: Failed to fetch model pricing: backend unavailable", + ); + }); + + it("returns null and logs a generic message when the error payload has no message", async () => { + get.mockResolvedValue({ + ok: false, + status: 502, + data: {}, + }); + + const result = await client.getModelPricing(); + + expect(result).toBeNull(); + expect(logError).toHaveBeenCalledWith( + "netra.models: Failed to fetch model pricing: Unknown error", + ); + }); + }); + + describe("when the response payload is unexpected", () => { + it("returns null and logs when the unwrapped payload is not an array", async () => { + get.mockResolvedValue({ + ok: true, + status: 200, + data: { data: { name: "not-an-array" } }, + }); + + const result = await client.getModelPricing(); + + expect(result).toBeNull(); + expect(logError).toHaveBeenCalledWith( + "netra.models: Unexpected response format; expected an array", + ); + }); + }); + + describe("when GET throws", () => { + it("returns null and logs the thrown error message", async () => { + get.mockRejectedValue(new Error("network down")); + + const result = await client.getModelPricing(); + + expect(result).toBeNull(); + expect(logError).toHaveBeenCalledWith( + "netra.models: Failed to fetch model pricing:", + "network down", + ); + }); + }); +}); diff --git a/src/api/models/client.ts b/src/api/models/client.ts new file mode 100644 index 0000000..9dfa4d3 --- /dev/null +++ b/src/api/models/client.ts @@ -0,0 +1,49 @@ +/** + * Internal HTTP client for Models APIs + */ + +import { Config } from "../../config"; +import { Logger } from "../../logger"; +import { NetraHttpClient } from "../http-client"; +import { ModelPricing } from "./models"; + +export class ModelsHttpClient extends NetraHttpClient { + constructor(config: Config) { + super(config, "NETRA_MODELS_TIMEOUT", 10.0); + } + + async getModelPricing(name?: string): Promise { + if (!this.isInitialized()) { + Logger.error( + "netra.models: Models client is not initialized; cannot fetch model pricing", + ); + return null; + } + + try { + const params = name ? { name } : undefined; + const response = await this.get("/sdk/models", params); + + if (!response.ok) { + const errorMessage = response.data?.error?.message ?? "Unknown error"; + Logger.error( + `netra.models: Failed to fetch model pricing: ${errorMessage}`, + ); + return null; + } + + const items = this.extractData(response, null); + if (items !== null && !Array.isArray(items)) { + Logger.error( + "netra.models: Unexpected response format; expected an array", + ); + return null; + } + return items; + } catch (err: any) { + const message = err?.response?.data?.error?.message ?? err?.message ?? ""; + Logger.error("netra.models: Failed to fetch model pricing:", message); + return null; + } + } +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts new file mode 100644 index 0000000..138c4b9 --- /dev/null +++ b/src/api/models/index.ts @@ -0,0 +1,13 @@ +/** + * Models API exports + */ + +export { Models } from "./api"; + +export type { + GetModelPricingParams, + ModelPrice, + ModelPricing, +} from "./models"; + +export { MODEL_PRICING_CACHE_TTL_SECONDS } from "./models"; diff --git a/src/api/models/models.ts b/src/api/models/models.ts new file mode 100644 index 0000000..4ccf3a8 --- /dev/null +++ b/src/api/models/models.ts @@ -0,0 +1,32 @@ +/** + * Models API Models + */ + +/** Default TTL (seconds) for model pricing cache when useCache is true and cacheTtl is omitted. */ +export const MODEL_PRICING_CACHE_TTL_SECONDS = 300; + +export interface ModelPrice { + usageType: string; + minUnits: number; + maxUnits: number; + price: number; + unitValue: number; +} + +export interface ModelPricing { + name: string; + projectId: string | null; + matchPattern: string; + prices: ModelPrice[]; +} + +export interface GetModelPricingParams { + name?: string; + /** When true, read/write in-memory cache (default: false). */ + useCache?: boolean; + /** + * Per-call TTL in seconds. + * When omitted with useCache: true, uses MODEL_PRICING_CACHE_TTL_SECONDS (300). + */ + cacheTtl?: number; +} diff --git a/src/api/prompts/api.test.ts b/src/api/prompts/api.test.ts index 9044eb5..75294de 100644 --- a/src/api/prompts/api.test.ts +++ b/src/api/prompts/api.test.ts @@ -2,13 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Config } from "../../config"; import { Prompts } from "./api"; import { PromptsHttpClient } from "./client"; +import { PROMPT_CACHE_TTL_SECONDS } from "./models"; describe("Prompts.getPrompt caching", () => { let prompts: Prompts; let getPromptVersion: ReturnType; beforeEach(() => { - const config = new Config({ cacheTtlSeconds: 60 }); + const config = new Config(); prompts = new Prompts(config); getPromptVersion = vi.fn(); (prompts as unknown as { client: PromptsHttpClient }).client = { @@ -87,7 +88,7 @@ describe("Prompts.getPrompt caching", () => { expect(getPromptVersion).toHaveBeenCalledTimes(2); }); - it("expires per-call cacheTtl before the global default TTL", async () => { + it("expires per-call cacheTtl before the module default TTL", async () => { vi.useFakeTimers(); getPromptVersion.mockResolvedValue({ data: { template: "v1" } }); @@ -108,6 +109,22 @@ describe("Prompts.getPrompt caching", () => { expect(getPromptVersion).toHaveBeenCalledTimes(2); }); + it("expires cached prompts after PROMPT_CACHE_TTL_SECONDS when cacheTtl is omitted", async () => { + vi.useFakeTimers(); + getPromptVersion.mockResolvedValue({ data: { template: "v1" } }); + + await prompts.getPrompt({ name: "my-prompt", useCache: true }); + expect(getPromptVersion).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(PROMPT_CACHE_TTL_SECONDS * 1000 - 1); + await prompts.getPrompt({ name: "my-prompt", useCache: true }); + expect(getPromptVersion).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2); + await prompts.getPrompt({ name: "my-prompt", useCache: true }); + expect(getPromptVersion).toHaveBeenCalledTimes(2); + }); + it("hits HTTP again after clearCache when useCache is true", async () => { getPromptVersion.mockResolvedValue({ data: { template: "v1" } }); diff --git a/src/api/prompts/api.ts b/src/api/prompts/api.ts index 90c3423..3d8ca6d 100644 --- a/src/api/prompts/api.ts +++ b/src/api/prompts/api.ts @@ -2,7 +2,11 @@ import { TTLCache } from "../../cache"; import { Config } from "../../config"; import { Logger } from "../../logger"; import { PromptsHttpClient } from "./client"; -import { GetPromptParams, PromptResponse } from "./models"; +import { + GetPromptParams, + PROMPT_CACHE_TTL_SECONDS, + PromptResponse, +} from "./models"; export class Prompts { private config: Config; @@ -12,7 +16,7 @@ export class Prompts { constructor(config: Config) { this.config = config; this.client = new PromptsHttpClient(config); - this.cache = new TTLCache(config.cacheTtlSeconds); + this.cache = new TTLCache(PROMPT_CACHE_TTL_SECONDS); } /** Clear all cached prompt entries. */ @@ -26,7 +30,7 @@ export class Prompts { * @param params.name - Name of the prompt (required) * @param params.label - Label of the prompt version (default: "production") * @param params.useCache - When true, read/write the in-memory cache (default: false) - * @param params.cacheTtl - Per-call cache TTL in seconds (default: init cacheTtlSeconds) + * @param params.cacheTtl - Per-call cache TTL in seconds (default: PROMPT_CACHE_TTL_SECONDS) */ async getPrompt(params: GetPromptParams): Promise { if (!params || typeof params.name !== "string" || !params.name) { diff --git a/src/api/prompts/index.ts b/src/api/prompts/index.ts index bedb08f..0847ecf 100644 --- a/src/api/prompts/index.ts +++ b/src/api/prompts/index.ts @@ -3,5 +3,5 @@ */ export { Prompts } from "./api"; - +export { PROMPT_CACHE_TTL_SECONDS } from "./models"; export type { GetPromptParams, PromptResponse } from "./models"; diff --git a/src/api/prompts/models.ts b/src/api/prompts/models.ts index ae9c1d8..50ed7cc 100644 --- a/src/api/prompts/models.ts +++ b/src/api/prompts/models.ts @@ -2,12 +2,18 @@ * Prompts API Models */ +/** Default TTL (seconds) for prompt cache when useCache is true and cacheTtl is omitted. */ +export const PROMPT_CACHE_TTL_SECONDS = 60; + export interface GetPromptParams { name: string; label?: string; /** When true, serve from in-memory cache when available (default: false). */ useCache?: boolean; - /** Per-call TTL in seconds; falls back to init `cacheTtlSeconds` when omitted. */ + /** + * Per-call TTL in seconds. + * When omitted with useCache: true, uses PROMPT_CACHE_TTL_SECONDS (60). + */ cacheTtl?: number; } diff --git a/src/config.ts b/src/config.ts index 5cdbed4..9bf08b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,8 +19,6 @@ export interface NetraConfig { instruments?: Set; blockInstruments?: Set; rootInstruments?: Set; - /** Default TTL in seconds for opt-in SDK read caches (env: NETRA_CACHE_TTL_SECONDS). */ - cacheTtlSeconds?: number; } export enum NetraInstruments { @@ -130,7 +128,6 @@ export class Config { environment: string; resourceAttributes: Record; blockedSpans?: string[]; - cacheTtlSeconds: number; constructor(config: NetraConfig = {}) { this.appName = this._getAppName(config.appName); @@ -167,11 +164,6 @@ export class Config { config.resourceAttributes, ); this.blockedSpans = config.blockedSpans; - this.cacheTtlSeconds = this._getIntConfig( - config.cacheTtlSeconds, - "NETRA_CACHE_TTL_SECONDS", - 60, - ); this._validateApiKey(); this._setupAuthentication(); @@ -251,24 +243,6 @@ export class Config { } } - private _getIntConfig( - param: number | undefined, - envVar: string, - defaultValue: number, - ): number { - if (param !== undefined) { - return param; - } - - const envValue = process.env[envVar]; - if (envValue === undefined) { - return defaultValue; - } - - const parsed = parseInt(envValue, 10); - return Number.isNaN(parsed) ? defaultValue : parsed; - } - private _getBoolConfig( param: boolean | undefined, envVar: string, diff --git a/src/index.shutdown-cache.test.ts b/src/index.shutdown-cache.test.ts index f34bbcb..ed9076e 100644 --- a/src/index.shutdown-cache.test.ts +++ b/src/index.shutdown-cache.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { ModelsHttpClient } from "./api/models/client"; import { PromptsHttpClient } from "./api/prompts/client"; vi.mock("./instrumentation", () => ({ @@ -17,7 +18,7 @@ describe("Netra.shutdown cache clearing", () => { }); it("clears prompts cache on shutdown so the next cached getPrompt hits HTTP", async () => { - await Netra.init({ cacheTtlSeconds: 60 }); + await Netra.init({}); const getPromptVersion = vi .fn() @@ -32,7 +33,7 @@ describe("Netra.shutdown cache clearing", () => { await Netra.shutdown(); - await Netra.init({ cacheTtlSeconds: 60 }); + await Netra.init({}); (Netra.prompts as unknown as { client: PromptsHttpClient }).client = { getPromptVersion, } as unknown as PromptsHttpClient; @@ -40,4 +41,35 @@ describe("Netra.shutdown cache clearing", () => { await Netra.prompts.getPrompt({ name: "my-prompt", useCache: true }); expect(getPromptVersion).toHaveBeenCalledTimes(2); }); + + it("clears models cache on shutdown so the next cached getModelPricing hits HTTP", async () => { + await Netra.init({}); + + const pricing = [ + { + name: "gpt-4", + projectId: null, + matchPattern: "gpt-4*", + prices: [], + }, + ]; + const getModelPricing = vi.fn().mockResolvedValue(pricing); + (Netra.models as unknown as { client: ModelsHttpClient }).client = { + getModelPricing, + } as unknown as ModelsHttpClient; + + await Netra.models.getModelPricing({ useCache: true }); + await Netra.models.getModelPricing({ useCache: true }); + expect(getModelPricing).toHaveBeenCalledTimes(1); + + await Netra.shutdown(); + + await Netra.init({}); + (Netra.models as unknown as { client: ModelsHttpClient }).client = { + getModelPricing, + } as unknown as ModelsHttpClient; + + await Netra.models.getModelPricing({ useCache: true }); + expect(getModelPricing).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/index.ts b/src/index.ts index 29a74df..87aaffe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { context, Span, SpanKind, trace } from "@opentelemetry/api"; import { createRequire } from "module"; -import { Prompts, Dashboard, Evaluation, Usage } from "./api"; +import { Prompts, Dashboard, Evaluation, Usage, Models } from "./api"; import { Config, NetraConfig } from "./config"; import { initInstrumentations, instrumentationsReady, uninstrumentAll } from "./instrumentation"; import { Logger } from "./logger"; @@ -66,6 +66,10 @@ export { Usage, // Prompts API Prompts, + PROMPT_CACHE_TTL_SECONDS, + // Models API + Models, + MODEL_PRICING_CACHE_TTL_SECONDS, } from "./api"; export type { @@ -105,6 +109,10 @@ export type { TraceSummary, GetPromptParams, PromptResponse, + // Models API + GetModelPricingParams, + ModelPrice, + ModelPricing, } from "./api"; // Export simulation types and classes @@ -141,6 +149,7 @@ export class Netra { static dashboard: Dashboard; static simulation: Simulation; static prompts: Prompts; + static models: Models; static getConfig(): Config { if (!this._config) { @@ -155,8 +164,6 @@ export class Netra { /** * Initialize the Netra SDK. - * - * @param config.cacheTtlSeconds - Default TTL in seconds for opt-in read caches (default: 60, env: NETRA_CACHE_TTL_SECONDS) */ static async init(config: NetraConfig = {}): Promise { if (this._initialized) { @@ -212,6 +219,12 @@ export class Netra { Logger.warn("Netra: failed to initialize prompts client:", e); } + try { + this.models = new Models(cfg); + } catch (e) { + Logger.warn("Netra: failed to initialize models client:", e); + } + this._initialized = true; Logger.info("Netra successfully initialized."); @@ -344,6 +357,7 @@ export class Netra { try { this.prompts?.clearCache(); + this.models?.clearCache(); } catch (e) { Logger.error("Error clearing SDK API caches:", e); }