This is page 4 of 7. Use http://codebase.md/stripe/agent-toolkit?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.yml
│ └── workflows
│ ├── main.yml
│ ├── npm_release_shared.yml
│ ├── pypi_release.yml
│ └── sync-skills.yml
├── .gitignore
├── .vscode
│ ├── extensions.json
│ ├── launch.json
│ └── settings.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── gemini-extension.json
├── LICENSE
├── llm
│ ├── ai-sdk
│ │ ├── jest.config.ts
│ │ ├── LICENSE
│ │ ├── meter
│ │ │ ├── examples
│ │ │ │ ├── .env.example
│ │ │ │ ├── .gitignore
│ │ │ │ ├── anthropic.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── openai.ts
│ │ │ │ ├── README.md
│ │ │ │ └── tsconfig.json
│ │ │ ├── index.ts
│ │ │ ├── meter-event-logging.ts
│ │ │ ├── meter-event-types.ts
│ │ │ ├── README.md
│ │ │ ├── tests
│ │ │ │ ├── ai-sdk-billing-wrapper-anthropic.test.ts
│ │ │ │ ├── ai-sdk-billing-wrapper-general.test.ts
│ │ │ │ ├── ai-sdk-billing-wrapper-google.test.ts
│ │ │ │ ├── ai-sdk-billing-wrapper-openai.test.ts
│ │ │ │ ├── ai-sdk-billing-wrapper-other-providers.test.ts
│ │ │ │ ├── meter-event-logging.test.ts
│ │ │ │ └── model-name-normalization.test.ts
│ │ │ ├── tsconfig.json
│ │ │ ├── types.ts
│ │ │ ├── utils.ts
│ │ │ └── wrapperV2.ts
│ │ ├── package.json
│ │ ├── pnpm-lock.yaml
│ │ ├── provider
│ │ │ ├── examples
│ │ │ │ ├── .env.example
│ │ │ │ ├── .gitignore
│ │ │ │ ├── anthropic.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── openai.ts
│ │ │ │ ├── README.md
│ │ │ │ └── tsconfig.json
│ │ │ ├── index.ts
│ │ │ ├── README.md
│ │ │ ├── stripe-language-model.ts
│ │ │ ├── stripe-provider.ts
│ │ │ ├── tests
│ │ │ │ ├── stripe-language-model.test.ts
│ │ │ │ ├── stripe-provider.test.ts
│ │ │ │ └── utils.test.ts
│ │ │ ├── tsconfig.build.json
│ │ │ ├── tsconfig.json
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── README.md
│ └── token-meter
│ ├── examples
│ │ ├── anthropic.ts
│ │ ├── gemini.ts
│ │ └── openai.ts
│ ├── index.ts
│ ├── jest.config.ts
│ ├── LICENSE
│ ├── meter-event-logging.ts
│ ├── meter-event-types.ts
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── README.md
│ ├── tests
│ │ ├── meter-event-logging.test.ts
│ │ ├── model-name-normalization.test.ts
│ │ ├── token-meter-anthropic.test.ts
│ │ ├── token-meter-gemini.test.ts
│ │ ├── token-meter-general.test.ts
│ │ ├── token-meter-openai.test.ts
│ │ └── type-detection.test.ts
│ ├── token-meter.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.json
│ ├── types.ts
│ └── utils
│ └── type-detection.ts
├── README.md
├── SECURITY.md
├── skills
│ ├── get-started-kiro.md
│ ├── README.md
│ ├── stripe-best-practices.md
│ └── sync.js
└── tools
├── modelcontextprotocol
│ ├── .dxtignore
│ ├── .gitignore
│ ├── .node-version
│ ├── .prettierrc
│ ├── build-dxt.js
│ ├── Dockerfile
│ ├── eslint.config.mjs
│ ├── jest.config.ts
│ ├── LICENSE
│ ├── manifest.json
│ ├── package.json
│ ├── pnpm-lock.yaml
│ ├── README.md
│ ├── server.json
│ ├── src
│ │ ├── index.ts
│ │ └── test
│ │ └── index.test.ts
│ ├── stripe_icon.png
│ └── tsconfig.json
├── python
│ ├── .editorconfig
│ ├── .flake8
│ ├── examples
│ │ ├── crewai
│ │ │ ├── .env.template
│ │ │ ├── main.py
│ │ │ └── README.md
│ │ ├── langchain
│ │ │ ├── __init__.py
│ │ │ ├── .env.template
│ │ │ ├── main.py
│ │ │ └── README.md
│ │ ├── openai
│ │ │ ├── .env.template
│ │ │ ├── customer_support
│ │ │ │ ├── .env.template
│ │ │ │ ├── emailer.py
│ │ │ │ ├── env.py
│ │ │ │ ├── main.py
│ │ │ │ ├── pyproject.toml
│ │ │ │ ├── README.md
│ │ │ │ ├── repl.py
│ │ │ │ └── support_agent.py
│ │ │ ├── file_search
│ │ │ │ ├── main.py
│ │ │ │ └── README.md
│ │ │ └── web_search
│ │ │ ├── .env.template
│ │ │ ├── main.py
│ │ │ └── README.md
│ │ └── strands
│ │ └── main.py
│ ├── Makefile
│ ├── pyproject.toml
│ ├── README.md
│ ├── requirements.txt
│ ├── stripe_agent_toolkit
│ │ ├── __init__.py
│ │ ├── api.py
│ │ ├── configuration.py
│ │ ├── crewai
│ │ │ ├── tool.py
│ │ │ └── toolkit.py
│ │ ├── functions.py
│ │ ├── langchain
│ │ │ ├── tool.py
│ │ │ └── toolkit.py
│ │ ├── openai
│ │ │ ├── hooks.py
│ │ │ ├── tool.py
│ │ │ └── toolkit.py
│ │ ├── prompts.py
│ │ ├── schema.py
│ │ ├── strands
│ │ │ ├── __init__.py
│ │ │ ├── hooks.py
│ │ │ ├── tool.py
│ │ │ └── toolkit.py
│ │ └── tools.py
│ └── tests
│ ├── __init__.py
│ ├── test_configuration.py
│ └── test_functions.py
├── README.md
└── typescript
├── .gitignore
├── .prettierrc
├── eslint.config.mjs
├── examples
│ ├── ai-sdk
│ │ ├── .env.template
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ └── tsconfig.json
│ ├── cloudflare
│ │ ├── .dev.vars.example
│ │ ├── .gitignore
│ │ ├── biome.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app.ts
│ │ │ ├── imageGenerator.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── worker-configuration.d.ts
│ │ └── wrangler.jsonc
│ ├── langchain
│ │ ├── .env.template
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ └── tsconfig.json
│ └── openai
│ ├── .env.template
│ ├── index.ts
│ ├── package.json
│ ├── README.md
│ └── tsconfig.json
├── jest.config.ts
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── src
│ ├── ai-sdk
│ │ ├── index.ts
│ │ ├── tool.ts
│ │ └── toolkit.ts
│ ├── cloudflare
│ │ ├── index.ts
│ │ └── README.md
│ ├── langchain
│ │ ├── index.ts
│ │ ├── tool.ts
│ │ └── toolkit.ts
│ ├── modelcontextprotocol
│ │ ├── index.ts
│ │ ├── README.md
│ │ ├── register-paid-tool.ts
│ │ └── toolkit.ts
│ ├── openai
│ │ ├── index.ts
│ │ └── toolkit.ts
│ ├── shared
│ │ ├── api.ts
│ │ ├── balance
│ │ │ └── retrieveBalance.ts
│ │ ├── configuration.ts
│ │ ├── coupons
│ │ │ ├── createCoupon.ts
│ │ │ └── listCoupons.ts
│ │ ├── customers
│ │ │ ├── createCustomer.ts
│ │ │ └── listCustomers.ts
│ │ ├── disputes
│ │ │ ├── listDisputes.ts
│ │ │ └── updateDispute.ts
│ │ ├── documentation
│ │ │ └── searchDocumentation.ts
│ │ ├── invoiceItems
│ │ │ └── createInvoiceItem.ts
│ │ ├── invoices
│ │ │ ├── createInvoice.ts
│ │ │ ├── finalizeInvoice.ts
│ │ │ └── listInvoices.ts
│ │ ├── paymentIntents
│ │ │ └── listPaymentIntents.ts
│ │ ├── paymentLinks
│ │ │ └── createPaymentLink.ts
│ │ ├── prices
│ │ │ ├── createPrice.ts
│ │ │ └── listPrices.ts
│ │ ├── products
│ │ │ ├── createProduct.ts
│ │ │ └── listProducts.ts
│ │ ├── refunds
│ │ │ └── createRefund.ts
│ │ ├── subscriptions
│ │ │ ├── cancelSubscription.ts
│ │ │ ├── listSubscriptions.ts
│ │ │ └── updateSubscription.ts
│ │ └── tools.ts
│ └── test
│ ├── modelcontextprotocol
│ │ └── register-paid-tool.test.ts
│ └── shared
│ ├── balance
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ ├── configuration.test.ts
│ ├── customers
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ ├── disputes
│ │ └── functions.test.ts
│ ├── documentation
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ ├── invoiceItems
│ │ ├── functions.test.ts
│ │ ├── parameters.test.ts
│ │ └── prompts.test.ts
│ ├── invoices
│ │ ├── functions.test.ts
│ │ ├── parameters.test.ts
│ │ └── prompts.test.ts
│ ├── paymentIntents
│ │ ├── functions.test.ts
│ │ ├── parameters.test.ts
│ │ └── prompts.test.ts
│ ├── paymentLinks
│ │ ├── functions.test.ts
│ │ ├── parameters.test.ts
│ │ └── prompts.test.ts
│ ├── prices
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ ├── products
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ ├── refunds
│ │ ├── functions.test.ts
│ │ └── parameters.test.ts
│ └── subscriptions
│ ├── functions.test.ts
│ ├── parameters.test.ts
│ └── prompts.test.ts
├── tsconfig.json
└── tsup.config.ts
```
# Files
--------------------------------------------------------------------------------
/llm/ai-sdk/meter/tests/ai-sdk-billing-wrapper-general.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * General tests for AI SDK billing wrapper functionality
3 | * Tests provider detection, model wrapping, and error handling with mocks
4 | */
5 |
6 | import Stripe from 'stripe';
7 | import {openai} from '@ai-sdk/openai';
8 | import {anthropic} from '@ai-sdk/anthropic';
9 | import {google} from '@ai-sdk/google';
10 | import {meteredModel} from '../index';
11 | import {determineProvider} from '../utils';
12 |
13 | // Mock Stripe
14 | jest.mock('stripe');
15 |
16 | describe('AI SDK Billing Wrapper - General', () => {
17 | let mockMeterEventsCreate: jest.Mock;
18 | const TEST_API_KEY = 'sk_test_mock_key';
19 |
20 | beforeEach(() => {
21 | mockMeterEventsCreate = jest.fn().mockResolvedValue({});
22 |
23 | // Mock the Stripe constructor
24 | (Stripe as unknown as jest.Mock).mockImplementation(() => ({
25 | v2: {
26 | billing: {
27 | meterEvents: {
28 | create: mockMeterEventsCreate,
29 | },
30 | },
31 | },
32 | }));
33 | });
34 |
35 | afterEach(() => {
36 | jest.clearAllMocks();
37 | });
38 |
39 | describe('Provider Detection', () => {
40 | it('should correctly detect OpenAI provider', () => {
41 | expect(determineProvider('openai')).toBe('openai');
42 | expect(determineProvider('openai.chat')).toBe('openai');
43 | });
44 |
45 | it('should correctly detect Anthropic provider', () => {
46 | expect(determineProvider('anthropic')).toBe('anthropic');
47 | expect(determineProvider('anthropic.messages')).toBe('anthropic');
48 | });
49 |
50 | it('should correctly detect Google provider', () => {
51 | expect(determineProvider('google')).toBe('google');
52 | expect(determineProvider('google-generative-ai')).toBe('google');
53 | expect(determineProvider('gemini')).toBe('google');
54 | });
55 |
56 | it('should correctly detect Azure provider', () => {
57 | expect(determineProvider('azure')).toBe('azure');
58 | expect(determineProvider('azure-openai')).toBe('azure');
59 | });
60 |
61 | it('should correctly detect Bedrock provider', () => {
62 | expect(determineProvider('bedrock')).toBe('bedrock');
63 | expect(determineProvider('amazon-bedrock')).toBe('bedrock');
64 | });
65 |
66 | it('should correctly detect other providers', () => {
67 | expect(determineProvider('groq')).toBe('groq');
68 | expect(determineProvider('huggingface')).toBe('huggingface');
69 | expect(determineProvider('together')).toBe('together');
70 | });
71 |
72 | it('should return lowercased provider name for unknown providers', () => {
73 | expect(determineProvider('unknown-provider')).toBe('unknown-provider');
74 | expect(determineProvider('Custom-Provider')).toBe('custom-provider');
75 | expect(determineProvider('MY-NEW-AI')).toBe('my-new-ai');
76 | });
77 | });
78 |
79 | describe('Model Wrapping', () => {
80 | it('should return wrapped model with same specification version', () => {
81 | const originalModel = openai('gpt-4o-mini');
82 | const wrappedModel = meteredModel(originalModel, TEST_API_KEY, 'cus_test123');
83 |
84 | expect(wrappedModel.specificationVersion).toBe(originalModel.specificationVersion);
85 | });
86 |
87 | it('should preserve model ID', () => {
88 | const originalModel = openai('gpt-4o-mini');
89 | const wrappedModel = meteredModel(originalModel, TEST_API_KEY, 'cus_test123');
90 |
91 | expect(wrappedModel.modelId).toBe(originalModel.modelId);
92 | });
93 |
94 | it('should preserve provider', () => {
95 | const originalModel = openai('gpt-4o-mini');
96 | const wrappedModel = meteredModel(originalModel, TEST_API_KEY, 'cus_test123');
97 |
98 | expect(wrappedModel.provider).toBe(originalModel.provider);
99 | });
100 | });
101 |
102 | describe('Error Handling', () => {
103 | it('should throw error for model without specification version', () => {
104 | const mockModel = {
105 | modelId: 'test-model',
106 | provider: 'test-provider',
107 | } as any;
108 |
109 | expect(() => {
110 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
111 | }).toThrow('Only LanguageModelV2 models are supported');
112 | });
113 |
114 | it('should throw error for unsupported specification version', () => {
115 | const mockModel = {
116 | modelId: 'test-model',
117 | provider: 'test-provider',
118 | specificationVersion: 'v99',
119 | } as any;
120 |
121 | expect(() => {
122 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
123 | }).toThrow('Only LanguageModelV2 models are supported');
124 | });
125 |
126 | it('should provide clear error messages', () => {
127 | const mockModel = {
128 | modelId: 'test-model',
129 | provider: 'test-provider',
130 | specificationVersion: 'v1',
131 | } as any;
132 |
133 | expect(() => {
134 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
135 | }).toThrow(/specificationVersion "v2"/);
136 | expect(() => {
137 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
138 | }).toThrow(/OpenAI, Anthropic, Google/);
139 | });
140 | });
141 |
142 | describe('Multi-Provider Integration', () => {
143 | it('should work with different providers', () => {
144 | const openaiModel = meteredModel(openai('gpt-4o-mini'), TEST_API_KEY, 'cus_test');
145 | const anthropicModel = meteredModel(anthropic('claude-3-5-haiku-20241022'), TEST_API_KEY, 'cus_test');
146 | const googleModel = meteredModel(google('gemini-2.5-flash'), TEST_API_KEY, 'cus_test');
147 |
148 | // Verify all models are wrapped correctly
149 | expect(openaiModel).toBeDefined();
150 | expect(anthropicModel).toBeDefined();
151 | expect(googleModel).toBeDefined();
152 |
153 | // Verify model IDs are preserved
154 | expect(openaiModel.modelId).toBe('gpt-4o-mini');
155 | expect(anthropicModel.modelId).toBe('claude-3-5-haiku-20241022');
156 | expect(googleModel.modelId).toBe('gemini-2.5-flash');
157 | });
158 |
159 | it('should support custom v2 provider', () => {
160 | const customModel = {
161 | modelId: 'custom-123',
162 | provider: 'custom-ai',
163 | specificationVersion: 'v2',
164 | doGenerate: jest.fn(),
165 | doStream: jest.fn(),
166 | } as any;
167 |
168 | const wrapped = meteredModel(customModel, TEST_API_KEY, 'cus_test');
169 |
170 | expect(wrapped).toBeDefined();
171 | expect(wrapped.provider).toBe('custom-ai');
172 | expect(wrapped.modelId).toBe('custom-123');
173 | });
174 | });
175 | });
176 |
```
--------------------------------------------------------------------------------
/tools/modelcontextprotocol/src/test/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {main} from '../index';
2 | import {parseArgs} from '../index';
3 | import {StripeAgentToolkit} from '@stripe/agent-toolkit/modelcontextprotocol';
4 | import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
5 | describe('parseArgs function', () => {
6 | describe('success cases', () => {
7 | it('should parse api-key, tools and stripe-header arguments correctly', () => {
8 | const args = [
9 | '--api-key=sk_test_123',
10 | '--tools=all',
11 | '--stripe-account=acct_123',
12 | ];
13 | const options = parseArgs(args);
14 | expect(options.apiKey).toBe('sk_test_123');
15 | expect(options.tools).toEqual(['all']);
16 | expect(options.stripeAccount).toBe('acct_123');
17 | });
18 |
19 | it('should parse api-key and tools arguments correctly', () => {
20 | const args = ['--api-key=sk_test_123', '--tools=all'];
21 | const options = parseArgs(args);
22 | expect(options.apiKey).toBe('sk_test_123');
23 | expect(options.tools).toEqual(['all']);
24 | });
25 |
26 | it('should parse restricted api key correctly', () => {
27 | const args = ['--api-key=rk_test_123', '--tools=all'];
28 | const options = parseArgs(args);
29 | expect(options.apiKey).toBe('rk_test_123');
30 | expect(options.tools).toEqual(['all']);
31 | });
32 |
33 | it('if api key set in env variable, should parse tools argument correctly', () => {
34 | process.env.STRIPE_SECRET_KEY = 'sk_test_123';
35 | const args = ['--tools=all'];
36 | const options = parseArgs(args);
37 | expect(options.apiKey).toBe('sk_test_123');
38 | expect(options.tools).toEqual(['all']);
39 | });
40 |
41 | it('if api key set in env variable but also passed into args, should prefer args key', () => {
42 | process.env.STRIPE_SECRET_KEY = 'sk_test_123';
43 | const args = ['--api-key=sk_test_456', '--tools=all'];
44 | const options = parseArgs(args);
45 | expect(options.apiKey).toBe('sk_test_456');
46 | expect(options.tools).toEqual(['all']);
47 | delete process.env.STRIPE_SECRET_KEY;
48 | });
49 |
50 | it('should parse tools argument correctly if a list of tools is provided', () => {
51 | const args = [
52 | '--api-key=sk_test_123',
53 | '--tools=customers.create,products.read,documentation.read',
54 | ];
55 | const options = parseArgs(args);
56 | expect(options.tools).toEqual([
57 | 'customers.create',
58 | 'products.read',
59 | 'documentation.read',
60 | ]);
61 | expect(options.apiKey).toBe('sk_test_123');
62 | });
63 |
64 | it('ignore all arguments not prefixed with --', () => {
65 | const args = [
66 | '--api-key=sk_test_123',
67 | '--tools=all',
68 | 'stripe-account=acct_123',
69 | ];
70 | const options = parseArgs(args);
71 | expect(options.apiKey).toBe('sk_test_123');
72 | expect(options.tools).toEqual(['all']);
73 | expect(options.stripeAccount).toBeUndefined();
74 | });
75 | });
76 |
77 | describe('error cases', () => {
78 | it("should throw an error if api-key does not start with 'sk_' or 'rk_'", () => {
79 | const args = ['--api-key=test_123', '--tools=all'];
80 | expect(() => parseArgs(args)).toThrow(
81 | 'API key must start with "sk_" or "rk_".'
82 | );
83 | });
84 |
85 | it('should throw an error if api-key is not provided', () => {
86 | const args = ['--tools=all'];
87 | expect(() => parseArgs(args)).toThrow(
88 | 'Stripe API key not provided. Please either pass it as an argument --api-key=$KEY or set the STRIPE_SECRET_KEY environment variable.'
89 | );
90 | });
91 |
92 | it("should throw an error if stripe-account does not start with 'acct_'", () => {
93 | const args = [
94 | '--api-key=sk_test_123',
95 | '--tools=all',
96 | '--stripe-account=test_123',
97 | ];
98 | expect(() => parseArgs(args)).toThrow(
99 | 'Stripe account must start with "acct_".'
100 | );
101 | });
102 |
103 | it('should throw an error if tools argument is not provided', () => {
104 | const args = ['--api-key=sk_test_123'];
105 | expect(() => parseArgs(args)).toThrow(
106 | 'The --tools arguments must be provided.'
107 | );
108 | });
109 |
110 | it('should throw an error if an invalid argument is provided', () => {
111 | const args = [
112 | '--invalid-arg=value',
113 | '--api-key=sk_test_123',
114 | '--tools=all',
115 | ];
116 | expect(() => parseArgs(args)).toThrow(
117 | 'Invalid argument: invalid-arg. Accepted arguments are: api-key, tools, stripe-account'
118 | );
119 | });
120 |
121 | it('should throw an error if tools is not in accepted tool list', () => {
122 | const args = [
123 | '--api-key=sk_test_123',
124 | '--tools=customers.create,products.read,fake.tool',
125 | ];
126 | expect(() => parseArgs(args)).toThrow(
127 | /Invalid tool: fake\.tool\. Accepted tools are: .*$/
128 | );
129 | });
130 | });
131 | });
132 |
133 | jest.mock('@stripe/agent-toolkit/modelcontextprotocol');
134 | jest.mock('@modelcontextprotocol/sdk/server/stdio.js');
135 |
136 | describe('main function', () => {
137 | beforeEach(() => {
138 | jest.clearAllMocks();
139 | });
140 |
141 | it('should initialize the server with tools=all correctly', async () => {
142 | process.argv = ['node', 'index.js', '--api-key=sk_test_123', '--tools=all'];
143 |
144 | await main();
145 |
146 | expect(StripeAgentToolkit).toHaveBeenCalledWith({
147 | secretKey: 'sk_test_123',
148 | configuration: {
149 | actions: ALL_ACTIONS,
150 | context: {mode: 'modelcontextprotocol'},
151 | },
152 | });
153 |
154 | expect(StdioServerTransport).toHaveBeenCalled();
155 | });
156 |
157 | it('should initialize the server with specific list of tools correctly', async () => {
158 | process.argv = [
159 | 'node',
160 | 'index.js',
161 | '--api-key=sk_test_123',
162 | '--tools=customers.create,products.read,documentation.read',
163 | ];
164 |
165 | await main();
166 |
167 | expect(StripeAgentToolkit).toHaveBeenCalledWith({
168 | secretKey: 'sk_test_123',
169 | configuration: {
170 | actions: {
171 | customers: {
172 | create: true,
173 | },
174 | products: {
175 | read: true,
176 | },
177 | documentation: {
178 | read: true,
179 | },
180 | },
181 | context: {
182 | mode: 'modelcontextprotocol',
183 | },
184 | },
185 | });
186 |
187 | expect(StdioServerTransport).toHaveBeenCalled();
188 | });
189 |
190 | it('should initialize the server with stripe header', async () => {
191 | process.argv = [
192 | 'node',
193 | 'index.js',
194 | '--api-key=sk_test_123',
195 | '--tools=all',
196 | '--stripe-account=acct_123',
197 | ];
198 |
199 | await main();
200 |
201 | expect(StripeAgentToolkit).toHaveBeenCalledWith({
202 | secretKey: 'sk_test_123',
203 | configuration: {
204 | actions: ALL_ACTIONS,
205 | context: {account: 'acct_123', mode: 'modelcontextprotocol'},
206 | },
207 | });
208 |
209 | expect(StdioServerTransport).toHaveBeenCalled();
210 | });
211 | });
212 |
213 | const ALL_ACTIONS = {
214 | customers: {
215 | create: true,
216 | read: true,
217 | },
218 | coupons: {
219 | create: true,
220 | read: true,
221 | },
222 | invoices: {
223 | create: true,
224 | update: true,
225 | read: true,
226 | },
227 | invoiceItems: {
228 | create: true,
229 | },
230 | paymentLinks: {
231 | create: true,
232 | },
233 | products: {
234 | create: true,
235 | read: true,
236 | },
237 | prices: {
238 | create: true,
239 | read: true,
240 | },
241 | balance: {
242 | read: true,
243 | },
244 | refunds: {
245 | create: true,
246 | },
247 | subscriptions: {
248 | read: true,
249 | update: true,
250 | },
251 | paymentIntents: {
252 | read: true,
253 | },
254 | disputes: {
255 | read: true,
256 | update: true,
257 | },
258 | documentation: {
259 | read: true,
260 | },
261 | };
262 |
```
--------------------------------------------------------------------------------
/tools/typescript/src/test/shared/subscriptions/functions.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {listSubscriptions} from '@/shared/subscriptions/listSubscriptions';
2 | import {cancelSubscription} from '@/shared/subscriptions/cancelSubscription';
3 | import {updateSubscription} from '@/shared/subscriptions/updateSubscription';
4 |
5 | const Stripe = jest.fn().mockImplementation(() => ({
6 | subscriptions: {
7 | list: jest.fn(),
8 | cancel: jest.fn(),
9 | update: jest.fn(),
10 | },
11 | }));
12 |
13 | let stripe: ReturnType<typeof Stripe>;
14 |
15 | beforeEach(() => {
16 | stripe = new Stripe('fake-api-key');
17 | });
18 |
19 | describe('listSubscriptions', () => {
20 | it('should list subscriptions and return data', async () => {
21 | const mockSubscriptions = [
22 | {
23 | id: 'sub_123456',
24 | customer: 'cus_123456',
25 | status: 'active',
26 | current_period_start: 1609459200, // 2021-01-01
27 | current_period_end: 1612137600, // 2021-02-01
28 | items: {
29 | data: [
30 | {
31 | id: 'si_123',
32 | price: 'price_123',
33 | quantity: 1,
34 | },
35 | ],
36 | },
37 | },
38 | {
39 | id: 'sub_789012',
40 | customer: 'cus_123456',
41 | status: 'canceled',
42 | current_period_start: 1609459200, // 2021-01-01
43 | current_period_end: 1612137600, // 2021-02-01
44 | items: {
45 | data: [
46 | {
47 | id: 'si_456',
48 | price: 'price_456',
49 | quantity: 2,
50 | },
51 | ],
52 | },
53 | },
54 | ];
55 |
56 | const context = {};
57 | const params = {};
58 |
59 | stripe.subscriptions.list.mockResolvedValue({data: mockSubscriptions});
60 | const result = await listSubscriptions(stripe, context, params);
61 |
62 | expect(stripe.subscriptions.list).toHaveBeenCalledWith(params, undefined);
63 | expect(result).toEqual(mockSubscriptions);
64 | });
65 |
66 | it('should add customer from context if provided', async () => {
67 | const mockSubscriptions = [
68 | {
69 | id: 'sub_123456',
70 | customer: 'cus_123456',
71 | status: 'active',
72 | current_period_start: 1609459200,
73 | current_period_end: 1612137600,
74 | items: {
75 | data: [
76 | {
77 | id: 'si_123',
78 | price: 'price_123',
79 | quantity: 1,
80 | },
81 | ],
82 | },
83 | },
84 | ];
85 |
86 | const context = {
87 | customer: 'cus_123456',
88 | };
89 | const params = {};
90 |
91 | stripe.subscriptions.list.mockResolvedValue({data: mockSubscriptions});
92 | const result = await listSubscriptions(stripe, context, params);
93 |
94 | expect(stripe.subscriptions.list).toHaveBeenCalledWith(
95 | {customer: 'cus_123456'},
96 | undefined
97 | );
98 | expect(result).toEqual(mockSubscriptions);
99 | });
100 |
101 | it('should specify the connected account if included in context', async () => {
102 | const mockSubscriptions = [
103 | {
104 | id: 'sub_123456',
105 | customer: 'cus_123456',
106 | status: 'active',
107 | current_period_start: 1609459200,
108 | current_period_end: 1612137600,
109 | items: {
110 | data: [
111 | {
112 | id: 'si_123',
113 | price: 'price_123',
114 | quantity: 1,
115 | },
116 | ],
117 | },
118 | },
119 | ];
120 |
121 | const context = {
122 | account: 'acct_123456',
123 | };
124 | const params = {};
125 |
126 | stripe.subscriptions.list.mockResolvedValue({data: mockSubscriptions});
127 | const result = await listSubscriptions(stripe, context, params);
128 |
129 | expect(stripe.subscriptions.list).toHaveBeenCalledWith(params, {
130 | stripeAccount: context.account,
131 | });
132 | expect(result).toEqual(mockSubscriptions);
133 | });
134 |
135 | it('should handle errors gracefully', async () => {
136 | const context = {};
137 | const params = {};
138 |
139 | stripe.subscriptions.list.mockRejectedValue(new Error('API Error'));
140 | const result = await listSubscriptions(stripe, context, params);
141 |
142 | expect(result).toBe('Failed to list subscriptions');
143 | });
144 | });
145 |
146 | describe('cancelSubscription', () => {
147 | it('should cancel a subscription and return the result', async () => {
148 | const mockSubscription = {
149 | id: 'sub_123456',
150 | customer: 'cus_123456',
151 | status: 'active',
152 | current_period_start: 1609459200,
153 | current_period_end: 1612137600,
154 | items: {
155 | data: [
156 | {
157 | id: 'si_123',
158 | price: 'price_123',
159 | quantity: 1,
160 | },
161 | ],
162 | },
163 | };
164 |
165 | const context = {};
166 | const params = {
167 | subscription: 'sub_123456',
168 | };
169 |
170 | stripe.subscriptions.cancel.mockResolvedValue(mockSubscription);
171 | const result = await cancelSubscription(stripe, context, params);
172 |
173 | expect(stripe.subscriptions.cancel).toHaveBeenCalledWith(
174 | 'sub_123456',
175 | {},
176 | undefined
177 | );
178 | expect(result).toEqual(mockSubscription);
179 | });
180 |
181 | it('should handle errors gracefully', async () => {
182 | const context = {};
183 | const params = {
184 | subscription: 'sub_123456',
185 | };
186 |
187 | stripe.subscriptions.cancel.mockRejectedValue(new Error('API Error'));
188 | const result = await cancelSubscription(stripe, context, params);
189 |
190 | expect(result).toBe('Failed to cancel subscription');
191 | });
192 | });
193 |
194 | describe('updateSubscription', () => {
195 | it('should update a subscription and return the result', async () => {
196 | const mockSubscription = {
197 | id: 'sub_123456',
198 | customer: 'cus_123456',
199 | status: 'active',
200 | current_period_start: 1609459200,
201 | current_period_end: 1612137600,
202 | items: {
203 | data: [
204 | {
205 | id: 'si_123',
206 | price: 'price_123',
207 | quantity: 1,
208 | },
209 | ],
210 | },
211 | };
212 |
213 | const context = {};
214 | const params = {
215 | subscription: 'sub_123456',
216 | items: [
217 | {
218 | id: 'si_123',
219 | quantity: 2,
220 | },
221 | ],
222 | };
223 |
224 | stripe.subscriptions.update.mockResolvedValue(mockSubscription);
225 | const result = await updateSubscription(stripe, context, params);
226 |
227 | expect(stripe.subscriptions.update).toHaveBeenCalledWith(
228 | 'sub_123456',
229 | {
230 | items: [
231 | {
232 | id: 'si_123',
233 | quantity: 2,
234 | },
235 | ],
236 | },
237 | undefined
238 | );
239 | expect(result).toEqual(mockSubscription);
240 | });
241 |
242 | it('should handle errors gracefully', async () => {
243 | const context = {};
244 | const params = {
245 | subscription: 'sub_123456',
246 | items: [
247 | {
248 | id: 'si_123',
249 | quantity: 2,
250 | },
251 | ],
252 | };
253 |
254 | stripe.subscriptions.update.mockRejectedValue(new Error('API Error'));
255 | const result = await updateSubscription(stripe, context, params);
256 |
257 | expect(result).toBe('Failed to update subscription');
258 | });
259 |
260 | it('should specify the connected account if included in context', async () => {
261 | const mockSubscription = {
262 | id: 'sub_123456',
263 | customer: 'cus_123456',
264 | status: 'active',
265 | current_period_start: 1609459200,
266 | current_period_end: 1612137600,
267 | items: {
268 | data: [
269 | {
270 | id: 'si_123',
271 | price: 'price_123',
272 | quantity: 1,
273 | },
274 | ],
275 | },
276 | };
277 |
278 | const context = {
279 | account: 'acct_123456',
280 | };
281 | const params = {
282 | subscription: 'sub_123456',
283 | cancel_at_period_end: true,
284 | };
285 |
286 | stripe.subscriptions.update.mockResolvedValue(mockSubscription);
287 | const result = await updateSubscription(stripe, context, params);
288 |
289 | expect(stripe.subscriptions.update).toHaveBeenCalledWith(
290 | 'sub_123456',
291 | {
292 | cancel_at_period_end: true,
293 | },
294 | {
295 | stripeAccount: context.account,
296 | }
297 | );
298 | expect(result).toEqual(mockSubscription);
299 | });
300 | });
301 |
```
--------------------------------------------------------------------------------
/llm/token-meter/token-meter.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Generic token metering implementation
3 | */
4 |
5 | import Stripe from 'stripe';
6 | import type OpenAI from 'openai';
7 | import type {Stream} from 'openai/streaming';
8 | import type Anthropic from '@anthropic-ai/sdk';
9 | import type {Stream as AnthropicStream} from '@anthropic-ai/sdk/streaming';
10 | import type {
11 | GenerateContentResult,
12 | GenerateContentStreamResult,
13 | } from '@google/generative-ai';
14 | import type {MeterConfig} from './types';
15 | import {logUsageEvent} from './meter-event-logging';
16 | import {
17 | detectResponse,
18 | isGeminiStream,
19 | extractUsageFromChatStream,
20 | extractUsageFromResponseStream,
21 | extractUsageFromAnthropicStream,
22 | type DetectedResponse,
23 | } from './utils/type-detection';
24 |
25 | /**
26 | * Supported response types from all AI providers
27 | */
28 | export type SupportedResponse =
29 | | OpenAI.ChatCompletion
30 | | OpenAI.Responses.Response
31 | | OpenAI.CreateEmbeddingResponse
32 | | Anthropic.Messages.Message
33 | | GenerateContentResult;
34 |
35 | /**
36 | * Supported stream types from all AI providers
37 | */
38 | export type SupportedStream =
39 | | Stream<OpenAI.ChatCompletionChunk>
40 | | Stream<OpenAI.Responses.ResponseStreamEvent>
41 | | AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
42 | | GenerateContentStreamResult;
43 |
44 | /**
45 | * Generic token meter interface
46 | */
47 | export interface TokenMeter {
48 | /**
49 | * Track usage from any supported response type (fire-and-forget)
50 | * Automatically detects provider and response type
51 | */
52 | trackUsage(response: SupportedResponse, stripeCustomerId: string): void;
53 |
54 | /**
55 | * Track usage from OpenAI streaming response
56 | * Model name is automatically extracted from the stream
57 | * Returns the wrapped stream for consumption
58 | */
59 | trackUsageStreamOpenAI<
60 | T extends
61 | | Stream<OpenAI.ChatCompletionChunk>
62 | | Stream<OpenAI.Responses.ResponseStreamEvent>
63 | >(
64 | stream: T,
65 | stripeCustomerId: string
66 | ): T;
67 |
68 | /**
69 | * Track usage from Anthropic streaming response
70 | * Model name is automatically extracted from the stream
71 | * Returns the wrapped stream for consumption
72 | */
73 | trackUsageStreamAnthropic(
74 | stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>,
75 | stripeCustomerId: string
76 | ): AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>;
77 |
78 | /**
79 | * Track usage from Gemini/Google streaming response
80 | * Model name must be provided as Gemini streams don't include it
81 | * Returns the wrapped stream for consumption
82 | */
83 | trackUsageStreamGemini(
84 | stream: GenerateContentStreamResult,
85 | stripeCustomerId: string,
86 | modelName: string
87 | ): GenerateContentStreamResult;
88 | }
89 |
90 | /**
91 | * Create a generic token meter that works with any supported AI provider
92 | *
93 | * @param stripeApiKey - Your Stripe API key
94 | * @param config - Optional configuration for the meter
95 | * @returns TokenMeter instance for tracking usage
96 | */
97 | export function createTokenMeter(
98 | stripeApiKey: string,
99 | config: MeterConfig = {}
100 | ): TokenMeter {
101 | // Construct Stripe client with the API key
102 | const stripeClient = new Stripe(stripeApiKey, {
103 | appInfo: {
104 | name: '@stripe/token-meter',
105 | version: '0.1.0',
106 | },
107 | });
108 | return {
109 | trackUsage(response: SupportedResponse, stripeCustomerId: string): void {
110 | const detected = detectResponse(response);
111 |
112 | if (!detected) {
113 | console.warn(
114 | 'Unable to detect response type. Supported types: OpenAI ChatCompletion, Responses API, Embeddings'
115 | );
116 | return;
117 | }
118 |
119 | // Fire-and-forget logging
120 | logUsageEvent(stripeClient, config, {
121 | model: detected.model,
122 | provider: detected.provider,
123 | usage: {
124 | inputTokens: detected.inputTokens,
125 | outputTokens: detected.outputTokens,
126 | },
127 | stripeCustomerId,
128 | });
129 | },
130 |
131 | trackUsageStreamGemini(
132 | stream: GenerateContentStreamResult,
133 | stripeCustomerId: string,
134 | modelName: string
135 | ): GenerateContentStreamResult {
136 | const originalStream = stream.stream;
137 |
138 | const wrappedStream = (async function* () {
139 | let lastUsageMetadata: any = null;
140 |
141 | for await (const chunk of originalStream) {
142 | if (chunk.usageMetadata) {
143 | lastUsageMetadata = chunk.usageMetadata;
144 | }
145 | yield chunk;
146 | }
147 |
148 | // Log usage after stream completes
149 | if (lastUsageMetadata) {
150 | const baseOutputTokens = lastUsageMetadata?.candidatesTokenCount ?? 0;
151 | // thoughtsTokenCount is for extended thinking models, may not always be present
152 | const reasoningTokens = (lastUsageMetadata as any)?.thoughtsTokenCount ?? 0;
153 |
154 | logUsageEvent(stripeClient, config, {
155 | model: modelName,
156 | provider: 'google',
157 | usage: {
158 | inputTokens: lastUsageMetadata?.promptTokenCount ?? 0,
159 | outputTokens: baseOutputTokens + reasoningTokens,
160 | },
161 | stripeCustomerId,
162 | });
163 | }
164 | })();
165 |
166 | // Return the wrapped structure
167 | return {
168 | stream: wrappedStream,
169 | response: stream.response,
170 | };
171 | },
172 |
173 | trackUsageStreamOpenAI<
174 | T extends
175 | | Stream<OpenAI.ChatCompletionChunk>
176 | | Stream<OpenAI.Responses.ResponseStreamEvent>
177 | >(stream: T, stripeCustomerId: string): T {
178 | const [peekStream, stream2] = stream.tee();
179 |
180 | (async () => {
181 | // Peek at the first chunk to determine stream type
182 | const [stream1, meterStream] = peekStream.tee();
183 | const reader = stream1[Symbol.asyncIterator]();
184 | const firstChunk = await reader.next();
185 |
186 | let detected: DetectedResponse | null = null;
187 |
188 | if (!firstChunk.done && firstChunk.value) {
189 | const chunk = firstChunk.value as any;
190 |
191 | // Check if it's an OpenAI Chat stream (has choices array)
192 | if ('choices' in chunk && Array.isArray(chunk.choices)) {
193 | detected = await extractUsageFromChatStream(meterStream as any);
194 | }
195 | // Check if it's an OpenAI Response API stream (has type starting with 'response.')
196 | else if (chunk.type && typeof chunk.type === 'string' && chunk.type.startsWith('response.')) {
197 | detected = await extractUsageFromResponseStream(meterStream as any);
198 | }
199 | else {
200 | console.warn('Unable to detect OpenAI stream type from first chunk:', chunk);
201 | }
202 | }
203 |
204 | if (detected) {
205 | logUsageEvent(stripeClient, config, {
206 | model: detected.model,
207 | provider: detected.provider,
208 | usage: {
209 | inputTokens: detected.inputTokens,
210 | outputTokens: detected.outputTokens,
211 | },
212 | stripeCustomerId,
213 | });
214 | } else {
215 | console.warn('Unable to extract usage from OpenAI stream');
216 | }
217 | })();
218 |
219 | return stream2 as T;
220 | },
221 |
222 | trackUsageStreamAnthropic(
223 | stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>,
224 | stripeCustomerId: string
225 | ): AnthropicStream<Anthropic.Messages.RawMessageStreamEvent> {
226 | const [peekStream, stream2] = stream.tee();
227 |
228 | (async () => {
229 | const detected = await extractUsageFromAnthropicStream(peekStream);
230 |
231 | if (detected) {
232 | logUsageEvent(stripeClient, config, {
233 | model: detected.model,
234 | provider: detected.provider,
235 | usage: {
236 | inputTokens: detected.inputTokens,
237 | outputTokens: detected.outputTokens,
238 | },
239 | stripeCustomerId,
240 | });
241 | } else {
242 | console.warn('Unable to extract usage from Anthropic stream');
243 | }
244 | })();
245 |
246 | return stream2;
247 | },
248 | };
249 | }
250 |
251 |
```
--------------------------------------------------------------------------------
/tools/typescript/src/test/modelcontextprotocol/register-paid-tool.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {z} from 'zod';
2 | import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
3 | import {registerPaidTool} from '../../modelcontextprotocol/register-paid-tool';
4 | import Stripe from 'stripe';
5 | import type {
6 | ServerNotification,
7 | ServerRequest,
8 | } from '@modelcontextprotocol/sdk/types.js';
9 | import type {RequestHandlerExtra} from '@modelcontextprotocol/sdk/shared/protocol.js';
10 |
11 | // Mock Stripe
12 | jest.mock('stripe');
13 | const mockSecretKey = 'sk_test_123';
14 |
15 | describe('registerPaidTool', () => {
16 | let mockMcpServer: jest.Mocked<McpServer>;
17 | let mockStripe: jest.Mocked<any>;
18 | let mockExtra: RequestHandlerExtra<ServerRequest, ServerNotification>;
19 |
20 | beforeEach(() => {
21 | // Reset all mocks
22 | jest.clearAllMocks();
23 |
24 | // Mock McpServer
25 | mockMcpServer = {
26 | tool: jest.fn(),
27 | } as any;
28 |
29 | // Mock Stripe instance and methods
30 | mockStripe = {
31 | customers: {
32 | list: jest.fn(),
33 | create: jest.fn(),
34 | },
35 | checkout: {
36 | sessions: {
37 | create: jest.fn(),
38 | retrieve: jest.fn(),
39 | list: jest.fn(),
40 | },
41 | },
42 | subscriptions: {
43 | list: jest.fn(),
44 | },
45 | billing: {
46 | meterEvents: {
47 | create: jest.fn(),
48 | },
49 | },
50 | };
51 |
52 | (Stripe as unknown as jest.Mock).mockImplementation(() => mockStripe);
53 |
54 | // Mock request handler extra
55 | mockExtra = {
56 | signal: new AbortController().signal,
57 | sendNotification: jest.fn(),
58 | sendRequest: jest.fn(),
59 | requestId: '123',
60 | };
61 | });
62 |
63 | it('should register a tool with the McpServer', async () => {
64 | const toolName = 'testTool';
65 | const toolDescription = 'Test tool description';
66 | const paramsSchema = {
67 | testParam: z.string(),
68 | };
69 | const callback = jest.fn();
70 |
71 | // @ts-ignore: https://github.com/modelcontextprotocol/typescript-sdk/issues/494
72 | await registerPaidTool(
73 | mockMcpServer,
74 | toolName,
75 | toolDescription,
76 | paramsSchema,
77 | callback,
78 | {
79 | paymentReason: 'Test payment',
80 | stripeSecretKey: mockSecretKey,
81 | userEmail: '[email protected]',
82 | checkout: {
83 | success_url: 'https://example.com/success',
84 | line_items: [{price: 'price_123', quantity: 1}],
85 | mode: 'subscription',
86 | },
87 | }
88 | );
89 |
90 | expect(mockMcpServer.tool).toHaveBeenCalledWith(
91 | toolName,
92 | toolDescription,
93 | paramsSchema,
94 | expect.any(Function)
95 | );
96 | });
97 |
98 | it('should create a new customer if one does not exist', async () => {
99 | mockStripe.customers.list.mockResolvedValue({data: []});
100 | mockStripe.customers.create.mockResolvedValue({id: 'cus_123'});
101 | mockStripe.subscriptions.list.mockResolvedValue({
102 | data: [
103 | {
104 | items: {
105 | data: [
106 | {
107 | price: {
108 | id: 'price_123',
109 | },
110 | },
111 | ],
112 | },
113 | },
114 | ],
115 | });
116 | mockStripe.checkout.sessions.list.mockResolvedValue({data: []});
117 | mockStripe.checkout.sessions.create.mockResolvedValue({
118 | id: 'cs_123',
119 | url: 'https://checkout.stripe.com/123',
120 | });
121 |
122 | const toolName = 'testTool';
123 | const callback = jest.fn();
124 |
125 | await registerPaidTool(
126 | mockMcpServer,
127 | toolName,
128 | 'Test description',
129 | {testParam: z.string()},
130 | callback,
131 | {
132 | paymentReason: 'Test payment',
133 | stripeSecretKey: mockSecretKey,
134 | userEmail: '[email protected]',
135 | checkout: {
136 | success_url: 'https://example.com/success',
137 | line_items: [{price: 'price_123', quantity: 1}],
138 | mode: 'subscription',
139 | },
140 | }
141 | );
142 |
143 | const registeredCallback = mockMcpServer.tool.mock.calls[0]?.[3];
144 | // @ts-ignore: TypeScript can't disambiguate between params schema and annotations
145 | await registeredCallback({testParam: 'test'}, mockExtra);
146 |
147 | expect(mockStripe.customers.list).toHaveBeenCalledWith({
148 | email: '[email protected]',
149 | });
150 | expect(mockStripe.customers.create).toHaveBeenCalledWith({
151 | email: '[email protected]',
152 | });
153 | });
154 |
155 | it('should create a checkout session for unpaid tools', async () => {
156 | mockStripe.customers.list.mockResolvedValue({
157 | data: [{id: 'cus_123', email: '[email protected]'}],
158 | });
159 | mockStripe.checkout.sessions.create.mockResolvedValue({
160 | id: 'cs_123',
161 | url: 'https://checkout.stripe.com/123',
162 | });
163 | mockStripe.subscriptions.list.mockResolvedValue({
164 | data: [], // No active subscriptions
165 | });
166 | mockStripe.checkout.sessions.list.mockResolvedValue({
167 | data: [], // No paid sessions
168 | });
169 |
170 | const toolName = 'testTool';
171 | const callback = jest.fn();
172 |
173 | await registerPaidTool(
174 | mockMcpServer,
175 | toolName,
176 | 'Test description',
177 | {testParam: z.string()},
178 | callback,
179 | {
180 | paymentReason: 'Test payment',
181 | stripeSecretKey: mockSecretKey,
182 | userEmail: '[email protected]',
183 | checkout: {
184 | success_url: 'https://example.com/success',
185 | line_items: [{price: 'price_123', quantity: 1}],
186 | mode: 'subscription',
187 | },
188 | }
189 | );
190 |
191 | const registeredCallback = mockMcpServer.tool.mock.calls[0]?.[3];
192 | // @ts-ignore: TypeScript can't disambiguate between params schema and annotations
193 | const result = await registeredCallback({testParam: 'test'}, mockExtra);
194 |
195 | expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith({
196 | success_url: 'https://example.com/success',
197 | line_items: [
198 | {
199 | price: 'price_123',
200 | quantity: 1,
201 | },
202 | ],
203 | mode: 'subscription',
204 | customer: 'cus_123',
205 | metadata: {toolName},
206 | });
207 | expect(result).toEqual({
208 | content: [
209 | {
210 | type: 'text',
211 | text: JSON.stringify({
212 | status: 'payment_required',
213 | data: {
214 | paymentType: 'oneTimeSubscription',
215 | checkoutUrl: 'https://checkout.stripe.com/123',
216 | paymentReason: 'Test payment',
217 | },
218 | }),
219 | },
220 | ],
221 | });
222 | expect(callback).not.toHaveBeenCalled();
223 | });
224 |
225 | it('should handle usage-based billing when meterEvent is provided', async () => {
226 | const toolName = 'testTool';
227 | mockStripe.customers.list.mockResolvedValue({
228 | data: [{id: 'cus_123', email: '[email protected]'}],
229 | });
230 | mockStripe.checkout.sessions.list.mockResolvedValue({
231 | data: [
232 | {
233 | id: 'cs_123',
234 | metadata: {toolName},
235 | payment_status: 'paid',
236 | subscription: 'sub_123',
237 | },
238 | ],
239 | });
240 | mockStripe.subscriptions.list.mockResolvedValue({
241 | data: [
242 | {
243 | items: {
244 | data: [
245 | {
246 | price: {
247 | id: 'price_123',
248 | },
249 | },
250 | ],
251 | },
252 | },
253 | ],
254 | });
255 | const callback = jest.fn().mockResolvedValue({
256 | content: [{type: 'text', text: 'Success'}],
257 | });
258 |
259 | await registerPaidTool(
260 | mockMcpServer,
261 | toolName,
262 | 'Test description',
263 | {testParam: z.string()},
264 | callback,
265 | {
266 | paymentReason: 'Test payment',
267 | meterEvent: 'test.event',
268 | stripeSecretKey: mockSecretKey,
269 | userEmail: '[email protected]',
270 | checkout: {
271 | success_url: 'https://example.com/success',
272 | line_items: [{price: 'price_123'}],
273 | mode: 'subscription',
274 | },
275 | }
276 | );
277 |
278 | const registeredCallback = mockMcpServer.tool.mock.calls[0]?.[3];
279 | // @ts-ignore: TypeScript can't disambiguate between params schema and annotations
280 | await registeredCallback({testParam: 'test'}, mockExtra);
281 |
282 | expect(mockStripe.billing.meterEvents.create).toHaveBeenCalledWith({
283 | event_name: 'test.event',
284 | payload: {
285 | stripe_customer_id: 'cus_123',
286 | value: '1',
287 | },
288 | });
289 | });
290 | });
291 |
```
--------------------------------------------------------------------------------
/llm/ai-sdk/provider/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Utility functions for Stripe AI SDK Provider
3 | */
4 |
5 | import {
6 | LanguageModelV2Prompt,
7 | LanguageModelV2FinishReason,
8 | } from '@ai-sdk/provider';
9 |
10 | type AssistantMessage = Extract<
11 | LanguageModelV2Prompt[number],
12 | {role: 'assistant'}
13 | >;
14 | type UserMessage = Extract<LanguageModelV2Prompt[number], {role: 'user'}>;
15 |
16 | type AssistantContentPart = AssistantMessage['content'][number];
17 | type UserContentPart = UserMessage['content'][number];
18 |
19 | /**
20 | * Type guards for content parts
21 | */
22 | function isTextPart(
23 | part: AssistantContentPart
24 | ): part is Extract<AssistantContentPart, {type: 'text'}> {
25 | return part.type === 'text';
26 | }
27 |
28 | function isToolCallPart(
29 | part: AssistantContentPart
30 | ): part is Extract<AssistantContentPart, {type: 'tool-call'}> {
31 | return part.type === 'tool-call';
32 | }
33 |
34 | function isUserTextPart(
35 | part: UserContentPart
36 | ): part is Extract<UserContentPart, {type: 'text'}> {
37 | return part.type === 'text';
38 | }
39 |
40 | function isUserFilePart(
41 | part: UserContentPart
42 | ): part is Extract<UserContentPart, {type: 'file'}> {
43 | return part.type === 'file';
44 | }
45 |
46 | /**
47 | * Converts AI SDK V2 prompt to OpenAI-compatible messages format
48 | */
49 | export function convertToOpenAIMessages(
50 | prompt: LanguageModelV2Prompt
51 | ): Array<{
52 | role: string;
53 | content:
54 | | string
55 | | Array<{
56 | type: string;
57 | text?: string;
58 | image_url?: {url: string};
59 | }>;
60 | tool_calls?: Array<{
61 | id: string;
62 | type: string;
63 | function: {name: string; arguments: string};
64 | }>;
65 | tool_call_id?: string;
66 | name?: string;
67 | }> {
68 | return prompt.map((message) => {
69 | switch (message.role) {
70 | case 'system':
71 | return [{
72 | role: 'system',
73 | content: message.content,
74 | }];
75 |
76 | case 'user': {
77 | const contentParts = message.content.map((part) => {
78 | if (isUserTextPart(part)) {
79 | return {
80 | type: 'text',
81 | text: part.text,
82 | };
83 | } else if (isUserFilePart(part)) {
84 | // Convert file data to data URL if needed
85 | let fileUrl: string;
86 | if (typeof part.data === 'string') {
87 | // Could be a URL or base64 data
88 | if (part.data.startsWith('http://') || part.data.startsWith('https://')) {
89 | fileUrl = part.data;
90 | } else {
91 | // Assume it's base64
92 | fileUrl = `data:${part.mediaType};base64,${part.data}`;
93 | }
94 | } else if (part.data instanceof URL) {
95 | fileUrl = part.data.toString();
96 | } else {
97 | // Convert Uint8Array to base64 data URL
98 | const base64 = btoa(
99 | String.fromCharCode(...Array.from(part.data))
100 | );
101 | fileUrl = `data:${part.mediaType};base64,${base64}`;
102 | }
103 |
104 | // For images, use image_url format
105 | if (part.mediaType.startsWith('image/')) {
106 | return {
107 | type: 'image_url',
108 | image_url: {url: fileUrl},
109 | };
110 | }
111 |
112 | // For other file types, use text representation
113 | // (OpenAI format doesn't support arbitrary file types well)
114 | return {
115 | type: 'text',
116 | text: `[File: ${part.filename || 'unknown'}, type: ${part.mediaType}]`,
117 | };
118 | } else {
119 | // TypeScript should prevent this, but handle it for runtime safety
120 | const _exhaustiveCheck: never = part;
121 | throw new Error(`Unsupported user message part type`);
122 | }
123 | });
124 |
125 | // If there's only one text part, send as a simple string
126 | // This is more compatible with Anthropic's requirements
127 | const content =
128 | contentParts.length === 1 && contentParts[0].type === 'text'
129 | ? contentParts[0].text!
130 | : contentParts;
131 |
132 | return [{
133 | role: 'user',
134 | content,
135 | }];
136 | }
137 |
138 | case 'assistant': {
139 | // Extract text content
140 | const textParts = message.content.filter(isTextPart);
141 | const textContent = textParts.length > 0
142 | ? textParts.map((part) => part.text).join('')
143 | : '';
144 |
145 | // Extract tool calls
146 | const toolCallParts = message.content.filter(isToolCallPart);
147 | const toolCalls = toolCallParts.length > 0
148 | ? toolCallParts.map((part) => ({
149 | id: part.toolCallId,
150 | type: 'function' as const,
151 | function: {
152 | name: part.toolName,
153 | arguments:
154 | typeof part.input === 'string'
155 | ? part.input
156 | : JSON.stringify(part.input),
157 | },
158 | }))
159 | : undefined;
160 |
161 | return [{
162 | role: 'assistant',
163 | content: textContent || (toolCalls ? '' : ''),
164 | tool_calls: toolCalls,
165 | }];
166 | }
167 |
168 | case 'tool':
169 | // In OpenAI format, each tool result is a separate message
170 | // Note: This returns an array, so we need to flatten it later
171 | return message.content.map((part) => {
172 | let content: string;
173 |
174 | // Handle different output types
175 | if (part.output.type === 'text') {
176 | content = part.output.value;
177 | } else if (part.output.type === 'json') {
178 | content = JSON.stringify(part.output.value);
179 | } else if (part.output.type === 'error-text') {
180 | content = `Error: ${part.output.value}`;
181 | } else if (part.output.type === 'error-json') {
182 | content = `Error: ${JSON.stringify(part.output.value)}`;
183 | } else if (part.output.type === 'content') {
184 | // Convert content array to string
185 | content = part.output.value
186 | .map((item) => {
187 | if (item.type === 'text') {
188 | return item.text;
189 | } else if (item.type === 'media') {
190 | return `[Media: ${item.mediaType}]`;
191 | }
192 | return '';
193 | })
194 | .join('\n');
195 | } else {
196 | content = String(part.output);
197 | }
198 |
199 | return {
200 | role: 'tool',
201 | tool_call_id: part.toolCallId,
202 | name: part.toolName,
203 | content,
204 | };
205 | });
206 |
207 | default:
208 | // TypeScript should ensure we never get here, but just in case
209 | const exhaustiveCheck: never = message;
210 | throw new Error(`Unsupported message role: ${JSON.stringify(exhaustiveCheck)}`);
211 | }
212 | }).flat();
213 | }
214 |
215 | /**
216 | * Maps OpenAI finish reasons to AI SDK V2 finish reasons
217 | */
218 | export function mapOpenAIFinishReason(
219 | finishReason: string | null | undefined
220 | ): LanguageModelV2FinishReason {
221 | switch (finishReason) {
222 | case 'stop':
223 | return 'stop';
224 | case 'length':
225 | return 'length';
226 | case 'content_filter':
227 | return 'content-filter';
228 | case 'tool_calls':
229 | case 'function_call':
230 | return 'tool-calls';
231 | default:
232 | return 'unknown';
233 | }
234 | }
235 |
236 | /**
237 | * Normalize model names to match Stripe's approved model list
238 | * This function handles provider-specific normalization rules:
239 | * - Anthropic: Removes date suffixes, -latest suffix, converts version dashes to dots
240 | * - OpenAI: Removes date suffixes (with exceptions)
241 | * - Google: Returns as-is
242 | *
243 | * @param modelId - Model ID in provider/model format (e.g., 'anthropic/claude-3-5-sonnet-20241022')
244 | * @returns Normalized model ID in the same format
245 | */
246 | export function normalizeModelId(modelId: string): string {
247 | // Split the model ID into provider and model parts
248 | const parts = modelId.split('/');
249 | if (parts.length !== 2) {
250 | // If format is not provider/model, return as-is
251 | return modelId;
252 | }
253 |
254 | const [provider, model] = parts;
255 | const normalizedProvider = provider.toLowerCase();
256 | let normalizedModel = model;
257 |
258 | if (normalizedProvider === 'anthropic') {
259 | // Remove date suffix (YYYYMMDD format at the end)
260 | normalizedModel = normalizedModel.replace(/-\d{8}$/, '');
261 |
262 | // Remove -latest suffix
263 | normalizedModel = normalizedModel.replace(/-latest$/, '');
264 |
265 | // Convert version number dashes to dots anywhere in the name
266 | // Match patterns like claude-3-7, opus-4-1, sonnet-4-5, etc.
267 | normalizedModel = normalizedModel.replace(/(-[a-z]+)?-(\d+)-(\d+)/g, '$1-$2.$3');
268 | } else if (normalizedProvider === 'openai') {
269 | // Exception: keep gpt-4o-2024-05-13 as is
270 | if (normalizedModel === 'gpt-4o-2024-05-13') {
271 | return modelId;
272 | }
273 |
274 | // Remove date suffix in format -YYYY-MM-DD
275 | normalizedModel = normalizedModel.replace(/-\d{4}-\d{2}-\d{2}$/, '');
276 | }
277 | // For other providers (google/gemini), return as is
278 |
279 | return `${provider}/${normalizedModel}`;
280 | }
281 |
```
--------------------------------------------------------------------------------
/llm/token-meter/tests/token-meter-general.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for TokenMeter - General Functionality
3 | * Tests for cross-provider features, unknown types, and edge cases
4 | */
5 |
6 | import Stripe from 'stripe';
7 | import {createTokenMeter} from '../token-meter';
8 | import type {MeterConfig} from '../types';
9 |
10 | // Mock Stripe
11 | jest.mock('stripe');
12 |
13 | describe('TokenMeter - General Functionality', () => {
14 | let mockMeterEventsCreate: jest.Mock;
15 | let config: MeterConfig;
16 | let consoleWarnSpy: jest.SpyInstance;
17 | const TEST_API_KEY = 'sk_test_mock_key';
18 |
19 | beforeEach(() => {
20 | jest.clearAllMocks();
21 | mockMeterEventsCreate = jest.fn().mockResolvedValue({});
22 |
23 | // Mock the Stripe constructor
24 | (Stripe as unknown as jest.Mock).mockImplementation(() => ({
25 | v2: {
26 | billing: {
27 | meterEvents: {
28 | create: mockMeterEventsCreate,
29 | },
30 | },
31 | },
32 | }));
33 |
34 | config = {};
35 | consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
36 | });
37 |
38 | afterEach(() => {
39 | consoleWarnSpy.mockRestore();
40 | });
41 |
42 | describe('Unknown Response Types', () => {
43 | it('should warn and not log for unknown response formats', async () => {
44 | const meter = createTokenMeter(TEST_API_KEY, config);
45 |
46 | const unknownResponse = {
47 | some: 'unknown',
48 | response: 'format',
49 | that: 'does not match any provider',
50 | };
51 |
52 | meter.trackUsage(unknownResponse as any, 'cus_123');
53 |
54 | expect(consoleWarnSpy).toHaveBeenCalledWith(
55 | 'Unable to detect response type. Supported types: OpenAI ChatCompletion, Responses API, Embeddings'
56 | );
57 | await new Promise(resolve => setImmediate(resolve));
58 | expect(mockMeterEventsCreate).not.toHaveBeenCalled();
59 | });
60 |
61 | it('should handle null response', async () => {
62 | const meter = createTokenMeter(TEST_API_KEY, config);
63 |
64 | meter.trackUsage(null as any, 'cus_123');
65 |
66 | expect(consoleWarnSpy).toHaveBeenCalled();
67 | await new Promise(resolve => setImmediate(resolve));
68 | expect(mockMeterEventsCreate).not.toHaveBeenCalled();
69 | });
70 |
71 | it('should handle undefined response', async () => {
72 | const meter = createTokenMeter(TEST_API_KEY, config);
73 |
74 | meter.trackUsage(undefined as any, 'cus_123');
75 |
76 | expect(consoleWarnSpy).toHaveBeenCalled();
77 | await new Promise(resolve => setImmediate(resolve));
78 | expect(mockMeterEventsCreate).not.toHaveBeenCalled();
79 | });
80 |
81 | it('should handle empty object response', async () => {
82 | const meter = createTokenMeter(TEST_API_KEY, config);
83 |
84 | meter.trackUsage({} as any, 'cus_123');
85 |
86 | expect(consoleWarnSpy).toHaveBeenCalled();
87 | await new Promise(resolve => setImmediate(resolve));
88 | expect(mockMeterEventsCreate).not.toHaveBeenCalled();
89 | });
90 | });
91 |
92 | describe('Customer ID Handling', () => {
93 | it('should pass customer ID correctly for different providers', async () => {
94 | const meter = createTokenMeter(TEST_API_KEY, config);
95 | const customerId = 'cus_special_id_123';
96 |
97 | // OpenAI
98 | const openaiResponse = {
99 | id: 'chatcmpl-123',
100 | object: 'chat.completion',
101 | created: Date.now(),
102 | model: 'gpt-4',
103 | choices: [{index: 0, message: {role: 'assistant', content: 'Hi'}, finish_reason: 'stop'}],
104 | usage: {prompt_tokens: 5, completion_tokens: 2, total_tokens: 7},
105 | };
106 |
107 | meter.trackUsage(openaiResponse as any, customerId);
108 |
109 | await new Promise(resolve => setImmediate(resolve));
110 |
111 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
112 | expect.objectContaining({
113 | payload: expect.objectContaining({
114 | stripe_customer_id: customerId,
115 | }),
116 | })
117 | );
118 |
119 | mockMeterEventsCreate.mockClear();
120 |
121 | // Anthropic
122 | const anthropicResponse = {
123 | id: 'msg_123',
124 | type: 'message',
125 | role: 'assistant',
126 | content: [{type: 'text', text: 'Hi'}],
127 | model: 'claude-3-5-sonnet-20241022',
128 | stop_reason: 'end_turn',
129 | stop_sequence: null,
130 | usage: {input_tokens: 5, output_tokens: 2},
131 | };
132 |
133 | meter.trackUsage(anthropicResponse as any, customerId);
134 |
135 | await new Promise(resolve => setImmediate(resolve));
136 |
137 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
138 | expect.objectContaining({
139 | payload: expect.objectContaining({
140 | stripe_customer_id: customerId,
141 | }),
142 | })
143 | );
144 |
145 | mockMeterEventsCreate.mockClear();
146 |
147 | // Gemini
148 | const geminiResponse = {
149 | response: {
150 | text: () => 'Hi',
151 | usageMetadata: {promptTokenCount: 5, candidatesTokenCount: 2, totalTokenCount: 7},
152 | modelVersion: 'gemini-1.5-pro',
153 | },
154 | };
155 |
156 | meter.trackUsage(geminiResponse as any, customerId);
157 |
158 | await new Promise(resolve => setImmediate(resolve));
159 |
160 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
161 | expect.objectContaining({
162 | payload: expect.objectContaining({
163 | stripe_customer_id: customerId,
164 | }),
165 | })
166 | );
167 | });
168 | });
169 |
170 | describe('Multiple Meter Instances', () => {
171 | it('should allow multiple independent meter instances', async () => {
172 | const config1: MeterConfig = {};
173 | const config2: MeterConfig = {};
174 |
175 | const meter1 = createTokenMeter(TEST_API_KEY, config1);
176 | const meter2 = createTokenMeter(TEST_API_KEY, config2);
177 |
178 | const response = {
179 | id: 'chatcmpl-123',
180 | object: 'chat.completion',
181 | created: Date.now(),
182 | model: 'gpt-4',
183 | choices: [{index: 0, message: {role: 'assistant', content: 'Hi'}, finish_reason: 'stop'}],
184 | usage: {prompt_tokens: 5, completion_tokens: 2, total_tokens: 7},
185 | };
186 |
187 | meter1.trackUsage(response as any, 'cus_123');
188 | meter2.trackUsage(response as any, 'cus_456');
189 |
190 | await new Promise(resolve => setImmediate(resolve));
191 |
192 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
193 | expect.objectContaining({
194 | payload: expect.objectContaining({stripe_customer_id: 'cus_123'}),
195 | })
196 | );
197 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
198 | expect.objectContaining({
199 | payload: expect.objectContaining({stripe_customer_id: 'cus_456'}),
200 | })
201 | );
202 | });
203 | });
204 |
205 | describe('Provider Detection Accuracy', () => {
206 | it('should correctly identify provider from response shape alone', async () => {
207 | const meter = createTokenMeter(TEST_API_KEY, config);
208 |
209 | // Test that provider is detected purely from response structure
210 | const responses = [
211 | {
212 | response: {
213 | id: 'chatcmpl-123',
214 | object: 'chat.completion',
215 | model: 'gpt-4',
216 | choices: [{message: {content: 'test'}}],
217 | usage: {prompt_tokens: 1, completion_tokens: 1},
218 | },
219 | expectedProvider: 'openai',
220 | },
221 | {
222 | response: {
223 | id: 'msg_123',
224 | type: 'message',
225 | role: 'assistant',
226 | content: [{type: 'text', text: 'test'}],
227 | model: 'claude-3-opus',
228 | usage: {input_tokens: 1, output_tokens: 1},
229 | },
230 | expectedProvider: 'anthropic',
231 | },
232 | {
233 | response: {
234 | response: {
235 | text: () => 'test',
236 | usageMetadata: {promptTokenCount: 1, candidatesTokenCount: 1},
237 | modelVersion: 'gemini-2.0-flash-exp',
238 | },
239 | },
240 | expectedProvider: 'google',
241 | },
242 | ];
243 |
244 | for (const {response, expectedProvider} of responses) {
245 | mockMeterEventsCreate.mockClear();
246 | meter.trackUsage(response as any, 'cus_123');
247 |
248 | await new Promise(resolve => setImmediate(resolve));
249 |
250 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
251 | expect.objectContaining({
252 | payload: expect.objectContaining({
253 | model: expect.stringContaining(expectedProvider + '/'),
254 | }),
255 | })
256 | );
257 | }
258 | });
259 | });
260 | });
261 |
262 | // Helper function to create mock streams with tee()
263 | function createMockStreamWithTee(chunks: any[]) {
264 | return {
265 | tee() {
266 | const stream1 = {
267 | async *[Symbol.asyncIterator]() {
268 | for (const chunk of chunks) {
269 | yield chunk;
270 | }
271 | },
272 | tee() {
273 | const s1 = {
274 | async *[Symbol.asyncIterator]() {
275 | for (const chunk of chunks) {
276 | yield chunk;
277 | }
278 | },
279 | };
280 | const s2 = {
281 | async *[Symbol.asyncIterator]() {
282 | for (const chunk of chunks) {
283 | yield chunk;
284 | }
285 | },
286 | };
287 | return [s1, s2];
288 | },
289 | };
290 | const stream2 = {
291 | async *[Symbol.asyncIterator]() {
292 | for (const chunk of chunks) {
293 | yield chunk;
294 | }
295 | },
296 | };
297 | return [stream1, stream2];
298 | },
299 | async *[Symbol.asyncIterator]() {
300 | for (const chunk of chunks) {
301 | yield chunk;
302 | }
303 | },
304 | };
305 | }
306 |
307 |
```
--------------------------------------------------------------------------------
/llm/ai-sdk/meter/tests/ai-sdk-billing-wrapper-other-providers.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for AI SDK billing wrapper with other providers and unsupported models
3 | * Tests error handling and edge cases with mocks
4 | */
5 |
6 | import Stripe from 'stripe';
7 | import {createOpenAI} from '@ai-sdk/openai';
8 | import {meteredModel} from '../index';
9 |
10 | // Mock Stripe
11 | jest.mock('stripe');
12 |
13 | describe('AI SDK Billing Wrapper - Other Providers', () => {
14 | let mockMeterEventsCreate: jest.Mock;
15 | const TEST_API_KEY = 'sk_test_mock_key';
16 |
17 | beforeEach(() => {
18 | mockMeterEventsCreate = jest.fn().mockResolvedValue({});
19 |
20 | // Mock the Stripe constructor
21 | (Stripe as unknown as jest.Mock).mockImplementation(() => ({
22 | v2: {
23 | billing: {
24 | meterEvents: {
25 | create: mockMeterEventsCreate,
26 | },
27 | },
28 | },
29 | }));
30 | });
31 |
32 | afterEach(() => {
33 | jest.clearAllMocks();
34 | });
35 |
36 | describe('OpenAI-Compatible Providers', () => {
37 | it('should work with Together AI (OpenAI-compatible)', async () => {
38 | const together = createOpenAI({
39 | apiKey: 'mock-key',
40 | baseURL: 'https://api.together.xyz/v1',
41 | });
42 |
43 | const model = meteredModel(
44 | together('meta-llama/Llama-3-70b-chat-hf'),
45 | TEST_API_KEY,
46 | 'cus_test123'
47 | );
48 |
49 | const originalModel = together('meta-llama/Llama-3-70b-chat-hf');
50 | jest.spyOn(originalModel, 'doGenerate').mockResolvedValue({
51 | text: 'Together AI response',
52 | usage: {
53 | inputTokens: 12,
54 | outputTokens: 5,
55 | },
56 | finishReason: 'stop',
57 | rawResponse: {},
58 | warnings: [],
59 | } as any);
60 |
61 | // Copy the mock to our wrapped model's internal model
62 | (model as any).model.doGenerate = originalModel.doGenerate;
63 |
64 | await model.doGenerate({
65 | inputFormat: 'prompt',
66 | mode: {type: 'regular'},
67 | prompt: [],
68 | } as any);
69 |
70 | // Wait for fire-and-forget logging to complete
71 | await new Promise(resolve => setImmediate(resolve));
72 |
73 | expect(mockMeterEventsCreate).toHaveBeenCalledTimes(2);
74 | });
75 |
76 | it('should work with custom OpenAI-compatible providers', async () => {
77 | const customProvider = createOpenAI({
78 | apiKey: 'mock-key',
79 | baseURL: 'https://custom-ai.example.com/v1',
80 | });
81 |
82 | const model = meteredModel(
83 | customProvider('custom-model-v1'),
84 | TEST_API_KEY,
85 | 'cus_test123'
86 | );
87 |
88 | const originalModel = customProvider('custom-model-v1');
89 | jest.spyOn(originalModel, 'doGenerate').mockResolvedValue({
90 | text: 'Custom response',
91 | usage: {
92 | inputTokens: 8,
93 | outputTokens: 3,
94 | },
95 | finishReason: 'stop',
96 | rawResponse: {},
97 | warnings: [],
98 | } as any);
99 |
100 | (model as any).model.doGenerate = originalModel.doGenerate;
101 |
102 | await model.doGenerate({
103 | inputFormat: 'prompt',
104 | mode: {type: 'regular'},
105 | prompt: [],
106 | } as any);
107 |
108 | // Wait for fire-and-forget logging to complete
109 | await new Promise(resolve => setImmediate(resolve));
110 |
111 | expect(mockMeterEventsCreate).toHaveBeenCalledTimes(2);
112 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
113 | expect.objectContaining({
114 | payload: expect.objectContaining({
115 | value: '8',
116 | token_type: 'input',
117 | }),
118 | })
119 | );
120 | });
121 | });
122 |
123 | describe('Unsupported Models', () => {
124 | it('should throw error for model with v1 specification', () => {
125 | const mockModel = {
126 | modelId: 'mock-model-v1',
127 | provider: 'mock-provider',
128 | specificationVersion: 'v1',
129 | doGenerate: jest.fn(),
130 | doStream: jest.fn(),
131 | } as any;
132 |
133 | expect(() => {
134 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
135 | }).toThrow('Only LanguageModelV2 models are supported');
136 | });
137 |
138 | it('should throw error for model without specification version', () => {
139 | const mockModel = {
140 | modelId: 'mock-model-unknown',
141 | provider: 'unknown-provider',
142 | doGenerate: jest.fn(),
143 | doStream: jest.fn(),
144 | } as any;
145 |
146 | expect(() => {
147 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
148 | }).toThrow('Only LanguageModelV2 models are supported');
149 | });
150 |
151 | it('should throw error for model with unknown specification version', () => {
152 | const mockModel = {
153 | modelId: 'mock-model-v99',
154 | provider: 'future-provider',
155 | specificationVersion: 'v99',
156 | doGenerate: jest.fn(),
157 | doStream: jest.fn(),
158 | } as any;
159 |
160 | expect(() => {
161 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
162 | }).toThrow('Only LanguageModelV2 models are supported');
163 | });
164 |
165 | it('should provide clear error message', () => {
166 | const mockModel = {specificationVersion: 'v1'} as any;
167 |
168 | expect(() => {
169 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
170 | }).toThrow(/Only LanguageModelV2 models are supported/);
171 | expect(() => {
172 | meteredModel(mockModel, TEST_API_KEY, 'cus_test123');
173 | }).toThrow(/Please use a supported provider/);
174 | });
175 | });
176 |
177 | describe('Provider Support', () => {
178 | it('should support any v2 provider name', async () => {
179 | const customProviderModel = {
180 | modelId: 'custom-model-123',
181 | provider: 'my-custom-provider',
182 | specificationVersion: 'v2',
183 | doGenerate: jest.fn().mockResolvedValue({
184 | text: 'Custom response',
185 | usage: {
186 | inputTokens: 10,
187 | outputTokens: 5,
188 | },
189 | finishReason: 'stop',
190 | rawResponse: {},
191 | warnings: [],
192 | }),
193 | doStream: jest.fn(),
194 | } as any;
195 |
196 | const wrapped = meteredModel(
197 | customProviderModel,
198 | TEST_API_KEY,
199 | 'cus_test123'
200 | );
201 |
202 | await wrapped.doGenerate({
203 | inputFormat: 'prompt',
204 | mode: {type: 'regular'},
205 | prompt: [],
206 | } as any);
207 |
208 | // Wait for fire-and-forget logging to complete
209 | await new Promise(resolve => setImmediate(resolve));
210 |
211 | expect(wrapped).toBeDefined();
212 | expect(wrapped.provider).toBe('my-custom-provider');
213 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
214 | expect.objectContaining({
215 | payload: expect.objectContaining({
216 | model: 'my-custom-provider/custom-model-123',
217 | }),
218 | })
219 | );
220 | });
221 |
222 | it('should provide helpful error message for unsupported models', () => {
223 | const unsupportedModel = {
224 | modelId: 'test',
225 | provider: 'test-provider',
226 | specificationVersion: 'v1',
227 | } as any;
228 |
229 | try {
230 | meteredModel(unsupportedModel, TEST_API_KEY, 'cus_test123');
231 | fail('Should have thrown an error');
232 | } catch (error: any) {
233 | expect(error.message).toContain('Only LanguageModelV2 models are supported');
234 | expect(error.message).toContain('specificationVersion "v2"');
235 | expect(error.message).toContain('OpenAI, Anthropic, Google');
236 | }
237 | });
238 | });
239 |
240 | describe('Edge Cases', () => {
241 | it('should handle model with missing usage data', async () => {
242 | const model = {
243 | modelId: 'test-model',
244 | provider: 'test-provider',
245 | specificationVersion: 'v2',
246 | doGenerate: jest.fn().mockResolvedValue({
247 | text: 'Response',
248 | usage: undefined,
249 | finishReason: 'stop',
250 | rawResponse: {},
251 | warnings: [],
252 | }),
253 | doStream: jest.fn(),
254 | } as any;
255 |
256 | const wrapped = meteredModel(model, TEST_API_KEY, 'cus_test123');
257 |
258 | await wrapped.doGenerate({
259 | inputFormat: 'prompt',
260 | mode: {type: 'regular'},
261 | prompt: [],
262 | } as any);
263 |
264 | // Wait for fire-and-forget logging to complete
265 | await new Promise(resolve => setImmediate(resolve));
266 |
267 | // Should not create meter events with 0 tokens (code only sends when > 0)
268 | expect(mockMeterEventsCreate).toHaveBeenCalledTimes(0);
269 | });
270 |
271 | it('should handle model with partial usage data', async () => {
272 | const model = {
273 | modelId: 'test-model',
274 | provider: 'test-provider',
275 | specificationVersion: 'v2',
276 | doGenerate: jest.fn().mockResolvedValue({
277 | text: 'Response',
278 | usage: {
279 | inputTokens: 10,
280 | outputTokens: undefined,
281 | },
282 | finishReason: 'stop',
283 | rawResponse: {},
284 | warnings: [],
285 | }),
286 | doStream: jest.fn(),
287 | } as any;
288 |
289 | const wrapped = meteredModel(model, TEST_API_KEY, 'cus_test123');
290 |
291 | await wrapped.doGenerate({
292 | inputFormat: 'prompt',
293 | mode: {type: 'regular'},
294 | prompt: [],
295 | } as any);
296 |
297 | // Wait for fire-and-forget logging to complete
298 | await new Promise(resolve => setImmediate(resolve));
299 |
300 | // Should handle partial data gracefully - only sends event for input tokens
301 | // Output tokens with value 0 are not sent (code only sends when > 0)
302 | expect(mockMeterEventsCreate).toHaveBeenCalledTimes(1);
303 | expect(mockMeterEventsCreate).toHaveBeenCalledWith(
304 | expect.objectContaining({
305 | payload: expect.objectContaining({
306 | value: '10',
307 | token_type: 'input',
308 | }),
309 | })
310 | );
311 | });
312 | });
313 | });
314 |
```
--------------------------------------------------------------------------------
/llm/token-meter/examples/openai.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Sample Usage: OpenAI with Usage Tracking
3 | * This demonstrates how to use the generic token meter to automatically report
4 | * token usage to Stripe for billing purposes.
5 | */
6 |
7 | import {config} from 'dotenv';
8 | import {resolve} from 'path';
9 | import OpenAI from 'openai';
10 | import {createTokenMeter} from '..';
11 |
12 | // Load .env from the examples folder
13 | config({path: resolve(__dirname, '.env')});
14 |
15 | // Load environment variables from .env file
16 | const STRIPE_API_KEY = process.env.STRIPE_API_KEY!;
17 | const STRIPE_CUSTOMER_ID = process.env.STRIPE_CUSTOMER_ID!;
18 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY!;
19 |
20 | // Initialize the OpenAI client (standard SDK)
21 | const openai = new OpenAI({
22 | apiKey: OPENAI_API_KEY,
23 | });
24 |
25 | // Create the token meter
26 | const meter = createTokenMeter(STRIPE_API_KEY);
27 |
28 | // Sample 1: Basic Chat Completion (non-streaming)
29 | async function sampleBasicChatCompletion() {
30 | const response = await openai.chat.completions.create({
31 | model: 'gpt-4o-mini',
32 | messages: [
33 | {role: 'user', content: 'Say "Hello, World!" and nothing else.'},
34 | ],
35 | max_tokens: 20,
36 | });
37 |
38 | // Track usage with the meter
39 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
40 |
41 | console.log('Response:', response.choices[0]?.message?.content);
42 | console.log('Usage:', response.usage);
43 | }
44 |
45 | // Sample 2: Streaming Chat Completion
46 | async function sampleStreamingChatCompletion() {
47 | const stream = await openai.chat.completions.create({
48 | model: 'gpt-4o-mini',
49 | messages: [
50 | {role: 'user', content: 'Count from 1 to 5, one number per line.'},
51 | ],
52 | stream: true,
53 | stream_options: {include_usage: true}, // Important for metering
54 | max_tokens: 50,
55 | });
56 |
57 | // Wrap the stream for metering
58 | const meteredStream = meter.trackUsageStreamOpenAI(stream, STRIPE_CUSTOMER_ID);
59 |
60 | let fullContent = '';
61 | for await (const chunk of meteredStream) {
62 | const content = chunk.choices[0]?.delta?.content || '';
63 | fullContent += content;
64 | process.stdout.write(content);
65 | }
66 |
67 | console.log('\n\nFull content:', fullContent);
68 | }
69 |
70 | // Sample 3: Chat Completion with Tools
71 | async function sampleChatCompletionWithTools() {
72 | const tools: any[] = [
73 | {
74 | type: 'function',
75 | function: {
76 | name: 'get_weather',
77 | description: 'Get the current weather in a location',
78 | parameters: {
79 | type: 'object',
80 | properties: {
81 | location: {
82 | type: 'string',
83 | description: 'The city and state, e.g. San Francisco, CA',
84 | },
85 | unit: {
86 | type: 'string',
87 | enum: ['celsius', 'fahrenheit'],
88 | },
89 | },
90 | required: ['location'],
91 | },
92 | },
93 | },
94 | ];
95 |
96 | const response = await openai.chat.completions.create({
97 | model: 'gpt-4o-mini',
98 | messages: [{role: 'user', content: 'What is the weather in New York?'}],
99 | tools,
100 | max_tokens: 100,
101 | });
102 |
103 | // Track usage with the meter
104 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
105 |
106 | console.log(
107 | 'Response:',
108 | JSON.stringify(response.choices[0]?.message, null, 2)
109 | );
110 | console.log('Usage:', response.usage);
111 | }
112 |
113 | // Sample 4: Streaming Chat Completion with Tools
114 | async function sampleStreamingChatCompletionWithTools() {
115 | const tools: any[] = [
116 | {
117 | type: 'function',
118 | function: {
119 | name: 'calculate',
120 | description: 'Calculate a mathematical expression',
121 | parameters: {
122 | type: 'object',
123 | properties: {
124 | expression: {
125 | type: 'string',
126 | description: 'The mathematical expression to calculate',
127 | },
128 | },
129 | required: ['expression'],
130 | },
131 | },
132 | },
133 | ];
134 |
135 | const stream = await openai.chat.completions.create({
136 | model: 'gpt-4o-mini',
137 | messages: [{role: 'user', content: 'What is 25 * 4?'}],
138 | tools,
139 | stream: true,
140 | stream_options: {include_usage: true}, // Important for metering
141 | max_tokens: 100,
142 | });
143 |
144 | // Wrap the stream for metering
145 | const meteredStream = meter.trackUsageStreamOpenAI(stream, STRIPE_CUSTOMER_ID);
146 |
147 | const toolCalls = new Map<
148 | number,
149 | {id: string; name: string; arguments: string}
150 | >();
151 |
152 | for await (const chunk of meteredStream) {
153 | const choice = chunk.choices[0];
154 |
155 | // Handle tool calls
156 | if (choice?.delta?.tool_calls) {
157 | for (const toolCall of choice.delta.tool_calls) {
158 | const index = toolCall.index;
159 | if (index === undefined) continue;
160 |
161 | if (!toolCalls.has(index)) {
162 | toolCalls.set(index, {id: '', name: '', arguments: ''});
163 | }
164 | const tc = toolCalls.get(index)!;
165 | if (toolCall.id) tc.id = toolCall.id;
166 | if (toolCall.function?.name) tc.name = toolCall.function.name;
167 | if (toolCall.function?.arguments)
168 | tc.arguments += toolCall.function.arguments;
169 | }
170 | }
171 |
172 | // Print usage if available
173 | if (chunk.usage) {
174 | console.log('\nUsage in stream:', chunk.usage);
175 | }
176 | }
177 |
178 | console.log('\nTool calls:', Array.from(toolCalls.values()));
179 | }
180 |
181 | // Sample 5: Responses API (non-streaming)
182 | async function sampleResponsesAPIBasic() {
183 | const response = await openai.responses.create({
184 | model: 'gpt-4o-mini',
185 | input: 'What is 2+2?',
186 | instructions: 'You are a helpful math assistant.',
187 | max_output_tokens: 50,
188 | });
189 |
190 | // Track usage with the meter
191 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
192 |
193 | console.log('Response:', JSON.stringify(response.output, null, 2));
194 | console.log('Usage:', response.usage);
195 | }
196 |
197 | // Sample 6: Responses API (streaming)
198 | async function sampleResponsesAPIStreaming() {
199 | const stream = await openai.responses.create({
200 | model: 'gpt-4o-mini',
201 | input: 'Tell me a fun fact about cats.',
202 | instructions: 'You are a helpful assistant.',
203 | stream: true,
204 | max_output_tokens: 100,
205 | });
206 |
207 | // Wrap the stream for metering
208 | const meteredStream = meter.trackUsageStreamOpenAI(stream, STRIPE_CUSTOMER_ID);
209 |
210 | let finalOutput: any = null;
211 | let finalUsage: any = null;
212 |
213 | for await (const event of meteredStream) {
214 | if (event.type === 'response.completed' && 'response' in event) {
215 | finalOutput = event.response.output;
216 | finalUsage = event.response.usage;
217 | }
218 | }
219 |
220 | console.log('Final output:', JSON.stringify(finalOutput, null, 2));
221 | console.log('Usage:', finalUsage);
222 | }
223 |
224 | // Sample 7: Responses API with parse (structured outputs)
225 | async function sampleResponsesAPIParse() {
226 | // Note: The schema is defined inline in the API call
227 | // Zod is not needed for this example
228 | const response = await openai.responses.parse({
229 | model: 'gpt-4o-mini',
230 | input: 'What is 15 multiplied by 7?',
231 | instructions:
232 | 'You are a helpful math assistant. Provide the answer and a brief explanation.',
233 | text: {
234 | format: {
235 | type: 'json_schema',
236 | name: 'math_response',
237 | strict: true,
238 | schema: {
239 | type: 'object',
240 | properties: {
241 | answer: {type: 'number'},
242 | explanation: {type: 'string'},
243 | },
244 | required: ['answer', 'explanation'],
245 | additionalProperties: false,
246 | },
247 | },
248 | },
249 | max_output_tokens: 100,
250 | });
251 |
252 | // Track usage with the meter
253 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
254 |
255 | console.log('Parsed response:', response.output[0]);
256 | console.log('Usage:', response.usage);
257 | }
258 |
259 | // Sample 8: Embeddings
260 | async function sampleEmbeddings() {
261 | const response = await openai.embeddings.create({
262 | model: 'text-embedding-3-small',
263 | input: 'The quick brown fox jumps over the lazy dog',
264 | });
265 |
266 | // Track usage with the meter
267 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
268 |
269 | console.log('Embedding dimensions:', response.data[0]?.embedding.length);
270 | console.log('First 5 values:', response.data[0]?.embedding.slice(0, 5));
271 | console.log('Usage:', response.usage);
272 | }
273 |
274 | // Sample 9: Multiple messages conversation
275 | async function sampleConversation() {
276 | const response = await openai.chat.completions.create({
277 | model: 'gpt-4o-mini',
278 | messages: [
279 | {role: 'system', content: 'You are a helpful assistant.'},
280 | {role: 'user', content: 'What is the capital of France?'},
281 | {role: 'assistant', content: 'The capital of France is Paris.'},
282 | {role: 'user', content: 'What is its population?'},
283 | ],
284 | max_tokens: 50,
285 | });
286 |
287 | // Track usage with the meter
288 | meter.trackUsage(response, STRIPE_CUSTOMER_ID);
289 |
290 | console.log('Response:', response.choices[0]?.message?.content);
291 | console.log('Usage:', response.usage);
292 | }
293 |
294 | // Run all samples
295 | async function runAllSamples() {
296 | console.log('Starting OpenAI Usage Tracking Examples');
297 | console.log(
298 | 'These examples show how to use the generic token meter with OpenAI\n'
299 | );
300 |
301 | try {
302 | await sampleBasicChatCompletion();
303 | await sampleStreamingChatCompletion();
304 | await sampleChatCompletionWithTools();
305 | await sampleStreamingChatCompletionWithTools();
306 | await sampleResponsesAPIBasic();
307 | await sampleResponsesAPIStreaming();
308 | await sampleResponsesAPIParse();
309 | await sampleEmbeddings();
310 | await sampleConversation();
311 |
312 | console.log('\n' + '='.repeat(80));
313 | console.log('All examples completed successfully!');
314 | console.log('='.repeat(80));
315 | } catch (error) {
316 | console.error('\n❌ Sample failed:', error);
317 | throw error;
318 | }
319 | }
320 |
321 | // Run the samples
322 | runAllSamples().catch(console.error);
323 |
324 |
```
--------------------------------------------------------------------------------
/llm/ai-sdk/provider/tests/stripe-provider.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for Stripe AI SDK Provider
3 | */
4 |
5 | import {createStripe, stripe} from '../index';
6 | import {StripeLanguageModel} from '../stripe-language-model';
7 |
8 | describe('Stripe Provider', () => {
9 | describe('createStripe', () => {
10 | it('should create a provider instance', () => {
11 | const provider = createStripe({
12 | apiKey: 'sk_test_123',
13 | customerId: 'cus_123',
14 | });
15 |
16 | expect(provider).toBeDefined();
17 | expect(typeof provider).toBe('function');
18 | expect(provider.languageModel).toBeDefined();
19 | });
20 |
21 | it('should create a language model with provider function', () => {
22 | const provider = createStripe({
23 | apiKey: 'sk_test_123',
24 | customerId: 'cus_123',
25 | });
26 |
27 | const model = provider('openai/gpt-5');
28 |
29 | expect(model).toBeInstanceOf(StripeLanguageModel);
30 | expect(model.modelId).toBe('openai/gpt-5');
31 | expect(model.provider).toBe('stripe');
32 | expect(model.specificationVersion).toBe('v2');
33 | });
34 |
35 | it('should create a language model with languageModel method', () => {
36 | const provider = createStripe({
37 | apiKey: 'sk_test_123',
38 | customerId: 'cus_123',
39 | });
40 |
41 | const model = provider.languageModel('google/gemini-2.5-pro');
42 |
43 | expect(model).toBeInstanceOf(StripeLanguageModel);
44 | expect(model.modelId).toBe('google/gemini-2.5-pro');
45 | });
46 |
47 | it('should throw error when called with new keyword', () => {
48 | const provider = createStripe({
49 | apiKey: 'sk_test_123',
50 | });
51 |
52 | expect(() => {
53 | // @ts-expect-error - Testing error case
54 | new provider('openai/gpt-5');
55 | }).toThrow(
56 | 'The Stripe provider function cannot be called with the new keyword.'
57 | );
58 | });
59 |
60 | it('should use default baseURL when not provided', () => {
61 | const provider = createStripe({
62 | apiKey: 'sk_test_123',
63 | customerId: 'cus_123',
64 | });
65 |
66 | const model = provider('openai/gpt-5') as any;
67 | expect(model.config.baseURL).toBe('https://llm.stripe.com');
68 | });
69 |
70 | it('should use custom baseURL when provided', () => {
71 | const provider = createStripe({
72 | apiKey: 'sk_test_123',
73 | customerId: 'cus_123',
74 | baseURL: 'https://custom.stripe.com',
75 | });
76 |
77 | const model = provider('openai/gpt-5') as any;
78 | expect(model.config.baseURL).toBe('https://custom.stripe.com');
79 | });
80 |
81 | it('should merge customer IDs from provider and model settings', () => {
82 | const provider = createStripe({
83 | apiKey: 'sk_test_123',
84 | customerId: 'cus_provider',
85 | });
86 |
87 | const model1 = provider('openai/gpt-5') as any;
88 | expect(model1.settings.customerId).toBe('cus_provider');
89 |
90 | const model2 = provider('openai/gpt-5', {
91 | customerId: 'cus_model',
92 | }) as any;
93 | expect(model2.settings.customerId).toBe('cus_model');
94 | });
95 |
96 | it('should throw error for text embedding models', () => {
97 | const provider = createStripe({
98 | apiKey: 'sk_test_123',
99 | });
100 |
101 | expect(() => {
102 | provider.textEmbeddingModel('openai/text-embedding-3-small');
103 | }).toThrow('Text embedding models are not yet supported by Stripe provider');
104 | });
105 |
106 | it('should throw error for image models', () => {
107 | const provider = createStripe({
108 | apiKey: 'sk_test_123',
109 | });
110 |
111 | expect(() => {
112 | provider.imageModel('openai/dall-e-3');
113 | }).toThrow('Image models are not yet supported by Stripe provider');
114 | });
115 | });
116 |
117 | describe('default stripe provider', () => {
118 | beforeEach(() => {
119 | // Clear environment variable
120 | delete process.env.STRIPE_API_KEY;
121 | });
122 |
123 | it('should be defined', () => {
124 | expect(stripe).toBeDefined();
125 | expect(typeof stripe).toBe('function');
126 | });
127 |
128 | it('should use STRIPE_API_KEY from environment', () => {
129 | process.env.STRIPE_API_KEY = 'sk_test_env';
130 |
131 | // This would throw if API key is not found
132 | const model = stripe('openai/gpt-5', {customerId: 'cus_123'});
133 | expect(model).toBeDefined();
134 | });
135 | });
136 |
137 | describe('API key handling', () => {
138 | it('should throw error when API key is not provided', () => {
139 | delete process.env.STRIPE_API_KEY;
140 |
141 | const provider = createStripe({
142 | customerId: 'cus_123',
143 | });
144 |
145 | expect(() => {
146 | const model = provider('openai/gpt-5') as any;
147 | model.config.headers();
148 | }).toThrow(
149 | 'Stripe API key is required. Provide it via config.apiKey or STRIPE_API_KEY environment variable.'
150 | );
151 | });
152 |
153 | it('should use API key from config', () => {
154 | const provider = createStripe({
155 | apiKey: 'sk_test_config',
156 | customerId: 'cus_123',
157 | });
158 |
159 | const model = provider('openai/gpt-5') as any;
160 | const headers = model.config.headers();
161 |
162 | expect(headers.Authorization).toBe('Bearer sk_test_config');
163 | });
164 |
165 | it('should prefer config API key over environment', () => {
166 | process.env.STRIPE_API_KEY = 'sk_test_env';
167 |
168 | const provider = createStripe({
169 | apiKey: 'sk_test_config',
170 | customerId: 'cus_123',
171 | });
172 |
173 | const model = provider('openai/gpt-5') as any;
174 | const headers = model.config.headers();
175 |
176 | expect(headers.Authorization).toBe('Bearer sk_test_config');
177 | });
178 | });
179 |
180 | describe('headers handling', () => {
181 | it('should include custom headers from provider config', () => {
182 | const provider = createStripe({
183 | apiKey: 'sk_test_123',
184 | customerId: 'cus_123',
185 | headers: {
186 | 'X-Custom': 'provider-value',
187 | },
188 | });
189 |
190 | const model = provider('openai/gpt-5') as any;
191 | const headers = model.config.headers();
192 |
193 | expect(headers['X-Custom']).toBe('provider-value');
194 | });
195 |
196 | it('should include custom headers from model settings', () => {
197 | const provider = createStripe({
198 | apiKey: 'sk_test_123',
199 | });
200 |
201 | const model = provider('openai/gpt-5', {
202 | customerId: 'cus_123',
203 | headers: {
204 | 'X-Model': 'model-value',
205 | },
206 | }) as any;
207 |
208 | expect(model.settings.headers['X-Model']).toBe('model-value');
209 | });
210 | });
211 |
212 | describe('supported models', () => {
213 | const provider = createStripe({
214 | apiKey: 'sk_test_123',
215 | customerId: 'cus_123',
216 | });
217 |
218 | const testModels = [
219 | 'openai/gpt-5',
220 | 'openai/gpt-4.1',
221 | 'openai/o3',
222 | 'google/gemini-2.5-pro',
223 | 'google/gemini-2.0-flash',
224 | 'anthropic/claude-sonnet-4',
225 | 'anthropic/claude-opus-4',
226 | ];
227 |
228 | testModels.forEach((modelId) => {
229 | it(`should create model for ${modelId}`, () => {
230 | const model = provider(modelId);
231 | expect(model.modelId).toBe(modelId);
232 | });
233 | });
234 | });
235 |
236 | describe('model name normalization', () => {
237 | const provider = createStripe({
238 | apiKey: 'sk_test_123',
239 | customerId: 'cus_123',
240 | });
241 |
242 | describe('Anthropic models', () => {
243 | it('should normalize model with date suffix (YYYYMMDD)', () => {
244 | const model = provider('anthropic/claude-3-5-sonnet-20241022');
245 | expect(model.modelId).toBe('anthropic/claude-3.5-sonnet');
246 | });
247 |
248 | it('should normalize model with -latest suffix', () => {
249 | const model = provider('anthropic/claude-sonnet-4-latest');
250 | expect(model.modelId).toBe('anthropic/claude-sonnet-4');
251 | });
252 |
253 | it('should normalize version dashes to dots', () => {
254 | const model = provider('anthropic/claude-3-5-sonnet');
255 | expect(model.modelId).toBe('anthropic/claude-3.5-sonnet');
256 | });
257 |
258 | it('should handle combined normalization (date + version)', () => {
259 | const model = provider('anthropic/claude-3-7-sonnet-20250115');
260 | expect(model.modelId).toBe('anthropic/claude-3.7-sonnet');
261 | });
262 |
263 | it('should normalize sonnet-4-5 to sonnet-4.5', () => {
264 | const model = provider('anthropic/sonnet-4-5');
265 | expect(model.modelId).toBe('anthropic/sonnet-4.5');
266 | });
267 |
268 | it('should normalize opus-4-1 to opus-4.1', () => {
269 | const model = provider('anthropic/opus-4-1');
270 | expect(model.modelId).toBe('anthropic/opus-4.1');
271 | });
272 | });
273 |
274 | describe('OpenAI models', () => {
275 | it('should normalize model with date suffix (YYYY-MM-DD)', () => {
276 | const model = provider('openai/gpt-4-turbo-2024-04-09');
277 | expect(model.modelId).toBe('openai/gpt-4-turbo');
278 | });
279 |
280 | it('should keep gpt-4o-2024-05-13 as exception', () => {
281 | const model = provider('openai/gpt-4o-2024-05-13');
282 | expect(model.modelId).toBe('openai/gpt-4o-2024-05-13');
283 | });
284 |
285 | it('should not normalize YYYYMMDD format (only YYYY-MM-DD)', () => {
286 | const model = provider('openai/gpt-4-20241231');
287 | expect(model.modelId).toBe('openai/gpt-4-20241231');
288 | });
289 | });
290 |
291 | describe('Google models', () => {
292 | it('should keep Google models unchanged', () => {
293 | const model = provider('google/gemini-2.5-pro');
294 | expect(model.modelId).toBe('google/gemini-2.5-pro');
295 | });
296 |
297 | it('should not remove any suffixes from Google models', () => {
298 | const model = provider('google/gemini-2.5-pro-20250101');
299 | expect(model.modelId).toBe('google/gemini-2.5-pro-20250101');
300 | });
301 | });
302 |
303 | describe('Other providers', () => {
304 | it('should keep unknown provider models unchanged', () => {
305 | const model = provider('custom/my-model-1-2-3');
306 | expect(model.modelId).toBe('custom/my-model-1-2-3');
307 | });
308 | });
309 | });
310 | });
311 |
312 |
```
--------------------------------------------------------------------------------
/tools/python/examples/openai/customer_support/emailer.py:
--------------------------------------------------------------------------------
```python
1 | # pyright: strict
2 |
3 | import imaplib
4 | import email
5 | import smtplib
6 | from email.mime.text import MIMEText
7 | from email.message import Message
8 | from email.mime.multipart import MIMEMultipart
9 | from email.utils import parseaddr
10 | from typing import List, Tuple, Callable, Union, Awaitable
11 | import asyncio
12 | import json
13 | import re
14 | from datetime import datetime
15 | from email.utils import parsedate_to_datetime
16 |
17 |
18 | class Email:
19 | def __init__(
20 | self,
21 | from_address: str,
22 | to_address: str,
23 | subject: str,
24 | body: str,
25 | id: str = "",
26 | date: datetime = datetime.now(),
27 | ):
28 | self.id = id
29 | self.to_address = to_address
30 | self.from_address = from_address
31 | self.subject = subject
32 | self.body = body
33 | self.date = date
34 |
35 | def to_message(self, reply_id: str, reply_to: str) -> MIMEMultipart:
36 | msg = MIMEMultipart()
37 | msg["From"] = self.from_address
38 | msg["To"] = self.to_address
39 | msg["Subject"] = self.subject
40 | msg["In-Reply-To"] = reply_id
41 | msg["References"] = reply_id
42 | msg["Reply-To"] = reply_to
43 | msg.attach(MIMEText(f"<html><body>{self.body}</body></html>", "html"))
44 | return msg
45 |
46 | def to_dict(self):
47 | return {
48 | "id": self.id,
49 | "to": self.to_address,
50 | "from": self.from_address,
51 | "subject": self.subject,
52 | "body": self.body,
53 | "date": self.date.strftime("%a, %d %b %Y %H:%M:%S %z"),
54 | }
55 |
56 |
57 | class Emailer:
58 | """
59 | Emailer is an IMAP/SMTP client that can be used to fetch and respond to emails.
60 | It was mostly vibe-coded so please make improvements!
61 | TODO: add agent replies to the context
62 | """
63 |
64 | def __init__(
65 | self,
66 | email_address: str,
67 | email_password: str,
68 | support_address: str = "",
69 | imap_server: str = "imap.gmail.com",
70 | imap_port: int = 993,
71 | smtp_server: str = "smtp.gmail.com",
72 | smtp_port: int = 587,
73 | ):
74 | # Email configuration
75 | self.email_address = email_address
76 | self.support_address = support_address if support_address else email_address
77 | self.email_password = email_password
78 | self.imap_server = imap_server
79 | self.imap_port = imap_port
80 | self.smtp_server = smtp_server
81 | self.smtp_port = smtp_port
82 |
83 | def _connect_to_email(self) -> Tuple[imaplib.IMAP4_SSL, smtplib.SMTP]:
84 | """Establish connections to email servers."""
85 | # Connect to IMAP server
86 | imap_conn = imaplib.IMAP4_SSL(self.imap_server, self.imap_port)
87 | imap_conn.login(self.email_address, self.email_password)
88 |
89 | # Connect to SMTP server
90 | smtp_conn = smtplib.SMTP(self.smtp_server, self.smtp_port)
91 | smtp_conn.starttls()
92 | smtp_conn.login(self.email_address, self.email_password)
93 |
94 | return imap_conn, smtp_conn
95 |
96 | def _get_body(self, email_message: Message) -> str:
97 | body: str = ""
98 | if email_message.is_multipart():
99 | for part in email_message.walk():
100 | if part.get_content_type() == "text/plain":
101 | payload = part.get_payload(decode=True)
102 | if isinstance(payload, bytes):
103 | body = payload.decode()
104 | break
105 | else:
106 | payload = email_message.get_payload(decode=True)
107 | if isinstance(payload, bytes):
108 | body = payload.decode()
109 | else:
110 | body = str(payload)
111 | return self._strip_replies(body)
112 |
113 | def _strip_replies(self, raw_body: str) -> str:
114 | lines = raw_body.split("\n")
115 | pruned: List[str] = []
116 | for line in lines:
117 | # Stop if we see a typical reply indicator
118 | if line.strip().startswith("On ") and " wrote:" in line:
119 | break
120 | pruned.append(line)
121 | return "\n".join(pruned).strip()
122 |
123 | def _parse_email(
124 | self, imap_conn: imaplib.IMAP4_SSL, email_id: bytes
125 | ) -> Union[Email, None]:
126 | _, msg_data = imap_conn.fetch(email_id.decode(), "(BODY.PEEK[])")
127 | if not msg_data or not msg_data[0]:
128 | return None
129 | msg_resp = msg_data[0]
130 | if isinstance(msg_resp, tuple) and len(msg_resp) == 2:
131 | email_body = msg_resp[1]
132 | else:
133 | return None
134 |
135 | email_message = email.message_from_bytes(email_body)
136 | subject = email_message["subject"] or ""
137 | from_address = parseaddr(email_message.get("From", ""))[1]
138 | to_address = parseaddr(email_message.get("To", ""))[1]
139 | date_str = email_message.get("Date", "")
140 | date = datetime.now()
141 | if date_str:
142 | try:
143 | date = parsedate_to_datetime(date_str)
144 | except Exception:
145 | pass
146 |
147 | body = self._get_body(email_message)
148 | return Email(
149 | id=email_id.decode(),
150 | from_address=from_address,
151 | to_address=to_address,
152 | subject=subject,
153 | body=body,
154 | date=date,
155 | )
156 |
157 | def _get_email_thread(
158 | self, imap_conn: imaplib.IMAP4_SSL, email_id_bytes: bytes
159 | ) -> List[Email]:
160 | email = self._parse_email(imap_conn, email_id_bytes)
161 | if not email:
162 | return []
163 |
164 | thread = [email]
165 |
166 | # Try thread via X-GM-THRID (Gmail extension)
167 | _, thrid_data = imap_conn.fetch(email.id, "(X-GM-THRID)")
168 | match = None
169 | if thrid_data and thrid_data[0]:
170 | data = thrid_data[0]
171 | if isinstance(data, bytes):
172 | match = re.search(r"X-GM-THRID\s+(\d+)", data.decode())
173 | else:
174 | match = re.search(r"X-GM-THRID\s+(\d+)", str(data))
175 | if match:
176 | thread_id = match.group(1)
177 | _, thread_ids = imap_conn.search(None, f"X-GM-THRID {thread_id}")
178 | if thread_ids and thread_ids[0]:
179 | thread = [
180 | self._parse_email(imap_conn, mid) for mid in thread_ids[0].split()
181 | ]
182 | thread = [e for e in thread if e]
183 | thread.sort(key=lambda e: e.date)
184 | return thread
185 |
186 | # Fallback: use REFERENCES header
187 | _, ref_data = imap_conn.fetch(
188 | email.id, "(BODY.PEEK[HEADER.FIELDS (REFERENCES)])"
189 | )
190 | if ref_data and ref_data[0]:
191 | ref_line = (
192 | ref_data[0][1].decode() if isinstance(ref_data[0][1], bytes) else ""
193 | )
194 | refs = re.findall(r"<([^>]+)>", ref_line)
195 | for ref in refs:
196 | _, ref_ids = imap_conn.search(None, f'(HEADER Message-ID "<{ref}>")')
197 | if ref_ids and ref_ids[0]:
198 | for ref_id in ref_ids[0].split():
199 | ref_email = self._parse_email(imap_conn, ref_id)
200 | if ref_email and ref_email.id not in [e.id for e in thread]:
201 | thread.append(ref_email)
202 |
203 | # Sort emails in the thread by date (ascending order)
204 | thread.sort(key=lambda e: e.date)
205 | return thread
206 |
207 | return thread
208 |
209 | def _get_unread_emails(self, imap_conn: imaplib.IMAP4_SSL) -> List[List[Email]]:
210 | imap_conn.select("INBOX")
211 | _, msg_nums = imap_conn.search(None, f'(UNSEEN TO "{self.support_address}")')
212 | emails: List[List[Email]] = []
213 |
214 | for email_id in msg_nums[0].split():
215 | thread = self._get_email_thread(imap_conn, email_id)
216 | emails.append(thread)
217 |
218 | return emails
219 |
220 | def mark_as_read(self, imap_conn: imaplib.IMAP4_SSL, message_id: str):
221 | imap_conn.store(message_id, "+FLAGS", "\\Seen")
222 |
223 | def get_email_thread(self, email_id: str) -> List[Email]:
224 | # Connect to email servers
225 | imap_conn, smtp_conn = self._connect_to_email()
226 | imap_conn.select("INBOX")
227 |
228 | # Get the thread
229 | thread = self._get_email_thread(
230 | imap_conn=imap_conn, email_id_bytes=email_id.encode()
231 | )
232 |
233 | # Close connections
234 | imap_conn.logout()
235 | smtp_conn.quit()
236 |
237 | return thread
238 |
239 | async def process(
240 | self,
241 | respond: Callable[[List[Email]], Awaitable[Union[Email, None]]],
242 | mark_read: bool = True,
243 | ):
244 | # Connect to email servers
245 | imap_conn, smtp_conn = self._connect_to_email()
246 |
247 | # Get unread emails
248 | print("Fetching unread emails...")
249 | unread_emails = self._get_unread_emails(imap_conn)
250 | for email_thread in unread_emails:
251 | # Get the most recent email in the thread
252 | most_recent = email_thread[-1]
253 |
254 | # Generate the response
255 | response = await respond(email_thread)
256 |
257 | # If there is no response, skip this email and keep as unread
258 | # in the inbox
259 | if response is None:
260 | continue
261 |
262 | # Send the response
263 | # Get the most recent email in the thread to reply to
264 | print(
265 | f"Replying to '{response.to_address}' with:\n {json.dumps(response.body)}"
266 | )
267 | smtp_conn.send_message(
268 | response.to_message(most_recent.id, self.support_address)
269 | )
270 |
271 | # Mark the original email as read
272 | if mark_read:
273 | self.mark_as_read(imap_conn, most_recent.id)
274 |
275 | # Close connections
276 | imap_conn.logout()
277 | smtp_conn.quit()
278 |
279 | async def run(
280 | self,
281 | respond: Callable[[List[Email]], Awaitable[Union[Email, None]]],
282 | mark_read: bool = True,
283 | delay: int = 60,
284 | ):
285 | while True:
286 | # Process emails
287 | await self.process(respond, mark_read)
288 | # Wait before next check
289 | print(f"Sleeping for {delay}s...")
290 | await asyncio.sleep(delay)
291 |
```
--------------------------------------------------------------------------------
/tools/modelcontextprotocol/eslint.config.mjs:
--------------------------------------------------------------------------------
```
1 | import prettier from "eslint-plugin-prettier";
2 | import _import from "eslint-plugin-import";
3 | import { fixupPluginRules } from "@eslint/compat";
4 | import globals from "globals";
5 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
6 | import path from "node:path";
7 | import { fileURLToPath } from "node:url";
8 | import js from "@eslint/js";
9 | import { FlatCompat } from "@eslint/eslintrc";
10 |
11 | const __filename = fileURLToPath(import.meta.url);
12 | const __dirname = path.dirname(__filename);
13 | const compat = new FlatCompat({
14 | baseDirectory: __dirname,
15 | recommendedConfig: js.configs.recommended,
16 | allConfig: js.configs.all
17 | });
18 |
19 | export default [...compat.extends("plugin:prettier/recommended"), {
20 | plugins: {
21 | prettier,
22 | import: fixupPluginRules(_import),
23 | },
24 |
25 | languageOptions: {
26 | globals: {
27 | ...globals.node,
28 | },
29 |
30 | ecmaVersion: 2018,
31 | sourceType: "commonjs",
32 | },
33 |
34 | rules: {
35 | "accessor-pairs": "error",
36 | "array-bracket-spacing": ["error", "never"],
37 | "array-callback-return": "off",
38 | "arrow-parens": "error",
39 | "arrow-spacing": "error",
40 | "block-scoped-var": "off",
41 | "block-spacing": "off",
42 |
43 | "brace-style": ["error", "1tbs", {
44 | allowSingleLine: true,
45 | }],
46 |
47 | "capitalized-comments": "off",
48 | "class-methods-use-this": "off",
49 | "comma-dangle": "off",
50 | "comma-spacing": "off",
51 | "comma-style": ["error", "last"],
52 | complexity: "error",
53 | "computed-property-spacing": ["error", "never"],
54 | "consistent-return": "off",
55 | "consistent-this": "off",
56 | curly: "error",
57 | "default-case": "off",
58 | "dot-location": ["error", "property"],
59 | "dot-notation": "error",
60 | "eol-last": "error",
61 | eqeqeq: "off",
62 | "func-call-spacing": "error",
63 | "func-name-matching": "error",
64 | "func-names": "off",
65 |
66 | "func-style": ["error", "declaration", {
67 | allowArrowFunctions: true,
68 | }],
69 |
70 | "generator-star-spacing": "error",
71 | "global-require": "off",
72 | "guard-for-in": "error",
73 | "handle-callback-err": "off",
74 | "id-blacklist": "error",
75 | "id-length": "off",
76 | "id-match": "error",
77 | "import/extensions": "off",
78 | "init-declarations": "off",
79 | "jsx-quotes": "error",
80 | "key-spacing": "error",
81 |
82 | "keyword-spacing": ["error", {
83 | after: true,
84 | before: true,
85 | }],
86 |
87 | "line-comment-position": "off",
88 | "linebreak-style": ["error", "unix"],
89 | "lines-around-directive": "error",
90 | "max-depth": "error",
91 | "max-len": "off",
92 | "max-lines": "off",
93 | "max-nested-callbacks": "error",
94 | "max-params": "off",
95 | "max-statements": "off",
96 | "max-statements-per-line": "off",
97 | "multiline-ternary": "off",
98 | "new-cap": "off",
99 | "new-parens": "error",
100 | "newline-after-var": "off",
101 | "newline-before-return": "off",
102 | "newline-per-chained-call": "off",
103 | "no-alert": "error",
104 | "no-array-constructor": "error",
105 | "no-await-in-loop": "error",
106 | "no-bitwise": "off",
107 | "no-caller": "error",
108 | "no-catch-shadow": "off",
109 | "no-compare-neg-zero": "error",
110 | "no-confusing-arrow": "error",
111 | "no-continue": "off",
112 | "no-div-regex": "error",
113 | "no-duplicate-imports": "off",
114 | "no-else-return": "off",
115 | "no-empty-function": "off",
116 | "no-eq-null": "off",
117 | "no-eval": "error",
118 | "no-extend-native": "error",
119 | "no-extra-bind": "error",
120 | "no-extra-label": "error",
121 | "no-extra-parens": "off",
122 | "no-floating-decimal": "error",
123 | "no-implicit-globals": "error",
124 | "no-implied-eval": "error",
125 | "no-inline-comments": "off",
126 | "no-inner-declarations": ["error", "functions"],
127 | "no-invalid-this": "off",
128 | "no-iterator": "error",
129 | "no-label-var": "error",
130 | "no-labels": "error",
131 | "no-lone-blocks": "error",
132 | "no-lonely-if": "error",
133 | "no-loop-func": "error",
134 | "no-magic-numbers": "off",
135 | "no-mixed-requires": "error",
136 | "no-multi-assign": "off",
137 | "no-multi-spaces": "error",
138 | "no-multi-str": "error",
139 | "no-multiple-empty-lines": "error",
140 | "no-native-reassign": "error",
141 | "no-negated-condition": "off",
142 | "no-negated-in-lhs": "error",
143 | "no-nested-ternary": "error",
144 | "no-new": "error",
145 | "no-new-func": "error",
146 | "no-new-object": "error",
147 | "no-new-require": "error",
148 | "no-new-wrappers": "error",
149 | "no-octal-escape": "error",
150 | "no-param-reassign": "off",
151 | "no-path-concat": "error",
152 |
153 | "no-plusplus": ["error", {
154 | allowForLoopAfterthoughts: true,
155 | }],
156 |
157 | "no-process-env": "off",
158 | "no-process-exit": "error",
159 | "no-proto": "error",
160 | "no-prototype-builtins": "off",
161 | "no-restricted-globals": "error",
162 | "no-restricted-imports": "error",
163 | "no-restricted-modules": "error",
164 | "no-restricted-properties": "error",
165 | "no-restricted-syntax": "error",
166 | "no-return-assign": "error",
167 | "no-return-await": "error",
168 | "no-script-url": "error",
169 | "no-self-compare": "error",
170 | "no-sequences": "error",
171 | "no-shadow": "off",
172 | "no-shadow-restricted-names": "error",
173 | "no-spaced-func": "error",
174 | "no-sync": "error",
175 | "no-tabs": "error",
176 | "no-template-curly-in-string": "error",
177 | "no-ternary": "off",
178 | "no-throw-literal": "error",
179 | "no-trailing-spaces": "error",
180 | "no-undef-init": "error",
181 | "no-undefined": "off",
182 | "no-underscore-dangle": "off",
183 | "no-unmodified-loop-condition": "error",
184 | "no-unneeded-ternary": "error",
185 | "no-unused-expressions": "error",
186 |
187 | "no-unused-vars": ["error", {
188 | args: "none",
189 | }],
190 |
191 | "no-use-before-define": "off",
192 | "no-useless-call": "error",
193 | "no-useless-computed-key": "error",
194 | "no-useless-concat": "error",
195 | "no-useless-constructor": "error",
196 | "no-useless-escape": "off",
197 | "no-useless-rename": "error",
198 | "no-useless-return": "error",
199 | "no-var": "off",
200 | "no-void": "error",
201 | "no-warning-comments": "error",
202 | "no-whitespace-before-property": "error",
203 | "no-with": "error",
204 | "nonblock-statement-body-position": "error",
205 | "object-curly-newline": "off",
206 | "object-curly-spacing": ["error", "never"],
207 | "object-property-newline": "off",
208 | "object-shorthand": "off",
209 | "one-var": "off",
210 | "one-var-declaration-per-line": "error",
211 | "operator-assignment": ["error", "always"],
212 | "operator-linebreak": "off",
213 | "padded-blocks": "off",
214 | "prefer-arrow-callback": "off",
215 | "prefer-const": "error",
216 |
217 | "prefer-destructuring": ["error", {
218 | array: false,
219 | object: false,
220 | }],
221 |
222 | "prefer-numeric-literals": "error",
223 | "prefer-promise-reject-errors": "error",
224 | "prefer-reflect": "off",
225 | "prefer-rest-params": "off",
226 | "prefer-spread": "off",
227 | "prefer-template": "off",
228 | "quote-props": "off",
229 |
230 | quotes: ["error", "single", {
231 | avoidEscape: true,
232 | }],
233 |
234 | radix: "error",
235 | "require-await": "error",
236 | "require-jsdoc": "off",
237 | "rest-spread-spacing": "error",
238 | semi: "off",
239 |
240 | "semi-spacing": ["error", {
241 | after: true,
242 | before: false,
243 | }],
244 |
245 | "sort-imports": "off",
246 | "sort-keys": "off",
247 | "sort-vars": "error",
248 | "space-before-blocks": "error",
249 | "space-before-function-paren": "off",
250 | "space-in-parens": ["error", "never"],
251 | "space-infix-ops": "error",
252 | "space-unary-ops": "error",
253 | "spaced-comment": ["error", "always"],
254 | strict: "off",
255 | "symbol-description": "error",
256 | "template-curly-spacing": "error",
257 | "template-tag-spacing": "error",
258 | "unicode-bom": ["error", "never"],
259 | "valid-jsdoc": "off",
260 | "vars-on-top": "off",
261 | "wrap-regex": "off",
262 | "yield-star-spacing": "error",
263 | yoda: ["error", "never"],
264 | },
265 | }, ...compat.extends(
266 | "eslint:recommended",
267 | "plugin:@typescript-eslint/eslint-recommended",
268 | "plugin:@typescript-eslint/recommended",
269 | "plugin:prettier/recommended",
270 | ).map(config => ({
271 | ...config,
272 | files: ["**/*.ts"],
273 | })), {
274 | files: ["**/*.ts"],
275 |
276 | plugins: {
277 | "@typescript-eslint": typescriptEslint,
278 | prettier,
279 | },
280 |
281 | rules: {
282 | "@typescript-eslint/no-use-before-define": 0,
283 | "@typescript-eslint/no-empty-interface": 0,
284 | "@typescript-eslint/no-unused-vars": 0,
285 | "@typescript-eslint/triple-slash-reference": 0,
286 | "@typescript-eslint/ban-ts-comment": "off",
287 | "@typescript-eslint/no-empty-function": 0,
288 | "@typescript-eslint/no-require-imports": 0,
289 |
290 | "@typescript-eslint/naming-convention": ["error", {
291 | selector: "default",
292 | format: ["camelCase", "UPPER_CASE", "PascalCase"],
293 | leadingUnderscore: "allow",
294 | }, {
295 | selector: "property",
296 | format: null,
297 | }],
298 |
299 | "@typescript-eslint/no-explicit-any": 0,
300 | "@typescript-eslint/explicit-function-return-type": "off",
301 | "@typescript-eslint/no-this-alias": "off",
302 | "@typescript-eslint/no-var-requires": 0,
303 | "prefer-rest-params": "off",
304 | },
305 | }, {
306 | files: ["test/**/*.ts"],
307 |
308 | rules: {
309 | "@typescript-eslint/explicit-function-return-type": "off",
310 | },
311 | }];
312 |
```
--------------------------------------------------------------------------------
/tools/typescript/eslint.config.mjs:
--------------------------------------------------------------------------------
```
1 | import prettier from "eslint-plugin-prettier";
2 | import _import from "eslint-plugin-import";
3 | import { fixupPluginRules } from "@eslint/compat";
4 | import globals from "globals";
5 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
6 | import path from "node:path";
7 | import { fileURLToPath } from "node:url";
8 | import js from "@eslint/js";
9 | import { FlatCompat } from "@eslint/eslintrc";
10 |
11 | const __filename = fileURLToPath(import.meta.url);
12 | const __dirname = path.dirname(__filename);
13 | const compat = new FlatCompat({
14 | baseDirectory: __dirname,
15 | recommendedConfig: js.configs.recommended,
16 | allConfig: js.configs.all
17 | });
18 |
19 | export default [...compat.extends("plugin:prettier/recommended"), {
20 | plugins: {
21 | prettier,
22 | import: fixupPluginRules(_import),
23 | },
24 |
25 | languageOptions: {
26 | globals: {
27 | ...globals.node,
28 | },
29 |
30 | ecmaVersion: 2018,
31 | sourceType: "commonjs",
32 | },
33 |
34 | rules: {
35 | "accessor-pairs": "error",
36 | "array-bracket-spacing": ["error", "never"],
37 | "array-callback-return": "off",
38 | "arrow-parens": "error",
39 | "arrow-spacing": "error",
40 | "block-scoped-var": "off",
41 | "block-spacing": "off",
42 |
43 | "brace-style": ["error", "1tbs", {
44 | allowSingleLine: true,
45 | }],
46 |
47 | "capitalized-comments": "off",
48 | "class-methods-use-this": "off",
49 | "comma-dangle": "off",
50 | "comma-spacing": "off",
51 | "comma-style": ["error", "last"],
52 | complexity: "error",
53 | "computed-property-spacing": ["error", "never"],
54 | "consistent-return": "off",
55 | "consistent-this": "off",
56 | curly: "error",
57 | "default-case": "off",
58 | "dot-location": ["error", "property"],
59 | "dot-notation": "error",
60 | "eol-last": "error",
61 | eqeqeq: "off",
62 | "func-call-spacing": "error",
63 | "func-name-matching": "error",
64 | "func-names": "off",
65 |
66 | "func-style": ["error", "declaration", {
67 | allowArrowFunctions: true,
68 | }],
69 |
70 | "generator-star-spacing": "error",
71 | "global-require": "off",
72 | "guard-for-in": "error",
73 | "handle-callback-err": "off",
74 | "id-blacklist": "error",
75 | "id-length": "off",
76 | "id-match": "error",
77 | "import/extensions": "off",
78 | "init-declarations": "off",
79 | "jsx-quotes": "error",
80 | "key-spacing": "error",
81 |
82 | "keyword-spacing": ["error", {
83 | after: true,
84 | before: true,
85 | }],
86 |
87 | "line-comment-position": "off",
88 | "linebreak-style": ["error", "unix"],
89 | "lines-around-directive": "error",
90 | "max-depth": "error",
91 | "max-len": "off",
92 | "max-lines": "off",
93 | "max-nested-callbacks": "error",
94 | "max-params": "off",
95 | "max-statements": "off",
96 | "max-statements-per-line": "off",
97 | "multiline-ternary": "off",
98 | "new-cap": "off",
99 | "new-parens": "error",
100 | "newline-after-var": "off",
101 | "newline-before-return": "off",
102 | "newline-per-chained-call": "off",
103 | "no-alert": "error",
104 | "no-array-constructor": "error",
105 | "no-await-in-loop": "error",
106 | "no-bitwise": "off",
107 | "no-caller": "error",
108 | "no-catch-shadow": "off",
109 | "no-compare-neg-zero": "error",
110 | "no-confusing-arrow": "error",
111 | "no-continue": "off",
112 | "no-div-regex": "error",
113 | "no-duplicate-imports": "off",
114 | "no-else-return": "off",
115 | "no-empty-function": "off",
116 | "no-eq-null": "off",
117 | "no-eval": "error",
118 | "no-extend-native": "error",
119 | "no-extra-bind": "error",
120 | "no-extra-label": "error",
121 | "no-extra-parens": "off",
122 | "no-floating-decimal": "error",
123 | "no-implicit-globals": "error",
124 | "no-implied-eval": "error",
125 | "no-inline-comments": "off",
126 | "no-inner-declarations": ["error", "functions"],
127 | "no-invalid-this": "off",
128 | "no-iterator": "error",
129 | "no-label-var": "error",
130 | "no-labels": "error",
131 | "no-lone-blocks": "error",
132 | "no-lonely-if": "error",
133 | "no-loop-func": "error",
134 | "no-magic-numbers": "off",
135 | "no-mixed-requires": "error",
136 | "no-multi-assign": "off",
137 | "no-multi-spaces": "error",
138 | "no-multi-str": "error",
139 | "no-multiple-empty-lines": "error",
140 | "no-native-reassign": "error",
141 | "no-negated-condition": "off",
142 | "no-negated-in-lhs": "error",
143 | "no-nested-ternary": "error",
144 | "no-new": "error",
145 | "no-new-func": "error",
146 | "no-new-object": "error",
147 | "no-new-require": "error",
148 | "no-new-wrappers": "error",
149 | "no-octal-escape": "error",
150 | "no-param-reassign": "off",
151 | "no-path-concat": "error",
152 |
153 | "no-plusplus": ["error", {
154 | allowForLoopAfterthoughts: true,
155 | }],
156 |
157 | "no-process-env": "off",
158 | "no-process-exit": "error",
159 | "no-proto": "error",
160 | "no-prototype-builtins": "off",
161 | "no-restricted-globals": "error",
162 | "no-restricted-imports": "error",
163 | "no-restricted-modules": "error",
164 | "no-restricted-properties": "error",
165 | "no-restricted-syntax": "error",
166 | "no-return-assign": "error",
167 | "no-return-await": "error",
168 | "no-script-url": "error",
169 | "no-self-compare": "error",
170 | "no-sequences": "error",
171 | "no-shadow": "off",
172 | "no-shadow-restricted-names": "error",
173 | "no-spaced-func": "error",
174 | "no-sync": "error",
175 | "no-tabs": "error",
176 | "no-template-curly-in-string": "error",
177 | "no-ternary": "off",
178 | "no-throw-literal": "error",
179 | "no-trailing-spaces": "error",
180 | "no-undef-init": "error",
181 | "no-undefined": "off",
182 | "no-underscore-dangle": "off",
183 | "no-unmodified-loop-condition": "error",
184 | "no-unneeded-ternary": "error",
185 | "no-unused-expressions": "error",
186 |
187 | "no-unused-vars": ["error", {
188 | args: "none",
189 | }],
190 |
191 | "no-use-before-define": "off",
192 | "no-useless-call": "error",
193 | "no-useless-computed-key": "error",
194 | "no-useless-concat": "error",
195 | "no-useless-constructor": "error",
196 | "no-useless-escape": "off",
197 | "no-useless-rename": "error",
198 | "no-useless-return": "error",
199 | "no-var": "off",
200 | "no-void": "error",
201 | "no-warning-comments": "error",
202 | "no-whitespace-before-property": "error",
203 | "no-with": "error",
204 | "nonblock-statement-body-position": "error",
205 | "object-curly-newline": "off",
206 | "object-curly-spacing": ["error", "never"],
207 | "object-property-newline": "off",
208 | "object-shorthand": "off",
209 | "one-var": "off",
210 | "one-var-declaration-per-line": "error",
211 | "operator-assignment": ["error", "always"],
212 | "operator-linebreak": "off",
213 | "padded-blocks": "off",
214 | "prefer-arrow-callback": "off",
215 | "prefer-const": "error",
216 |
217 | "prefer-destructuring": ["error", {
218 | array: false,
219 | object: false,
220 | }],
221 |
222 | "prefer-numeric-literals": "error",
223 | "prefer-promise-reject-errors": "error",
224 | "prefer-reflect": "off",
225 | "prefer-rest-params": "off",
226 | "prefer-spread": "off",
227 | "prefer-template": "off",
228 | "quote-props": "off",
229 |
230 | quotes: ["error", "single", {
231 | avoidEscape: true,
232 | }],
233 |
234 | radix: "error",
235 | "require-await": "error",
236 | "require-jsdoc": "off",
237 | "rest-spread-spacing": "error",
238 | semi: "off",
239 |
240 | "semi-spacing": ["error", {
241 | after: true,
242 | before: false,
243 | }],
244 |
245 | "sort-imports": "off",
246 | "sort-keys": "off",
247 | "sort-vars": "error",
248 | "space-before-blocks": "error",
249 | "space-before-function-paren": "off",
250 | "space-in-parens": ["error", "never"],
251 | "space-infix-ops": "error",
252 | "space-unary-ops": "error",
253 | "spaced-comment": ["error", "always"],
254 | strict: "off",
255 | "symbol-description": "error",
256 | "template-curly-spacing": "error",
257 | "template-tag-spacing": "error",
258 | "unicode-bom": ["error", "never"],
259 | "valid-jsdoc": "off",
260 | "vars-on-top": "off",
261 | "wrap-regex": "off",
262 | "yield-star-spacing": "error",
263 | yoda: ["error", "never"],
264 | },
265 | }, ...compat.extends(
266 | "eslint:recommended",
267 | "plugin:@typescript-eslint/eslint-recommended",
268 | "plugin:@typescript-eslint/recommended",
269 | "plugin:prettier/recommended",
270 | ).map(config => ({
271 | ...config,
272 | files: ["**/*.ts"],
273 | })), {
274 | files: ["**/*.ts"],
275 |
276 | plugins: {
277 | "@typescript-eslint": typescriptEslint,
278 | prettier,
279 | },
280 |
281 | rules: {
282 | "@typescript-eslint/no-use-before-define": 0,
283 | "@typescript-eslint/no-empty-interface": 0,
284 | "@typescript-eslint/no-unused-vars": 0,
285 | "@typescript-eslint/triple-slash-reference": 0,
286 | "@typescript-eslint/ban-ts-comment": "off",
287 | "@typescript-eslint/no-empty-function": 0,
288 | "@typescript-eslint/no-require-imports": 0,
289 |
290 | "@typescript-eslint/naming-convention": ["error", {
291 | selector: "default",
292 | format: ["camelCase", "UPPER_CASE", "PascalCase"],
293 | leadingUnderscore: "allow",
294 | }, {
295 | selector: "property",
296 | format: null,
297 | }],
298 |
299 | "@typescript-eslint/no-explicit-any": 0,
300 | "@typescript-eslint/explicit-function-return-type": "off",
301 | "@typescript-eslint/no-this-alias": "off",
302 | "@typescript-eslint/no-var-requires": 0,
303 | "prefer-rest-params": "off",
304 | },
305 | }, {
306 | files: ["test/**/*.ts"],
307 |
308 | rules: {
309 | "@typescript-eslint/explicit-function-return-type": "off",
310 | },
311 | }, {
312 | files: ["examples/cloudflare/**/*.ts", "examples/cloudflare/**/*.js", "examples/cloudflare/**/*.mjs"],
313 | ignores: [],
314 | rules: {
315 | // Disable all rules for cloudflare examples
316 | ...Object.fromEntries(
317 | Object.keys(typescriptEslint.rules).map(rule => [`@typescript-eslint/${rule}`, "off"])
318 | ),
319 | // Disable all base rules
320 | "no-unused-vars": "off",
321 | "no-undef": "off",
322 | "no-console": "off",
323 | "require-await": "off",
324 | "prettier/prettier": "off",
325 | "func-style": "off",
326 | "no-warning-comments": "off",
327 | "no-constant-condition": "off",
328 | // Add any other rules you want to disable
329 | }
330 | }];
331 |
```
--------------------------------------------------------------------------------
/llm/token-meter/utils/type-detection.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Utilities for detecting response types from different AI providers
3 | */
4 |
5 | import type OpenAI from 'openai';
6 | import type {Stream} from 'openai/streaming';
7 | import type Anthropic from '@anthropic-ai/sdk';
8 | import type {Stream as AnthropicStream} from '@anthropic-ai/sdk/streaming';
9 | import type {GenerateContentResult} from '@google/generative-ai';
10 |
11 | /**
12 | * Provider types
13 | */
14 | export type Provider = 'openai' | 'anthropic' | 'google' | 'unknown';
15 |
16 | /**
17 | * Response type categories
18 | */
19 | export type ResponseType =
20 | | 'chat_completion'
21 | | 'response_api'
22 | | 'embedding'
23 | | 'unknown';
24 |
25 | /**
26 | * Detected response information
27 | */
28 | export interface DetectedResponse {
29 | provider: Provider;
30 | type: ResponseType;
31 | model: string;
32 | inputTokens: number;
33 | outputTokens: number;
34 | }
35 |
36 | /**
37 | * Check if response is an OpenAI ChatCompletion
38 | */
39 | function isOpenAIChatCompletion(response: any): response is OpenAI.ChatCompletion {
40 | return (
41 | response &&
42 | typeof response === 'object' &&
43 | 'choices' in response &&
44 | 'model' in response &&
45 | response.choices?.[0]?.message !== undefined
46 | );
47 | }
48 |
49 | /**
50 | * Check if response is an OpenAI Responses API response
51 | */
52 | function isOpenAIResponse(response: any): response is OpenAI.Responses.Response {
53 | return (
54 | response &&
55 | typeof response === 'object' &&
56 | 'output' in response &&
57 | 'model' in response &&
58 | 'usage' in response &&
59 | response.usage?.input_tokens !== undefined
60 | );
61 | }
62 |
63 | /**
64 | * Check if response is an OpenAI Embedding response
65 | */
66 | function isOpenAIEmbedding(
67 | response: any
68 | ): response is OpenAI.CreateEmbeddingResponse {
69 | return (
70 | response &&
71 | typeof response === 'object' &&
72 | 'data' in response &&
73 | 'model' in response &&
74 | Array.isArray(response.data) &&
75 | response.data?.[0]?.embedding !== undefined
76 | );
77 | }
78 |
79 | /**
80 | * Check if response is an Anthropic Message
81 | */
82 | function isAnthropicMessage(
83 | response: any
84 | ): response is Anthropic.Messages.Message {
85 | return (
86 | response &&
87 | typeof response === 'object' &&
88 | 'content' in response &&
89 | 'model' in response &&
90 | 'usage' in response &&
91 | response.usage?.input_tokens !== undefined &&
92 | response.usage?.output_tokens !== undefined &&
93 | response.type === 'message'
94 | );
95 | }
96 |
97 | /**
98 | * Check if response is a Gemini GenerateContentResult
99 | */
100 | function isGeminiResponse(response: any): response is GenerateContentResult {
101 | return (
102 | response &&
103 | typeof response === 'object' &&
104 | 'response' in response &&
105 | response.response?.usageMetadata !== undefined &&
106 | response.response?.usageMetadata?.promptTokenCount !== undefined
107 | );
108 | }
109 |
110 | /**
111 | * Detect and extract usage information from a response
112 | */
113 | export function detectResponse(response: any): DetectedResponse | null {
114 | // OpenAI Chat Completion
115 | if (isOpenAIChatCompletion(response)) {
116 | return {
117 | provider: 'openai',
118 | type: 'chat_completion',
119 | model: response.model,
120 | inputTokens: response.usage?.prompt_tokens ?? 0,
121 | outputTokens: response.usage?.completion_tokens ?? 0,
122 | };
123 | }
124 |
125 | // OpenAI Responses API
126 | if (isOpenAIResponse(response)) {
127 | return {
128 | provider: 'openai',
129 | type: 'response_api',
130 | model: response.model,
131 | inputTokens: response.usage?.input_tokens ?? 0,
132 | outputTokens: response.usage?.output_tokens ?? 0,
133 | };
134 | }
135 |
136 | // OpenAI Embeddings
137 | if (isOpenAIEmbedding(response)) {
138 | return {
139 | provider: 'openai',
140 | type: 'embedding',
141 | model: response.model,
142 | inputTokens: response.usage?.prompt_tokens ?? 0,
143 | outputTokens: 0, // Embeddings don't have output tokens
144 | };
145 | }
146 |
147 | // Anthropic Message
148 | if (isAnthropicMessage(response)) {
149 | return {
150 | provider: 'anthropic',
151 | type: 'chat_completion',
152 | model: response.model,
153 | inputTokens: response.usage?.input_tokens ?? 0,
154 | outputTokens: response.usage?.output_tokens ?? 0,
155 | };
156 | }
157 |
158 | // Gemini GenerateContentResult
159 | if (isGeminiResponse(response)) {
160 | const usageMetadata = response.response.usageMetadata;
161 | const baseOutputTokens = usageMetadata?.candidatesTokenCount ?? 0;
162 | // thoughtsTokenCount is for extended thinking models, may not always be present
163 | const reasoningTokens = (usageMetadata as any)?.thoughtsTokenCount ?? 0;
164 |
165 | // Extract model name from response if available
166 | const model = (response.response as any)?.modelVersion || 'gemini';
167 |
168 | return {
169 | provider: 'google',
170 | type: 'chat_completion',
171 | model,
172 | inputTokens: usageMetadata?.promptTokenCount ?? 0,
173 | outputTokens: baseOutputTokens + reasoningTokens, // Include reasoning tokens
174 | };
175 | }
176 |
177 | // Unknown response type
178 | return null;
179 | }
180 |
181 | /**
182 | * Stream type detection
183 | */
184 | export type StreamType = 'chat_completion' | 'response_api' | 'unknown';
185 |
186 | /**
187 | * Detect stream type by checking if it has OpenAI stream methods
188 | * OpenAI streams have 'toReadableStream' method, Anthropic streams don't
189 | */
190 | export function isOpenAIStream(stream: any): stream is Stream<any> {
191 | return (
192 | stream &&
193 | typeof stream === 'object' &&
194 | 'tee' in stream &&
195 | 'toReadableStream' in stream
196 | );
197 | }
198 |
199 | /**
200 | * Extract usage from OpenAI chat completion stream chunks
201 | */
202 | export async function extractUsageFromChatStream(
203 | stream: Stream<OpenAI.ChatCompletionChunk>
204 | ): Promise<DetectedResponse | null> {
205 | let usage: any = {
206 | prompt_tokens: 0,
207 | completion_tokens: 0,
208 | };
209 | let model = '';
210 |
211 | try {
212 | for await (const chunk of stream) {
213 | if (chunk.model) {
214 | model = chunk.model;
215 | }
216 | if (chunk.usage) {
217 | usage = chunk.usage;
218 | }
219 | }
220 |
221 | if (model) {
222 | return {
223 | provider: 'openai',
224 | type: 'chat_completion',
225 | model,
226 | inputTokens: usage.prompt_tokens ?? 0,
227 | outputTokens: usage.completion_tokens ?? 0,
228 | };
229 | }
230 | } catch (error) {
231 | console.error('Error extracting usage from chat stream:', error);
232 | }
233 |
234 | return null;
235 | }
236 |
237 | /**
238 | * Extract usage from OpenAI Responses API stream events
239 | */
240 | export async function extractUsageFromResponseStream(
241 | stream: Stream<OpenAI.Responses.ResponseStreamEvent>
242 | ): Promise<DetectedResponse | null> {
243 | let usage: any = {
244 | input_tokens: 0,
245 | output_tokens: 0,
246 | };
247 | let model = '';
248 |
249 | try {
250 | for await (const chunk of stream) {
251 | if ('response' in chunk && chunk.response) {
252 | if (chunk.response.model) {
253 | model = chunk.response.model;
254 | }
255 | if (chunk.response.usage) {
256 | usage = chunk.response.usage;
257 | }
258 | }
259 | }
260 |
261 | if (model) {
262 | return {
263 | provider: 'openai',
264 | type: 'response_api',
265 | model,
266 | inputTokens: usage.input_tokens ?? 0,
267 | outputTokens: usage.output_tokens ?? 0,
268 | };
269 | }
270 | } catch (error) {
271 | console.error('Error extracting usage from response stream:', error);
272 | }
273 |
274 | return null;
275 | }
276 |
277 | /**
278 | * Check if stream is an Anthropic stream
279 | * Anthropic streams have 'controller' but NOT 'toReadableStream'
280 | */
281 | export function isAnthropicStream(
282 | stream: any
283 | ): stream is AnthropicStream<Anthropic.Messages.RawMessageStreamEvent> {
284 | return (
285 | stream &&
286 | typeof stream === 'object' &&
287 | 'tee' in stream &&
288 | 'controller' in stream &&
289 | !('toReadableStream' in stream)
290 | );
291 | }
292 |
293 | /**
294 | * Extract usage from Anthropic message stream events
295 | */
296 | export async function extractUsageFromAnthropicStream(
297 | stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
298 | ): Promise<DetectedResponse | null> {
299 | const usage: {
300 | input_tokens: number;
301 | output_tokens: number;
302 | } = {
303 | input_tokens: 0,
304 | output_tokens: 0,
305 | };
306 | let model = '';
307 |
308 | try {
309 | for await (const chunk of stream) {
310 | // Capture usage from message_start event (input tokens)
311 | if (chunk.type === 'message_start') {
312 | usage.input_tokens = chunk.message.usage.input_tokens ?? 0;
313 | model = chunk.message.model;
314 | }
315 | // Capture usage from message_delta event (output tokens)
316 | if (chunk.type === 'message_delta' && 'usage' in chunk) {
317 | usage.output_tokens = chunk.usage.output_tokens ?? 0;
318 | }
319 | }
320 |
321 | if (model) {
322 | return {
323 | provider: 'anthropic',
324 | type: 'chat_completion',
325 | model,
326 | inputTokens: usage.input_tokens,
327 | outputTokens: usage.output_tokens,
328 | };
329 | }
330 | } catch (error) {
331 | console.error('Error extracting usage from Anthropic stream:', error);
332 | }
333 |
334 | return null;
335 | }
336 |
337 | /**
338 | * Check if stream is a Gemini stream
339 | * Gemini returns an object with {stream, response}, not just a stream
340 | */
341 | export function isGeminiStream(stream: any): boolean {
342 | // Gemini returns {stream: AsyncGenerator, response: Promise}
343 | return (
344 | stream &&
345 | typeof stream === 'object' &&
346 | 'stream' in stream &&
347 | 'response' in stream &&
348 | typeof stream.stream?.[Symbol.asyncIterator] === 'function' &&
349 | !('tee' in stream)
350 | );
351 | }
352 |
353 | /**
354 | * Extract usage from Gemini stream
355 | * Gemini provides an object with both stream and response promise
356 | */
357 | export async function extractUsageFromGeminiStream(
358 | streamResult: any
359 | ): Promise<DetectedResponse | null> {
360 | try {
361 | // Gemini returns {stream, response}
362 | // We need to consume the stream to get usage
363 | let lastUsageMetadata: any = null;
364 |
365 | for await (const chunk of streamResult.stream) {
366 | if (chunk.usageMetadata) {
367 | lastUsageMetadata = chunk.usageMetadata;
368 | }
369 | }
370 |
371 | if (lastUsageMetadata) {
372 | const baseOutputTokens = lastUsageMetadata?.candidatesTokenCount ?? 0;
373 | // thoughtsTokenCount is for extended thinking models, may not always be present
374 | const reasoningTokens = (lastUsageMetadata as any)?.thoughtsTokenCount ?? 0;
375 |
376 | // Get model from the response - this field is always present in real Gemini responses
377 | const response = await streamResult.response;
378 | const model = (response as any)?.modelVersion;
379 |
380 | if (!model) {
381 | throw new Error('Gemini response is missing modelVersion field. This should never happen with real Gemini API responses.');
382 | }
383 |
384 | return {
385 | provider: 'google',
386 | type: 'chat_completion',
387 | model,
388 | inputTokens: lastUsageMetadata?.promptTokenCount ?? 0,
389 | outputTokens: baseOutputTokens + reasoningTokens,
390 | };
391 | }
392 | } catch (error) {
393 | console.error('Error extracting usage from Gemini stream:', error);
394 | }
395 |
396 | return null;
397 | }
398 |
399 | /**
400 | * Detect stream type by examining first chunk (without consuming the stream)
401 | * This is a heuristic approach - for now we'll try chat completion first,
402 | * then fall back to response API
403 | */
404 | export function detectStreamType(_stream: any): StreamType {
405 | // For now, we'll assume chat completion by default
406 | // In the future, we could peek at the first chunk to determine type
407 | return 'chat_completion';
408 | }
409 |
410 |
```
--------------------------------------------------------------------------------
/tools/python/stripe_agent_toolkit/functions.py:
--------------------------------------------------------------------------------
```python
1 | import stripe
2 | from typing import Optional
3 | from .configuration import Context
4 |
5 |
6 | def create_customer(context: Context, name: str, email: Optional[str] = None):
7 | """
8 | Create a customer.
9 |
10 | Parameters:
11 | name (str): The name of the customer.
12 | email (str, optional): The email address of the customer.
13 |
14 | Returns:
15 | stripe.Customer: The created customer.
16 | """
17 | customer_data: dict = {"name": name}
18 | if email:
19 | customer_data["email"] = email
20 | if context.get("account") is not None:
21 | account = context.get("account")
22 | if account is not None:
23 | customer_data["stripe_account"] = account
24 |
25 | customer = stripe.Customer.create(**customer_data)
26 | return {"id": customer.id}
27 |
28 |
29 | def list_customers(
30 | context: Context,
31 | email: Optional[str] = None,
32 | limit: Optional[int] = None,
33 | ):
34 | """
35 | List Customers.
36 |
37 | Parameters:
38 | email (str, optional): The email address of the customer.
39 | limit (int, optional): The number of customers to return.
40 |
41 | Returns:
42 | stripe.ListObject: A list of customers.
43 | """
44 | customer_data: dict = {}
45 | if email:
46 | customer_data["email"] = email
47 | if limit:
48 | customer_data["limit"] = limit
49 | if context.get("account") is not None:
50 | account = context.get("account")
51 | if account is not None:
52 | customer_data["stripe_account"] = account
53 |
54 | customers = stripe.Customer.list(**customer_data)
55 | return [{"id": customer.id} for customer in customers.data]
56 |
57 |
58 | def create_product(
59 | context: Context, name: str, description: Optional[str] = None
60 | ):
61 | """
62 | Create a product.
63 |
64 | Parameters:
65 | name (str): The name of the product.
66 | description (str, optional): The description of the product.
67 |
68 | Returns:
69 | stripe.Product: The created product.
70 | """
71 | product_data: dict = {"name": name}
72 | if description:
73 | product_data["description"] = description
74 | if context.get("account") is not None:
75 | account = context.get("account")
76 | if account is not None:
77 | product_data["stripe_account"] = account
78 |
79 | return stripe.Product.create(**product_data)
80 |
81 |
82 | def list_products(context: Context, limit: Optional[int] = None):
83 | """
84 | List Products.
85 | Parameters:
86 | limit (int, optional): The number of products to return.
87 |
88 | Returns:
89 | stripe.ListObject: A list of products.
90 | """
91 | product_data: dict = {}
92 | if limit:
93 | product_data["limit"] = limit
94 | if context.get("account") is not None:
95 | account = context.get("account")
96 | if account is not None:
97 | product_data["stripe_account"] = account
98 |
99 | return stripe.Product.list(**product_data).data
100 |
101 |
102 | def create_price(
103 | context: Context, product: str, currency: str, unit_amount: int
104 | ):
105 | """
106 | Create a price.
107 |
108 | Parameters:
109 | product (str): The ID of the product.
110 | currency (str): The currency of the price.
111 | unit_amount (int): The unit amount of the price.
112 |
113 | Returns:
114 | stripe.Price: The created price.
115 | """
116 | price_data: dict = {
117 | "product": product,
118 | "currency": currency,
119 | "unit_amount": unit_amount,
120 | }
121 | if context.get("account") is not None:
122 | account = context.get("account")
123 | if account is not None:
124 | price_data["stripe_account"] = account
125 |
126 | return stripe.Price.create(**price_data)
127 |
128 |
129 | def list_prices(
130 | context: Context,
131 | product: Optional[str] = None,
132 | limit: Optional[int] = None,
133 | ):
134 | """
135 | List Prices.
136 |
137 | Parameters:
138 | product (str, optional): The ID of the product to list prices for.
139 | limit (int, optional): The number of prices to return.
140 |
141 | Returns:
142 | stripe.ListObject: A list of prices.
143 | """
144 | prices_data: dict = {}
145 | if product:
146 | prices_data["product"] = product
147 | if limit:
148 | prices_data["limit"] = limit
149 | if context.get("account") is not None:
150 | account = context.get("account")
151 | if account is not None:
152 | prices_data["stripe_account"] = account
153 |
154 | return stripe.Price.list(**prices_data).data
155 |
156 |
157 | def create_payment_link(context: Context, price: str, quantity: int, redirect_url: Optional[str] = None):
158 | """
159 | Create a payment link.
160 |
161 | Parameters:
162 | price (str): The ID of the price.
163 | quantity (int): The quantity of the product.
164 | redirect_url (string, optional): The URL the customer will be redirected to after the purchase is complete.
165 |
166 | Returns:
167 | stripe.PaymentLink: The created payment link.
168 | """
169 | payment_link_data: dict = {
170 | "line_items": [{"price": price, "quantity": quantity}],
171 | }
172 | if context.get("account") is not None:
173 | account = context.get("account")
174 | if account is not None:
175 | payment_link_data["stripe_account"] = account
176 |
177 | if redirect_url:
178 | payment_link_data["after_completion"] = {"type": "redirect", "redirect": {"url": redirect_url}}
179 |
180 | payment_link = stripe.PaymentLink.create(**payment_link_data)
181 |
182 | return {"id": payment_link.id, "url": payment_link.url}
183 |
184 |
185 | def list_invoices(
186 | context: Context,
187 | customer: Optional[str] = None,
188 | limit: Optional[int] = None,
189 | ):
190 | """
191 | List invoices.
192 |
193 | Parameters:
194 | customer (str, optional): The ID of the customer.
195 | limit (int, optional): The number of invoices to return.
196 |
197 | Returns:
198 | stripe.ListObject: A list of invoices.
199 | """
200 | invoice_data: dict = {}
201 | if customer:
202 | invoice_data["customer"] = customer
203 | if limit:
204 | invoice_data["limit"] = limit
205 | if context.get("account") is not None:
206 | account = context.get("account")
207 | if account is not None:
208 | invoice_data["stripe_account"] = account
209 |
210 | return stripe.Invoice.list(**invoice_data).data
211 |
212 |
213 | def create_invoice(context: Context, customer: str, days_until_due: int = 30):
214 | """
215 | Create an invoice.
216 |
217 | Parameters:
218 | customer (str): The ID of the customer.
219 | days_until_due (int, optional): The number of days until the
220 | invoice is due.
221 |
222 | Returns:
223 | stripe.Invoice: The created invoice.
224 | """
225 | invoice_data: dict = {
226 | "customer": customer,
227 | "collection_method": "send_invoice",
228 | "days_until_due": days_until_due,
229 | }
230 | if context.get("account") is not None:
231 | account = context.get("account")
232 | if account is not None:
233 | invoice_data["stripe_account"] = account
234 |
235 | invoice = stripe.Invoice.create(**invoice_data)
236 |
237 | return {
238 | "id": invoice.id,
239 | "hosted_invoice_url": invoice.hosted_invoice_url,
240 | "customer": invoice.customer,
241 | "status": invoice.status,
242 | }
243 |
244 |
245 | def create_invoice_item(
246 | context: Context, customer: str, price: str, invoice: str
247 | ):
248 | """
249 | Create an invoice item.
250 |
251 | Parameters:
252 | customer (str): The ID of the customer.
253 | price (str): The ID of the price.
254 | invoice (str): The ID of the invoice.
255 |
256 | Returns:
257 | stripe.InvoiceItem: The created invoice item.
258 | """
259 | invoice_item_data: dict = {
260 | "customer": customer,
261 | "price": price,
262 | "invoice": invoice,
263 | }
264 | if context.get("account") is not None:
265 | account = context.get("account")
266 | if account is not None:
267 | invoice_item_data["stripe_account"] = account
268 |
269 | invoice_item = stripe.InvoiceItem.create(**invoice_item_data)
270 |
271 | return {"id": invoice_item.id, "invoice": invoice_item.invoice}
272 |
273 |
274 | def finalize_invoice(context: Context, invoice: str):
275 | """
276 | Finalize an invoice.
277 |
278 | Parameters:
279 | invoice (str): The ID of the invoice.
280 |
281 | Returns:
282 | stripe.Invoice: The finalized invoice.
283 | """
284 | invoice_data: dict = {"invoice": invoice}
285 | if context.get("account") is not None:
286 | account = context.get("account")
287 | if account is not None:
288 | invoice_data["stripe_account"] = account
289 |
290 | invoice_object = stripe.Invoice.finalize_invoice(**invoice_data)
291 |
292 | return {
293 | "id": invoice_object.id,
294 | "hosted_invoice_url": invoice_object.hosted_invoice_url,
295 | "customer": invoice_object.customer,
296 | "status": invoice_object.status,
297 | }
298 |
299 |
300 | def retrieve_balance(
301 | context: Context,
302 | ):
303 | """
304 | Retrieve the balance.
305 |
306 | Returns:
307 | stripe.Balance: The balance.
308 | """
309 | balance_data: dict = {}
310 | if context.get("account") is not None:
311 | account = context.get("account")
312 | if account is not None:
313 | balance_data["stripe_account"] = account
314 |
315 | return stripe.Balance.retrieve(**balance_data)
316 |
317 |
318 | def create_refund(
319 | context: Context, payment_intent: str, amount: Optional[int] = None
320 | ):
321 | """
322 | Create a refund.
323 |
324 | Parameters:
325 | payment_intent (str): The ID of the payment intent.
326 | amount (int, optional): The amount to refund in cents.
327 |
328 | Returns:
329 | stripe.Refund: The created refund.
330 | """
331 | refund_data: dict = {
332 | "payment_intent": payment_intent,
333 | }
334 | if amount:
335 | refund_data["amount"] = amount
336 | if context.get("account") is not None:
337 | account = context.get("account")
338 | if account is not None:
339 | refund_data["stripe_account"] = account
340 |
341 | return stripe.Refund.create(**refund_data)
342 |
343 | def list_payment_intents(context: Context, customer: Optional[str] = None, limit: Optional[int] = None):
344 | """
345 | List payment intents.
346 |
347 | Parameters:
348 | customer (str, optional): The ID of the customer to list payment intents for.
349 | limit (int, optional): The number of payment intents to return.
350 |
351 | Returns:
352 | stripe.ListObject: A list of payment intents.
353 | """
354 | payment_intent_data: dict = {}
355 | if customer:
356 | payment_intent_data["customer"] = customer
357 | if limit:
358 | payment_intent_data["limit"] = limit
359 | if context.get("account") is not None:
360 | account = context.get("account")
361 | if account is not None:
362 | payment_intent_data["stripe_account"] = account
363 |
364 | return stripe.PaymentIntent.list(**payment_intent_data).data
365 |
366 | def create_billing_portal_session(context: Context, customer: str, return_url: Optional[str] = None):
367 | """
368 | Creates a session of the customer portal.
369 |
370 | Parameters:
371 | customer (str): The ID of the customer to list payment intents for.
372 | return_url (str, optional): The URL to return to after the session is complete.
373 |
374 | Returns:
375 | stripe.BillingPortalSession: The created billing portal session.
376 | """
377 | billing_portal_session_data: dict = {
378 | "customer": customer,
379 | }
380 | if return_url:
381 | billing_portal_session_data["return_url"] = return_url
382 | if context.get("account") is not None:
383 | account = context.get("account")
384 | if account is not None:
385 | billing_portal_session_data["stripe_account"] = account
386 |
387 | session_object = stripe.billing_portal.Session.create(**billing_portal_session_data)
388 |
389 | return {
390 | "id": session_object.id,
391 | "customer": session_object.customer,
392 | "url": session_object.url,
393 | }
394 |
```