ai-xray 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import * as fs from 'fs';
2
+
3
+ export interface TokenResult {
4
+ characters: number;
5
+ words: number;
6
+ estimated_tokens: number;
7
+ cost_estimate?: {
8
+ model: string;
9
+ input_cost_usd: number;
10
+ };
11
+ }
12
+
13
+ // Simple BPE-style token estimation
14
+ // Average: 4 characters per token for English, but varies by content
15
+ function estimateTokens(text: string): number {
16
+ // Count tokens more accurately by considering:
17
+ // - Word boundaries (~1 token per word on average)
18
+ // - Punctuation (~1 token per ~4 chars)
19
+ // - Numbers (~1 token per number sequence)
20
+
21
+ const words = text.split(/\s+/).filter(w => w.length > 0);
22
+ const wordCount = words.length;
23
+
24
+ // Base estimation: words + chars/4
25
+ const charBased = Math.ceil(text.length / 4);
26
+ const wordBased = Math.ceil(wordCount * 1.3); // ~1.3 tokens per word
27
+
28
+ // Use average of both approaches
29
+ const estimated = Math.round((charBased + wordBased) / 2);
30
+
31
+ return Math.max(1, estimated);
32
+ }
33
+
34
+ // Pricing constants (USD per 1M tokens) - approximate
35
+ const MODEL_PRICING: Record<string, number> = {
36
+ 'gpt-4o': 2.50, // $2.50 per 1M input tokens
37
+ 'gpt-4o-mini': 0.15,
38
+ 'gpt-4-turbo': 10.00,
39
+ 'gpt-4': 30.00,
40
+ 'gpt-3.5-turbo': 0.50,
41
+ 'claude-3-5-sonnet-2024': 3.00,
42
+ 'claude-3-opus-2024': 15.00,
43
+ 'claude-3-haiku-2024': 0.25,
44
+ 'gemini-1.5-pro': 1.25,
45
+ 'gemini-1.5-flash': 0.075,
46
+ };
47
+
48
+ function getModelPrice(modelName: string): number | null {
49
+ const lowerModel = modelName.toLowerCase();
50
+ for (const [key, price] of Object.entries(MODEL_PRICING)) {
51
+ if (lowerModel.includes(key)) {
52
+ return price;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export async function countTokens(
59
+ input: string,
60
+ options?: { model?: string }
61
+ ): Promise<TokenResult> {
62
+ let text = input;
63
+
64
+ // If input looks like a file path, read the file
65
+ if (!input.includes('\n') && fs.existsSync(input)) {
66
+ try {
67
+ text = fs.readFileSync(input, 'utf-8');
68
+ } catch {
69
+ // Keep original input if file read fails
70
+ }
71
+ }
72
+
73
+ const characters = text.length;
74
+ const words = text.split(/\s+/).filter(w => w.length > 0).length;
75
+ const estimatedTokens = estimateTokens(text);
76
+
77
+ const result: TokenResult = {
78
+ characters,
79
+ words,
80
+ estimated_tokens: estimatedTokens,
81
+ };
82
+
83
+ // Add cost estimate if model specified
84
+ if (options?.model) {
85
+ const price = getModelPrice(options.model);
86
+ if (price !== null) {
87
+ const cost = (estimatedTokens / 1000000) * price;
88
+ result.cost_estimate = {
89
+ model: options.model,
90
+ input_cost_usd: parseFloat(cost.toFixed(5)),
91
+ };
92
+ }
93
+ }
94
+
95
+ return result;
96
+ }
@@ -0,0 +1,86 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+ import { URL } from 'url';
4
+
5
+ export interface HttpRequestOptions {
6
+ method?: string;
7
+ headers?: Record<string, string>;
8
+ body?: string;
9
+ timeout?: number;
10
+ }
11
+
12
+ export interface HttpResponse {
13
+ statusCode: number;
14
+ headers: Record<string, string>;
15
+ body: string;
16
+ }
17
+
18
+ export function request(
19
+ urlString: string,
20
+ options: HttpRequestOptions = {}
21
+ ): Promise<HttpResponse> {
22
+ return new Promise((resolve, reject) => {
23
+ const url = new URL(urlString);
24
+ const isHttps = url.protocol === 'https:';
25
+ const client = isHttps ? https : http;
26
+
27
+ const requestOptions: http.RequestOptions = {
28
+ hostname: url.hostname,
29
+ port: url.port || (isHttps ? 443 : 80),
30
+ path: url.pathname + url.search,
31
+ method: options.method || 'GET',
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'Accept': 'application/json',
35
+ ...options.headers,
36
+ },
37
+ timeout: options.timeout || 30000,
38
+ };
39
+
40
+ const req = client.request(requestOptions, (res) => {
41
+ let body = '';
42
+ res.on('data', (chunk) => { body += chunk; });
43
+ res.on('end', () => {
44
+ const headers: Record<string, string> = {};
45
+ for (const [key, value] of Object.entries(res.headers)) {
46
+ if (typeof value === 'string') {
47
+ headers[key] = value;
48
+ } else if (Array.isArray(value)) {
49
+ headers[key] = value.join(', ');
50
+ }
51
+ }
52
+ resolve({
53
+ statusCode: res.statusCode || 0,
54
+ headers,
55
+ body,
56
+ });
57
+ });
58
+ });
59
+
60
+ req.on('error', reject);
61
+ req.on('timeout', () => {
62
+ req.destroy();
63
+ reject(new Error('Request timeout'));
64
+ });
65
+
66
+ if (options.body) {
67
+ req.write(options.body);
68
+ }
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ export function requestJson<T = unknown>(
74
+ urlString: string,
75
+ options: HttpRequestOptions = {}
76
+ ): Promise<{ response: HttpResponse; data: T }> {
77
+ return request(urlString, options).then((response) => {
78
+ let data: T;
79
+ try {
80
+ data = JSON.parse(response.body) as T;
81
+ } catch {
82
+ throw new Error(`Invalid JSON response: ${response.body.slice(0, 100)}`);
83
+ }
84
+ return { response, data };
85
+ });
86
+ }
@@ -1,123 +1,36 @@
1
- /**
2
- * Core output management for ai-xray.
3
- * Guarantees Machine-First protocol: Pure JSON on stdout, structured error on stderr.
4
- */
5
-
6
- // A very rough token estimator (approximately 4 characters per token for English/code)
7
- export function estimateTokens(text: string): number {
8
- return Math.max(1, Math.ceil(text.length / 4));
9
- }
10
-
11
- // Ensure the final object doesn't exceed the token budget if one is provided
12
- export function applyBudget(data: any, budget?: number): any {
13
- if (!budget || budget <= 0) return data;
14
-
15
- const serialized = JSON.stringify(data);
16
- const estimated = estimateTokens(serialized);
17
-
18
- if (estimated > budget) {
19
- // Actually truncate: serialize to JSON, cut to budget char limit, close cleanly
20
- const budgetChars = budget * 4;
21
-
22
- if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
23
- // Try pruning array values first (largest arrays get trimmed)
24
- const pruned = { ...data };
25
- const keys = Object.keys(pruned);
26
-
27
- // Sort keys by serialized size descending to prune largest first
28
- const keySizes = keys
29
- .filter(k => k !== '_meta')
30
- .map(k => ({ key: k, size: JSON.stringify(pruned[k]).length }))
31
- .sort((a, b) => b.size - a.size);
32
-
33
- for (const { key } of keySizes) {
34
- const currentSize = JSON.stringify(pruned).length;
35
- if (currentSize <= budgetChars) break;
36
-
37
- if (Array.isArray(pruned[key]) && pruned[key].length > 0) {
38
- // Trim array items from the end until under budget
39
- const arr = [...pruned[key]];
40
- while (arr.length > 0 && JSON.stringify({ ...pruned, [key]: arr }).length > budgetChars) {
41
- arr.pop();
42
- }
43
- pruned[key] = arr.length > 0 ? arr : `[TRUNCATED: ${pruned[key].length} items removed]`;
44
- } else if (typeof pruned[key] === 'string' && pruned[key].length > 200) {
45
- // Truncate long string values
46
- const allowedLen = Math.max(100, pruned[key].length - (currentSize - budgetChars));
47
- pruned[key] = pruned[key].slice(0, allowedLen) + '...[TRUNCATED]';
48
- }
49
- }
50
-
51
- // Final fallback: if still over budget, hard-truncate the JSON string
52
- const prunedJson = JSON.stringify(pruned);
53
- if (prunedJson.length > budgetChars) {
54
- const truncatedStr = prunedJson.slice(0, budgetChars);
55
- // Try to parse back; if not valid, wrap as raw truncated output
56
- return {
57
- _truncated_raw: truncatedStr,
58
- _meta: {
59
- tokensEstimate: estimated,
60
- budget,
61
- truncated: true,
62
- hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
63
- }
64
- };
65
- }
66
-
67
- return {
68
- ...pruned,
69
- _meta: {
70
- ...(pruned._meta || {}),
71
- tokensEstimate: estimateTokens(JSON.stringify(pruned)),
72
- budget,
73
- truncated: true,
74
- hint: `Output exceeded budget of ${budget} tokens. Some fields were pruned.`
75
- }
76
- };
77
- }
78
-
79
- // Non-object data: hard truncate the serialized string
80
- const truncated = serialized.slice(0, budgetChars);
81
- return {
82
- _truncated_raw: truncated,
83
- _meta: {
84
- tokensEstimate: estimated,
85
- budget,
86
- truncated: true,
87
- hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
88
- }
89
- };
90
- }
91
-
92
- // Under budget — attach meta info
93
- if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
94
- return {
95
- ...data,
96
- _meta: {
97
- ...(data._meta || {}),
98
- tokensEstimate: estimated,
99
- budget,
100
- truncated: false
101
- }
102
- };
103
- }
104
-
105
- return data;
106
- }
107
-
108
- export function outputSuccess(data: any, budget?: number) {
109
- const finalData = applyBudget(data, budget);
110
- const output = JSON.stringify(finalData, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n';
111
- process.stdout.write(output, () => process.exit(0));
112
- }
113
-
114
- export function outputError(error: unknown, code = 1) {
115
- const message = error instanceof Error ? error.message : String(error);
116
- const errorObj = {
117
- error: message,
118
- code: code
119
- };
120
-
121
- process.stderr.write(JSON.stringify(errorObj, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n');
122
- process.exit(code);
123
- }
1
+ /**
2
+ * Core output management for ai-xray v2.0.0
3
+ * Guarantees Machine-First protocol: Pure JSON on stdout, structured error on stderr.
4
+ */
5
+
6
+ let prettyMode = false;
7
+
8
+ export function setPrettyMode(enabled: boolean): void {
9
+ prettyMode = enabled;
10
+ }
11
+
12
+ export function isPrettyMode(): boolean {
13
+ return prettyMode;
14
+ }
15
+
16
+ export function outputSuccess(data: unknown): void {
17
+ const output = JSON.stringify(data, null, prettyMode ? 2 : undefined) + '\n';
18
+ process.stdout.write(output);
19
+ process.exit(0);
20
+ }
21
+
22
+ export function outputError(error: unknown, code = 1): void {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ const errorObj = {
25
+ error: message,
26
+ code
27
+ };
28
+ const output = JSON.stringify(errorObj, null, prettyMode ? 2 : undefined) + '\n';
29
+ process.stderr.write(output);
30
+ process.exit(code);
31
+ }
32
+
33
+ export function outputJson(data: unknown): void {
34
+ const output = JSON.stringify(data, null, prettyMode ? 2 : undefined) + '\n';
35
+ process.stdout.write(output);
36
+ }
@@ -0,0 +1,75 @@
1
+ export class Timer {
2
+ private startTime: number = 0;
3
+ private endTime: number = 0;
4
+ private running: boolean = false;
5
+
6
+ start(): void {
7
+ this.startTime = performance.now();
8
+ this.running = true;
9
+ }
10
+
11
+ stop(): number {
12
+ if (!this.running) {
13
+ throw new Error('Timer is not running');
14
+ }
15
+ this.endTime = performance.now();
16
+ this.running = false;
17
+ return this.elapsed();
18
+ }
19
+
20
+ elapsed(): number {
21
+ if (this.running) {
22
+ return performance.now() - this.startTime;
23
+ }
24
+ return this.endTime - this.startTime;
25
+ }
26
+
27
+ reset(): void {
28
+ this.startTime = 0;
29
+ this.endTime = 0;
30
+ this.running = false;
31
+ }
32
+
33
+ static measure<T>(fn: () => T | Promise<T>): Promise<{ result: T; elapsed_ms: number }> {
34
+ return (async () => {
35
+ const timer = new Timer();
36
+ timer.start();
37
+ const result = await fn();
38
+ const elapsed = timer.stop();
39
+ return { result, elapsed_ms: elapsed };
40
+ })();
41
+ }
42
+
43
+ static async measureAsync<T>(fn: () => Promise<T>): Promise<{ result: T; elapsed_ms: number }> {
44
+ const timer = new Timer();
45
+ timer.start();
46
+ const result = await fn();
47
+ const elapsed = timer.stop();
48
+ return { result, elapsed_ms: elapsed };
49
+ }
50
+ }
51
+
52
+ export function median(values: number[]): number {
53
+ if (values.length === 0) return 0;
54
+ const sorted = [...values].sort((a, b) => a - b);
55
+ const mid = Math.floor(sorted.length / 2);
56
+ return sorted.length % 2 === 0
57
+ ? (sorted[mid - 1] + sorted[mid]) / 2
58
+ : sorted[mid];
59
+ }
60
+
61
+ export function mean(values: number[]): number {
62
+ if (values.length === 0) return 0;
63
+ return values.reduce((a, b) => a + b, 0) / values.length;
64
+ }
65
+
66
+ export function p95(values: number[]): number {
67
+ if (values.length === 0) return 0;
68
+ const sorted = [...values].sort((a, b) => a - b);
69
+ const index = Math.ceil(sorted.length * 0.95) - 1;
70
+ return sorted[index];
71
+ }
72
+
73
+ export function sum(values: number[]): number {
74
+ return values.reduce((a, b) => a + b, 0);
75
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { bench, BenchResult } from '../src/commands/bench';
3
+
4
+ describe('bench', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof bench).toBe('function');
7
+ });
8
+
9
+ it('should accept options parameter', () => {
10
+ // Just verify the function accepts the expected parameters
11
+ expect(bench).toBeDefined();
12
+ });
13
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { loadConfig, ProviderConfig } from '../src/client';
3
+
4
+ describe('client', () => {
5
+ beforeEach(() => {
6
+ delete process.env.AI_XRAY_API_KEY;
7
+ delete process.env.AI_XRAY_BASE_URL;
8
+ delete process.env.AI_XRAY_MODEL;
9
+ });
10
+
11
+ it('should use default values when no env vars set', () => {
12
+ const config = loadConfig();
13
+ expect(config.baseUrl).toBe('https://api.openai.com/v1');
14
+ expect(config.model).toBe('gpt-4o');
15
+ expect(config.apiKey).toBeUndefined();
16
+ });
17
+
18
+ it('should use environment variables when set', () => {
19
+ process.env.AI_XRAY_API_KEY = 'test-key';
20
+ process.env.AI_XRAY_BASE_URL = 'https://custom.api.com/v1';
21
+ process.env.AI_XRAY_MODEL = 'gpt-5';
22
+
23
+ const config = loadConfig();
24
+ expect(config.apiKey).toBe('test-key');
25
+ expect(config.baseUrl).toBe('https://custom.api.com/v1');
26
+ expect(config.model).toBe('gpt-5');
27
+ });
28
+
29
+ it('should use provider config when specified', () => {
30
+ // This test requires a config file, so we just test the function exists
31
+ expect(loadConfig).toBeDefined();
32
+ });
33
+
34
+ it('should loadConfig be a function', () => {
35
+ expect(typeof loadConfig).toBe('function');
36
+ });
37
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compare, CompareResult } from '../src/commands/compare';
3
+
4
+ describe('compare', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof compare).toBe('function');
7
+ });
8
+
9
+ it('should return result with expected structure', async () => {
10
+ const result = await compare(['openai'], { prompt: 'Say hi' });
11
+
12
+ expect(result).toHaveProperty('prompt');
13
+ expect(result).toHaveProperty('results');
14
+
15
+ expect(result.prompt).toBe('Say hi');
16
+ expect(Array.isArray(result.results)).toBe(true);
17
+ });
18
+
19
+ it('should accept custom prompt', async () => {
20
+ const customPrompt = 'Write a haiku';
21
+ const result = await compare(['openai'], { prompt: customPrompt });
22
+ expect(result.prompt).toBe(customPrompt);
23
+ });
24
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { request } from '../src/utils/http';
3
+
4
+ describe('http', () => {
5
+ it('should be defined', () => {
6
+ expect(request).toBeDefined();
7
+ });
8
+
9
+ it('should be a function', () => {
10
+ expect(typeof request).toBe('function');
11
+ });
12
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { identify } from '../src/commands/id';
3
+
4
+ describe('id', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof identify).toBe('function');
7
+ });
8
+
9
+ it('should accept config parameter', () => {
10
+ // Just verify the function exists and accepts expected params
11
+ expect(identify).toBeDefined();
12
+ });
13
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ping } from '../src/commands/ping';
3
+
4
+ describe('ping', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof ping).toBe('function');
7
+ });
8
+
9
+ it('should accept config parameter', () => {
10
+ expect(ping).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { probe, ProbeResult } from '../src/commands/probe';
3
+
4
+ describe('probe', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof probe).toBe('function');
7
+ });
8
+
9
+ it('should accept config parameter', () => {
10
+ // Just verify the function exists and accepts expected params
11
+ expect(probe).toBeDefined();
12
+ });
13
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { countTokens } from '../src/commands/tokenize';
3
+
4
+ describe('tokenize', () => {
5
+ it('should be a function', () => {
6
+ expect(typeof countTokens).toBe('function');
7
+ });
8
+
9
+ it('should count characters accurately', async () => {
10
+ const result = await countTokens('Hello world');
11
+ expect(result.characters).toBe(11);
12
+ });
13
+
14
+ it('should count words accurately', async () => {
15
+ const result = await countTokens('Hello world from AI');
16
+ expect(result.words).toBe(4);
17
+ });
18
+
19
+ it('should estimate tokens within reasonable range', async () => {
20
+ const text = 'The quick brown fox jumps over the lazy dog';
21
+ const result = await countTokens(text);
22
+ expect(result.estimated_tokens).toBeGreaterThan(0);
23
+ expect(result.estimated_tokens).toBeLessThan(50);
24
+ });
25
+
26
+ it('should compute cost estimate for known models', async () => {
27
+ const result = await countTokens('Hello world', { model: 'gpt-4o' });
28
+ expect(result.cost_estimate).toBeDefined();
29
+ expect(result.cost_estimate?.model).toBe('gpt-4o');
30
+ expect(result.cost_estimate?.input_cost_usd).toBeGreaterThan(0);
31
+ });
32
+ });
package/tsup.config.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { defineConfig } from 'tsup';
2
-
3
- export default defineConfig({
4
- entry: ['src/cli.ts'],
5
- format: ['cjs'],
6
- target: 'node18',
7
- clean: true,
8
- minify: true,
9
- // We bundle everything so there are zero dependencies at runtime
10
- noExternal: [/(.*)/],
11
- });
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/cli.ts'],
5
+ format: ['cjs'],
6
+ target: 'node18',
7
+ clean: true,
8
+ sourcemap: true,
9
+ // We bundle everything so there are zero dependencies at runtime
10
+ noExternal: [/(.*)/],
11
+ });
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['tests/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html'],
11
+ },
12
+ },
13
+ });