-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add credit tracking via API (check_credits tool) #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { CallToolResult } from '@modelcontextprotocol/sdk/types.js' | ||
| import { callNutrientApi } from './api.js' | ||
| import { handleApiError, pipeToString } from './utils.js' | ||
| import { createSuccessResponse } from '../responses.js' | ||
|
|
||
| export type CreditAction = 'balance' | 'usage' | ||
| export type CreditPeriod = 'day' | 'week' | 'month' | 'all' | ||
|
|
||
| const DEFAULT_USAGE_PERIOD: CreditPeriod = 'week' | ||
|
|
||
| const BALANCE_PATHS: string[][] = [ | ||
| ['credits', 'remaining'], | ||
| ['credits', 'balance'], | ||
| ['credits', 'remainingCredits'], | ||
| ['credits', 'remaining_credits'], | ||
| ['remainingCredits'], | ||
| ['remaining_credits'], | ||
| ['remaining'], | ||
| ['balance'], | ||
| ['creditBalance'], | ||
| ['credit_balance'], | ||
| ] | ||
|
|
||
| const USAGE_PATHS: string[][] = [ | ||
| ['usage'], | ||
| ['credits', 'usage'], | ||
| ['consumption'], | ||
| ['creditUsage'], | ||
| ['usageBreakdown'], | ||
| ['usage_breakdown'], | ||
| ] | ||
|
|
||
| /** | ||
| * Fetches current account credit information from the Nutrient API. | ||
| */ | ||
| export async function performCheckCreditsCall( | ||
| action: CreditAction, | ||
| period?: CreditPeriod, | ||
| ): Promise<CallToolResult> { | ||
| try { | ||
| const response = await callNutrientApi('account/info', undefined, { method: 'GET' }) | ||
| const responseString = await pipeToString(response.data) | ||
|
|
||
| let payload: unknown | ||
| try { | ||
| payload = JSON.parse(responseString) | ||
| } catch { | ||
| return createSuccessResponse(responseString) | ||
| } | ||
|
|
||
| if (action === 'balance') { | ||
| const balance = extractBalance(payload) | ||
| if (balance !== null) { | ||
| return createSuccessResponse(JSON.stringify({ balance }, null, 2)) | ||
| } | ||
| return createSuccessResponse(JSON.stringify(payload, null, 2)) | ||
| } | ||
|
|
||
| const usagePeriod = period ?? DEFAULT_USAGE_PERIOD | ||
| const usage = extractUsage(payload, usagePeriod) | ||
| if (usage !== null) { | ||
| return createSuccessResponse(JSON.stringify({ period: usagePeriod, usage }, null, 2)) | ||
| } | ||
|
|
||
| return createSuccessResponse(JSON.stringify(payload, null, 2)) | ||
| } catch (e: unknown) { | ||
| return handleApiError(e) | ||
| } | ||
| } | ||
|
|
||
| function extractBalance(payload: unknown): number | null { | ||
| if (!isRecord(payload)) { | ||
| return null | ||
| } | ||
|
|
||
| for (const path of BALANCE_PATHS) { | ||
| const value = getNestedValue(payload, path) | ||
| const numberValue = parseNumber(value) | ||
| if (numberValue !== null) { | ||
| return numberValue | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| function extractUsage(payload: unknown, period: CreditPeriod): unknown | null { | ||
| if (!isRecord(payload)) { | ||
| return null | ||
| } | ||
|
|
||
| for (const path of USAGE_PATHS) { | ||
| const value = getNestedValue(payload, path) | ||
| if (isRecord(value)) { | ||
| if (period in value) { | ||
| return value[period] | ||
| } | ||
| return { allPeriods: value, note: "Requested period not available, returning all usage data" } | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
Comment on lines
+87
to
+103
|
||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null | ||
| } | ||
|
|
||
| function getNestedValue(source: Record<string, unknown>, path: string[]): unknown { | ||
| let current: unknown = source | ||
|
|
||
| for (const key of path) { | ||
| if (!isRecord(current) || !(key in current)) { | ||
| return undefined | ||
| } | ||
| current = current[key] | ||
| } | ||
|
|
||
| return current | ||
| } | ||
|
|
||
| function parseNumber(value: unknown): number | null { | ||
| if (typeof value === 'number' && Number.isFinite(value)) { | ||
| return value | ||
| } | ||
|
|
||
| if (typeof value === 'string') { | ||
| const parsed = Number.parseFloat(value) | ||
| if (Number.isFinite(parsed)) { | ||
| return parsed | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,10 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import fs, { Stats } from 'fs' | ||
| import { Readable } from 'stream' | ||
| import { Instructions, SignatureOptions } from '../src/schemas.js' | ||
| import { CheckCreditsArgsSchema, Instructions, SignatureOptions } from '../src/schemas.js' | ||
| import { config as dotenvConfig } from 'dotenv' | ||
| import { performBuildCall } from '../src/dws/build.js' | ||
| import { performCheckCreditsCall } from '../src/dws/credits.js' | ||
| import { performSignCall } from '../src/dws/sign.js' | ||
| import { performDirectoryTreeCall } from '../src/fs/directoryTree.js' | ||
| import * as sandbox from '../src/fs/sandbox.js' | ||
|
|
@@ -364,6 +365,87 @@ describe('API Functions', () => { | |
| }) | ||
| }) | ||
|
|
||
| describe('performCheckCreditsCall', () => { | ||
| it('should extract balance from nested JSON', async () => { | ||
| const mockStream = createMockStream(JSON.stringify({ credits: { remaining: 500 } })) | ||
| vi.mocked(api.callNutrientApi).mockResolvedValueOnce({ | ||
| data: mockStream, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| headers: {}, | ||
| config: {} as InternalAxiosRequestConfig, | ||
| }) | ||
|
|
||
| const result = await performCheckCreditsCall('balance') | ||
|
|
||
| expect(result.isError).toBe(false) | ||
| expect(api.callNutrientApi).toHaveBeenCalledWith('account/info', undefined, { method: 'GET' }) | ||
|
|
||
| const payload = JSON.parse(getTextContent(result)) | ||
| expect(payload).toEqual({ balance: 500 }) | ||
| }) | ||
|
|
||
| it('should extract usage for matching period', async () => { | ||
| const mockStream = createMockStream(JSON.stringify({ usage: { week: { used: 10 }, month: { used: 33 } } })) | ||
| vi.mocked(api.callNutrientApi).mockResolvedValueOnce({ | ||
| data: mockStream, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| headers: {}, | ||
| config: {} as InternalAxiosRequestConfig, | ||
| }) | ||
|
|
||
| const result = await performCheckCreditsCall('usage', 'month') | ||
|
|
||
| expect(result.isError).toBe(false) | ||
|
|
||
| const payload = JSON.parse(getTextContent(result)) | ||
| expect(payload).toEqual({ period: 'month', usage: { used: 33 } }) | ||
| }) | ||
|
|
||
| it('should return raw text for non-JSON responses', async () => { | ||
| const mockStream = createMockStream('not json') | ||
| vi.mocked(api.callNutrientApi).mockResolvedValueOnce({ | ||
| data: mockStream, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| headers: {}, | ||
| config: {} as InternalAxiosRequestConfig, | ||
| }) | ||
|
|
||
| const result = await performCheckCreditsCall('balance') | ||
|
|
||
| expect(result.isError).toBe(false) | ||
| expect(getTextContent(result)).toBe('not json') | ||
| }) | ||
|
|
||
| it('should handle API errors', async () => { | ||
| vi.mocked(api.callNutrientApi).mockRejectedValueOnce(new Error('Boom')) | ||
|
|
||
| const result = await performCheckCreditsCall('balance') | ||
|
|
||
| expect(result.isError).toBe(true) | ||
| expect(getTextContent(result)).toContain('Error: Boom') | ||
| }) | ||
|
|
||
| it('should return full JSON when balance cannot be extracted', async () => { | ||
| const apiPayload = { credits: { spent: 12 }, other: 'value' } | ||
| const mockStream = createMockStream(JSON.stringify(apiPayload)) | ||
| vi.mocked(api.callNutrientApi).mockResolvedValueOnce({ | ||
| data: mockStream, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| headers: {}, | ||
| config: {} as InternalAxiosRequestConfig, | ||
| }) | ||
|
|
||
| const result = await performCheckCreditsCall('balance') | ||
|
|
||
| expect(result.isError).toBe(false) | ||
| expect(JSON.parse(getTextContent(result))).toEqual(apiPayload) | ||
| }) | ||
| }) | ||
|
|
||
| describe('performDirectoryTreeCall', () => { | ||
| it('should return a tree structure for a valid directory', async () => { | ||
| vi.spyOn(fs.promises, 'access').mockImplementation(async () => {}) | ||
|
|
@@ -499,6 +581,31 @@ describe('API Functions', () => { | |
| }) | ||
| }) | ||
|
|
||
| describe('CheckCreditsArgsSchema', () => { | ||
| it('should accept balance without period', () => { | ||
| const result = CheckCreditsArgsSchema.parse({ action: 'balance' }) | ||
| expect(result.action).toBe('balance') | ||
| expect(result.period).toBeUndefined() | ||
| }) | ||
|
|
||
| it('should accept usage with explicit period', () => { | ||
| const result = CheckCreditsArgsSchema.parse({ action: 'usage', period: 'month' }) | ||
| expect(result.action).toBe('usage') | ||
| expect(result.period).toBe('month') | ||
| }) | ||
|
|
||
| it('should allow usage without period', () => { | ||
| const result = CheckCreditsArgsSchema.parse({ action: 'usage' }) | ||
| expect(result.action).toBe('usage') | ||
| expect(result.period).toBeUndefined() | ||
| }) | ||
|
|
||
| it('should reject invalid action', () => { | ||
| const result = CheckCreditsArgsSchema.safeParse({ action: 'invalid' }) | ||
| expect(result.success).toBe(false) | ||
| }) | ||
| }) | ||
|
Comment on lines
+584
to
+607
|
||
|
|
||
| describe('Sandbox Functionality', () => { | ||
| beforeEach(async () => { | ||
| vi.resetAllMocks() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a hallucination?
account/infoper documentation doesn't offer breakdown information. It only return on a 200 status, a json object with the following schema: