diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa58055..d6f1ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,18 @@ on: branches: [main] jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + typecheck: name: Typecheck runs-on: ubuntu-latest @@ -29,12 +41,12 @@ jobs: node-version: 20 cache: npm - run: npm ci - - run: npm run test:run + - run: npx vitest run --coverage build: name: Build runs-on: ubuntu-latest - needs: [typecheck, test] + needs: [lint, typecheck, test] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b39f252..782b416 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,17 @@ npm run test:run # Run tests once and exit All tests must pass before submitting a pull request. +## Linting + +This project uses [Biome](https://biomejs.dev/) for linting and formatting: + +```bash +npm run lint # Check for lint and formatting issues +npm run lint:fix # Auto-fix issues +``` + +CI enforces linting — run `npm run lint` before pushing. + ## Building and Running To build the TypeScript code: @@ -58,6 +69,7 @@ npm start - Use TypeScript for all code - Follow ESM module conventions (this project uses `"type": "module"`) +- Run `npm run lint` to check formatting and lint rules ([Biome](https://biomejs.dev/) enforces style automatically) - Keep code readable and maintainable - Comments should explain the "why", not the "what" diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..1d442ca --- /dev/null +++ b/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "files": { + "includes": ["src/**/*.ts"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all" + } + } +} diff --git a/package-lock.json b/package-lock.json index 79d614b..afe6aad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tokencost-dev", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tokencost-dev", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.3", @@ -17,6 +17,7 @@ "tokencost-dev": "dist/index.js" }, "devDependencies": { + "@biomejs/biome": "2.4.4", "@types/node": "^25.3.0", "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.9.3", @@ -86,6 +87,169 @@ "node": ">=18" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz", + "integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.4", + "@biomejs/cli-darwin-x64": "2.4.4", + "@biomejs/cli-linux-arm64": "2.4.4", + "@biomejs/cli-linux-arm64-musl": "2.4.4", + "@biomejs/cli-linux-x64": "2.4.4", + "@biomejs/cli-linux-x64-musl": "2.4.4", + "@biomejs/cli-win32-arm64": "2.4.4", + "@biomejs/cli-win32-x64": "2.4.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz", + "integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz", + "integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz", + "integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz", + "integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz", + "integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz", + "integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz", + "integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz", + "integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", diff --git a/package.json b/package.json index 34218f1..7b61dbc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "dev": "tsc --watch", "start": "node dist/index.js", "prepublishOnly": "npm run build", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", "test": "vitest", "test:run": "vitest run" }, @@ -47,6 +49,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@biomejs/biome": "2.4.4", "@types/node": "^25.3.0", "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.9.3", diff --git a/src/index.ts b/src/index.ts index 55d838c..063ebe7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { tools, executeTool } from "./tools.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { getModels } from "./pricing.js"; +import { executeTool, tools } from "./tools.js"; const server = new Server( { diff --git a/src/pricing.test.ts b/src/pricing.test.ts index 01c8508..bd6593b 100644 --- a/src/pricing.test.ts +++ b/src/pricing.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock fs so disk cache doesn't interfere vi.mock("node:fs", () => ({ @@ -303,9 +303,7 @@ describe("pricing module", () => { }); const { refreshPrices } = await loadPricing(); - await expect(refreshPrices()).rejects.toThrow( - "Failed to fetch pricing data: 503", - ); + await expect(refreshPrices()).rejects.toThrow("Failed to fetch pricing data: 503"); }); }); @@ -435,10 +433,9 @@ describe("pricing module", () => { const { refreshPrices } = await loadPricing(); await refreshPrices(); - expect(mkdirSync).toHaveBeenCalledWith( - expect.stringContaining(".cache"), - { recursive: true }, - ); + expect(mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".cache"), { + recursive: true, + }); }); }); }); diff --git a/src/pricing.ts b/src/pricing.ts index a579f28..b7d9e0c 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -40,10 +40,7 @@ interface CacheData { let cache: CacheData | null = null; -function normalize( - key: string, - raw: Record, -): ModelEntry | null { +function normalize(key: string, raw: Record): ModelEntry | null { const inputCost = Number(raw.input_cost_per_token) || 0; const outputCost = Number(raw.output_cost_per_token) || 0; @@ -57,9 +54,7 @@ function normalize( : null; const cacheReadCost = - typeof raw.cache_read_input_token_cost === "number" - ? raw.cache_read_input_token_cost - : null; + typeof raw.cache_read_input_token_cost === "number" ? raw.cache_read_input_token_cost : null; return { key, @@ -69,26 +64,19 @@ function normalize( output_cost_per_million: outputCost * 1_000_000, input_cost_per_token_above_200k: tieredInput, output_cost_per_token_above_200k: tieredOutput, - input_cost_per_million_above_200k: - tieredInput != null ? tieredInput * 1_000_000 : null, - output_cost_per_million_above_200k: - tieredOutput != null ? tieredOutput * 1_000_000 : null, + input_cost_per_million_above_200k: tieredInput != null ? tieredInput * 1_000_000 : null, + output_cost_per_million_above_200k: tieredOutput != null ? tieredOutput * 1_000_000 : null, cache_read_input_token_cost: cacheReadCost, cache_read_input_token_cost_per_million: cacheReadCost != null ? cacheReadCost * 1_000_000 : null, - max_input_tokens: - typeof raw.max_input_tokens === "number" ? raw.max_input_tokens : null, - max_output_tokens: - typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : null, - max_tokens: - typeof raw.max_tokens === "number" ? raw.max_tokens : null, - litellm_provider: - typeof raw.litellm_provider === "string" ? raw.litellm_provider : "unknown", + max_input_tokens: typeof raw.max_input_tokens === "number" ? raw.max_input_tokens : null, + max_output_tokens: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : null, + max_tokens: typeof raw.max_tokens === "number" ? raw.max_tokens : null, + litellm_provider: typeof raw.litellm_provider === "string" ? raw.litellm_provider : "unknown", mode: typeof raw.mode === "string" ? raw.mode : "chat", supports_vision: raw.supports_vision === true, supports_function_calling: raw.supports_function_calling === true, - supports_parallel_function_calling: - raw.supports_parallel_function_calling === true, + supports_parallel_function_calling: raw.supports_parallel_function_calling === true, }; } @@ -180,12 +168,13 @@ export async function getModels(): Promise> { if (cache) { return cache.models; } - throw new Error( - "No pricing data available — fetch failed and no cache exists", - ); + throw new Error("No pricing data available — fetch failed and no cache exists"); } - return cache!.models; + if (!cache) { + throw new Error("No pricing data available — refresh succeeded but cache is empty"); + } + return cache.models; } export function getModelCount(): number { diff --git a/src/search.test.ts b/src/search.test.ts index 740f356..a37ce3b 100644 --- a/src/search.test.ts +++ b/src/search.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { fuzzyMatch, fuzzyMatchMultiple, fuzzyMatchWithMetadata } from "./search.js"; +import { describe, expect, it } from "vitest"; import type { ModelEntry } from "./pricing.js"; +import { fuzzyMatch, fuzzyMatchMultiple, fuzzyMatchWithMetadata } from "./search.js"; /** Helper to build a minimal ModelEntry for testing */ function makeModel(overrides: Partial & { key: string }): ModelEntry { @@ -76,92 +76,92 @@ describe("fuzzyMatch", () => { it("strips azure/ prefix before matching", () => { const result = fuzzyMatch("azure/gpt-4o", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("strips bedrock/ prefix before matching", () => { const result = fuzzyMatch("bedrock/gpt-4o", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("strips vertex_ai/ prefix before matching", () => { const result = fuzzyMatch("vertex_ai/gemini-2.0-flash", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gemini-2.0-flash"); + expect(result?.key).toBe("gemini-2.0-flash"); }); it("strips vertex_ai_beta/ prefix before matching", () => { const result = fuzzyMatch("vertex_ai_beta/claude-sonnet-4-5", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("claude-sonnet-4-5"); + expect(result?.key).toBe("claude-sonnet-4-5"); }); it("strips openrouter/ prefix before matching", () => { const result = fuzzyMatch("openrouter/gpt-4o-mini", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o-mini"); + expect(result?.key).toBe("gpt-4o-mini"); }); it("strips together_ai/ prefix before matching", () => { const result = fuzzyMatch("together_ai/claude-opus-4", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("claude-opus-4"); + expect(result?.key).toBe("claude-opus-4"); }); it("strips fireworks_ai/ prefix before matching", () => { const result = fuzzyMatch("fireworks_ai/gpt-4o", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("strips prefix with case-insensitive matching", () => { const result = fuzzyMatch("AZURE/GPT-4O", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("returns exact match when key matches exactly", () => { const result = fuzzyMatch("gpt-4o", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("returns exact match for full model name", () => { const result = fuzzyMatch("claude-sonnet-4-5", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("claude-sonnet-4-5"); + expect(result?.key).toBe("claude-sonnet-4-5"); }); it("handles case-insensitive exact match", () => { const result = fuzzyMatch("GPT-4O", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("handles mixed case exact match", () => { const result = fuzzyMatch("Claude-Sonnet-4-5", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("claude-sonnet-4-5"); + expect(result?.key).toBe("claude-sonnet-4-5"); }); it("fuzzy matches partial/close model names", () => { const result = fuzzyMatch("claude sonnet", sampleModels); expect(result).not.toBeNull(); // Should match one of the claude models - expect(result!.key).toContain("claude"); + expect(result?.key).toContain("claude"); }); it("fuzzy matches with typos", () => { const result = fuzzyMatch("gpt-4o-mni", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o-mini"); + expect(result?.key).toBe("gpt-4o-mini"); }); it("fuzzy matches gemini models", () => { const result = fuzzyMatch("gemini-2.0", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toContain("gemini"); + expect(result?.key).toContain("gemini"); }); it("returns null for garbage input", () => { @@ -242,26 +242,26 @@ describe("fine-tuned model patterns (ft: prefix)", () => { it("extracts base model from OpenAI fine-tuned pattern", () => { const result = fuzzyMatch("ft:gpt-4o:my-org:custom_suffix:id", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("extracts base model from fine-tuned gpt-4o-mini", () => { const result = fuzzyMatch("ft:gpt-4o-mini:my-org:suffix:abc123", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o-mini"); + expect(result?.key).toBe("gpt-4o-mini"); }); it("returns metadata indicating fine-tuned status", () => { const result = fuzzyMatchWithMetadata("ft:gpt-4o:my-org:custom:id", sampleModels); expect(result.entry).not.toBeNull(); - expect(result.entry!.key).toBe("gpt-4o"); + expect(result.entry?.key).toBe("gpt-4o"); expect(result.isFineTuned).toBe(true); }); it("returns metadata with isFineTuned=false for non-fine-tuned models", () => { const result = fuzzyMatchWithMetadata("gpt-4o", sampleModels); expect(result.entry).not.toBeNull(); - expect(result.entry!.key).toBe("gpt-4o"); + expect(result.entry?.key).toBe("gpt-4o"); expect(result.isFineTuned).toBe(false); }); @@ -269,13 +269,13 @@ describe("fine-tuned model patterns (ft: prefix)", () => { // ft: should be processed before provider prefix const result = fuzzyMatch("azure/ft:gpt-4o:my-org:custom:id", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("handles case-insensitive fine-tuned pattern", () => { const result = fuzzyMatch("FT:GPT-4O:MY-ORG:CUSTOM:ID", sampleModels); expect(result).not.toBeNull(); - expect(result!.key).toBe("gpt-4o"); + expect(result?.key).toBe("gpt-4o"); }); it("fuzzyMatchMultiple handles fine-tuned patterns", () => { diff --git a/src/search.ts b/src/search.ts index 93c99a8..44c28bc 100644 --- a/src/search.ts +++ b/src/search.ts @@ -68,14 +68,10 @@ function buildIndex(models: Record): Fuse<{ key: string }> { return fuse; } -export function fuzzyMatch( - query: string, - models: Record, -): ModelEntry | null { +export function fuzzyMatch(query: string, models: Record): ModelEntry | null { // Strip provider prefix first, then handle fine-tuned pattern const withoutProvider = stripProviderPrefix(query); - const { base, isFineTuned } = extractFineTunedBase(withoutProvider); - const normalizedQuery = base; + const { base: normalizedQuery } = extractFineTunedBase(withoutProvider); // Try exact match first if (models[normalizedQuery]) { @@ -106,7 +102,7 @@ export function fuzzyMatchWithMetadata( models: Record, ): SearchResult { const withoutProvider = stripProviderPrefix(query); - const { base, isFineTuned } = extractFineTunedBase(withoutProvider); + const { isFineTuned } = extractFineTunedBase(withoutProvider); const entry = fuzzyMatch(query, models); return { entry, isFineTuned }; } diff --git a/src/tools.test.ts b/src/tools.test.ts index 20942b1..2633677 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelEntry } from "./pricing.js"; // Mock the pricing module @@ -11,9 +11,9 @@ vi.mock("./pricing.js", async (importOriginal) => { }; }); +import { getModels, refreshPrices } from "./pricing.js"; // Import after mock setup -import { executeTool, calculateTieredCost } from "./tools.js"; -import { getModels, refreshPrices, TIERED_PRICING_THRESHOLD } from "./pricing.js"; +import { calculateTieredCost, executeTool } from "./tools.js"; /** Helper to build a minimal ModelEntry */ function makeModel(overrides: Partial & { key: string }): ModelEntry { @@ -403,9 +403,7 @@ describe("executeTool", () => { }); it("handles getModels throwing an error", async () => { - vi.mocked(getModels).mockRejectedValueOnce( - new Error("No pricing data available"), - ); + vi.mocked(getModels).mockRejectedValueOnce(new Error("No pricing data available")); const result = await executeTool("get_model_details", { model_name: "gpt-4o", @@ -417,9 +415,7 @@ describe("executeTool", () => { }); it("handles refreshPrices throwing an error", async () => { - vi.mocked(refreshPrices).mockRejectedValueOnce( - new Error("Network failure"), - ); + vi.mocked(refreshPrices).mockRejectedValueOnce(new Error("Network failure")); const result = await executeTool("refresh_prices", {}); diff --git a/src/tools.ts b/src/tools.ts index f73a654..9955232 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,11 +1,6 @@ import { z } from "zod"; -import { - getModels, - refreshPrices, - TIERED_PRICING_THRESHOLD, - type ModelEntry, -} from "./pricing.js"; -import { fuzzyMatch, fuzzyMatchWithMetadata } from "./search.js"; +import { getModels, type ModelEntry, refreshPrices, TIERED_PRICING_THRESHOLD } from "./pricing.js"; +import { fuzzyMatchWithMetadata } from "./search.js"; export const tools = [ { @@ -61,8 +56,7 @@ export const tools = [ properties: { provider: { type: "string", - description: - "Filter by provider (e.g. 'anthropic', 'openai', 'google', 'amazon')", + description: "Filter by provider (e.g. 'anthropic', 'openai', 'google', 'amazon')", }, min_context: { type: "number", @@ -144,14 +138,10 @@ export function calculateTieredCost( let inputTieredCost = 0; let tieredInput = false; - if ( - model.input_cost_per_token_above_200k != null && - inputTokens > threshold - ) { + if (model.input_cost_per_token_above_200k != null && inputTokens > threshold) { tieredInput = true; inputBaseCost = threshold * model.input_cost_per_token; - inputTieredCost = - (inputTokens - threshold) * model.input_cost_per_token_above_200k; + inputTieredCost = (inputTokens - threshold) * model.input_cost_per_token_above_200k; } else { inputBaseCost = inputTokens * model.input_cost_per_token; } @@ -160,14 +150,10 @@ export function calculateTieredCost( let outputTieredCost = 0; let tieredOutput = false; - if ( - model.output_cost_per_token_above_200k != null && - outputTokens > threshold - ) { + if (model.output_cost_per_token_above_200k != null && outputTokens > threshold) { tieredOutput = true; outputBaseCost = threshold * model.output_cost_per_token; - outputTieredCost = - (outputTokens - threshold) * model.output_cost_per_token_above_200k; + outputTieredCost = (outputTokens - threshold) * model.output_cost_per_token_above_200k; } else { outputBaseCost = outputTokens * model.output_cost_per_token; } @@ -192,8 +178,7 @@ function formatModelDetails(model: ModelEntry): string { const capabilities: string[] = []; if (model.supports_vision) capabilities.push("vision"); if (model.supports_function_calling) capabilities.push("function_calling"); - if (model.supports_parallel_function_calling) - capabilities.push("parallel_function_calling"); + if (model.supports_parallel_function_calling) capabilities.push("parallel_function_calling"); const hasTieredInput = model.input_cost_per_million_above_200k != null; const hasTieredOutput = model.output_cost_per_million_above_200k != null; @@ -206,14 +191,10 @@ function formatModelDetails(model: ModelEntry): string { `Tiered Pricing (above ${formatTokenCount(TIERED_PRICING_THRESHOLD)} tokens, per 1M):`, ); if (hasTieredInput) { - tieredLines.push( - ` Input: ${formatCost(model.input_cost_per_million_above_200k!)}`, - ); + tieredLines.push(` Input: ${formatCost(model.input_cost_per_million_above_200k ?? 0)}`); } if (hasTieredOutput) { - tieredLines.push( - ` Output: ${formatCost(model.output_cost_per_million_above_200k!)}`, - ); + tieredLines.push(` Output: ${formatCost(model.output_cost_per_million_above_200k ?? 0)}`); } } @@ -240,9 +221,7 @@ function formatModelDetails(model: ModelEntry): string { `Context Window:`, ` Max Input: ${formatTokenCount(model.max_input_tokens)}`, ` Max Output: ${formatTokenCount(model.max_output_tokens)}`, - ...(model.max_tokens !== null - ? [` Max Tokens: ${formatTokenCount(model.max_tokens)}`] - : []), + ...(model.max_tokens !== null ? [` Max Tokens: ${formatTokenCount(model.max_tokens)}`] : []), ``, `Capabilities: ${capabilities.length > 0 ? capabilities.join(", ") : "none listed"}`, ].join("\n"); @@ -299,9 +278,7 @@ export async function executeTool( // Resolve cached token count: cap at input_tokens, ignore if model doesn't support caching const resolvedCachedTokens = - cached_tokens != null && - model.cache_read_input_token_cost != null && - cached_tokens > 0 + cached_tokens != null && model.cache_read_input_token_cost != null && cached_tokens > 0 ? Math.min(cached_tokens, input_tokens) : 0; const uncachedInputTokens = input_tokens - resolvedCachedTokens; @@ -309,7 +286,7 @@ export async function executeTool( const result = calculateTieredCost(model, uncachedInputTokens, output_tokens); const cachedCost = resolvedCachedTokens > 0 - ? resolvedCachedTokens * model.cache_read_input_token_cost! + ? resolvedCachedTokens * (model.cache_read_input_token_cost ?? 0) : 0; const totalCost = result.totalCost + cachedCost; @@ -324,7 +301,7 @@ export async function executeTool( if (resolvedCachedTokens > 0) { lines.push( - ` Cached input: ${formatTokenCount(resolvedCachedTokens)} tokens × ${formatCost(model.cache_read_input_token_cost_per_million!)}/1M = ${formatCost(cachedCost)}`, + ` Cached input: ${formatTokenCount(resolvedCachedTokens)} tokens × ${formatCost(model.cache_read_input_token_cost_per_million ?? 0)}/1M = ${formatCost(cachedCost)}`, ); } @@ -335,7 +312,7 @@ export async function executeTool( ` Input (base): ${formatTokenCount(baseTokens)} tokens × ${formatCost(model.input_cost_per_million)}/1M = ${formatCost(result.inputBaseCost)}`, ); lines.push( - ` Input (>200K): ${formatTokenCount(tieredTokens)} tokens × ${formatCost(model.input_cost_per_million_above_200k!)}/1M = ${formatCost(result.inputTieredCost)}`, + ` Input (>200K): ${formatTokenCount(tieredTokens)} tokens × ${formatCost(model.input_cost_per_million_above_200k ?? 0)}/1M = ${formatCost(result.inputTieredCost)}`, ); } else { lines.push( @@ -350,7 +327,7 @@ export async function executeTool( ` Output (base): ${formatTokenCount(baseTokens)} tokens × ${formatCost(model.output_cost_per_million)}/1M = ${formatCost(result.outputBaseCost)}`, ); lines.push( - ` Output (>200K): ${formatTokenCount(tieredTokens)} tokens × ${formatCost(model.output_cost_per_million_above_200k!)}/1M = ${formatCost(result.outputTieredCost)}`, + ` Output (>200K): ${formatTokenCount(tieredTokens)} tokens × ${formatCost(model.output_cost_per_million_above_200k ?? 0)}/1M = ${formatCost(result.outputTieredCost)}`, ); } else { lines.push( @@ -372,8 +349,7 @@ export async function executeTool( } case "compare_models": { - const { provider, min_context, mode } = - compareModelsSchema.parse(args); + const { provider, min_context, mode } = compareModelsSchema.parse(args); const models = await getModels(); let filtered = Object.values(models); @@ -389,17 +365,13 @@ export async function executeTool( if (min_context !== undefined) { filtered = filtered.filter( - (m) => - m.max_input_tokens !== null && - m.max_input_tokens >= min_context, + (m) => m.max_input_tokens !== null && m.max_input_tokens >= min_context, ); } if (mode) { const lowerMode = mode.toLowerCase(); - filtered = filtered.filter( - (m) => m.mode.toLowerCase() === lowerMode, - ); + filtered = filtered.filter((m) => m.mode.toLowerCase() === lowerMode); } if (filtered.length === 0) { @@ -414,9 +386,7 @@ export async function executeTool( } // Sort by input cost (most cost-effective first) - filtered.sort( - (a, b) => a.input_cost_per_token - b.input_cost_per_token, - ); + filtered.sort((a, b) => a.input_cost_per_token - b.input_cost_per_token); const top = filtered.slice(0, 5); const header = `Top ${top.length} most cost-effective models${provider ? ` (provider: ${provider})` : ""}${min_context ? ` (min context: ${formatTokenCount(min_context)})` : ""}${mode ? ` (mode: ${mode})` : ""}:\n`; @@ -435,9 +405,7 @@ export async function executeTool( const total = `\n(${filtered.length} models matched total)`; return { - content: [ - { type: "text", text: header + rows.join("\n\n") + total }, - ], + content: [{ type: "text", text: header + rows.join("\n\n") + total }], }; } diff --git a/vitest.config.ts b/vitest.config.ts index 4265d6d..5a95829 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,8 +8,14 @@ export default defineConfig({ coverage: { provider: "v8", include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts"], + exclude: ["src/**/*.test.ts", "src/index.ts"], reporter: ["text", "json"], + thresholds: { + statements: 85, + branches: 80, + functions: 80, + lines: 85, + }, }, }, });