@loopers/client 1.0.0 → 1.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [1.3.0](https://github.com/CURSED-ME/loopers-oss/compare/sdk-ts-v1.2.0...sdk-ts-v1.3.0) (2026-06-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * add 14 providers, bump SDKs to 1.0.0, prepare for launch ([abd1d6d](https://github.com/CURSED-ME/loopers-oss/commit/abd1d6d06623bd4ba475247cc8f4d83d744e67f4))
9
+ * **sdk:** support Phase 1 session headers ([ec81008](https://github.com/CURSED-ME/loopers-oss/commit/ec81008939fb5c3d9e78fe210d2e6c766bfc99b6))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * bump sdk versions and disable homebrew publishing for CI ([9e8ceae](https://github.com/CURSED-ME/loopers-oss/commit/9e8ceaef264fcde3dad46af132481f9db91cebb0))
15
+ * **ci:** disable homebrew tap until repo is created, bump versions to 0.4.5 ([8523ff4](https://github.com/CURSED-ME/loopers-oss/commit/8523ff498a82e8310a8cdc6f856e39b399ef7d5a))
16
+ * **ci:** fix goreleaser sbom config and bump versions to 0.4.3 ([56563ec](https://github.com/CURSED-ME/loopers-oss/commit/56563ece8d04927227308905b7105e396326880f))
17
+ * **ci:** fix invalid action commit hashes for goreleaser, bump version to 0.4.2 ([d6a40ef](https://github.com/CURSED-ME/loopers-oss/commit/d6a40efa1f51529046d3513368b9bea6e7d4ae54))
18
+ * **ci:** point syft to latest v0 to support --enrich flag and bump to v0.4.7 ([29a7f7f](https://github.com/CURSED-ME/loopers-oss/commit/29a7f7f7c2ae1ba293004db61a9743a2218b55d5))
19
+ * **ci:** use latest syft for goreleaser sboms, bump versions to 0.4.4 ([af0790a](https://github.com/CURSED-ME/loopers-oss/commit/af0790a6245cd9e9bf309b5747dfb89e6fe8c3f3))
20
+ * **sdk:** add Phase 1 headers to all adapters, bump to v1.2.0, expand test coverage ([9a40389](https://github.com/CURSED-ME/loopers-oss/commit/9a40389146df62674d53705f500851601181d1cb))
21
+ * **sdk:** fix anthropic sdk version in TS package, bump versions to 0.4.1 ([df374e6](https://github.com/CURSED-ME/loopers-oss/commit/df374e6d4e95aa09d77f24f94dd10dd41444c3b2))
22
+ * **security:** address OpenSSF scorecard issues and bump to v0.4.6 ([4d31477](https://github.com/CURSED-ME/loopers-oss/commit/4d3147704db87b8c11e988ddd17b06f18a658c9d))
package/dist/client.d.ts CHANGED
@@ -7,6 +7,9 @@ export interface LoopersClientOptions {
7
7
  sessionId?: string;
8
8
  sessionBudget?: number;
9
9
  maxSteps?: number;
10
+ sessionTtl?: number;
11
+ maxTools?: number;
12
+ maxServers?: number;
10
13
  }
11
14
  export declare class LoopersOpenAI extends OpenAI {
12
15
  constructor(options: LoopersClientOptions & {
package/dist/client.js CHANGED
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LoopersAnthropic = exports.LoopersTogether = exports.LoopersDeepSeek = exports.LoopersMistral = exports.LoopersGroq = exports.LoopersOpenAI = void 0;
7
7
  const openai_1 = __importDefault(require("openai"));
8
8
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
9
- function createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, customFetch) {
9
+ function createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, sessionTtl, maxTools, maxServers, customFetch) {
10
10
  const originalFetch = customFetch || (typeof fetch !== 'undefined' ? fetch : undefined);
11
11
  if (!originalFetch) {
12
12
  throw new Error('A global fetch function is not available. Please pass a custom fetch implementation (e.g. node-fetch) or use Node.js 18+.');
@@ -28,6 +28,15 @@ function createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, m
28
28
  if (maxSteps !== undefined) {
29
29
  headers.set('X-Loopers-Session-Max-Steps', String(maxSteps));
30
30
  }
31
+ if (sessionTtl !== undefined) {
32
+ headers.set('X-Loopers-Session-TTL', String(sessionTtl));
33
+ }
34
+ if (maxTools !== undefined) {
35
+ headers.set('X-Loopers-Session-Max-Tools', String(maxTools));
36
+ }
37
+ if (maxServers !== undefined) {
38
+ headers.set('X-Loopers-Session-Max-Servers', String(maxServers));
39
+ }
31
40
  const modifiedInit = {
32
41
  ...init,
33
42
  headers,
@@ -78,9 +87,9 @@ function createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, m
78
87
  }
79
88
  class LoopersOpenAI extends openai_1.default {
80
89
  constructor(options) {
81
- const { loopersUrl, loopersKey, providerKey, sessionId, sessionBudget, maxSteps, _providerPath, ...openaiOptions } = options;
90
+ const { loopersUrl, loopersKey, providerKey, sessionId, sessionBudget, maxSteps, sessionTtl, maxTools, maxServers, _providerPath, ...openaiOptions } = options;
82
91
  const baseFetch = openaiOptions.fetch;
83
- const loopersFetch = createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, baseFetch);
92
+ const loopersFetch = createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, sessionTtl, maxTools, maxServers, baseFetch);
84
93
  super({
85
94
  ...openaiOptions,
86
95
  baseURL: `${loopersUrl.replace(/\/$/, '')}/${_providerPath || 'openai/v1'}`,
@@ -116,9 +125,9 @@ class LoopersTogether extends LoopersOpenAI {
116
125
  exports.LoopersTogether = LoopersTogether;
117
126
  class LoopersAnthropic extends sdk_1.default {
118
127
  constructor(options) {
119
- const { loopersUrl, loopersKey, providerKey, sessionId, sessionBudget, maxSteps, ...anthropicOptions } = options;
128
+ const { loopersUrl, loopersKey, providerKey, sessionId, sessionBudget, maxSteps, sessionTtl, maxTools, maxServers, ...anthropicOptions } = options;
120
129
  const baseFetch = anthropicOptions.fetch;
121
- const loopersFetch = createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, baseFetch);
130
+ const loopersFetch = createLoopersFetch(loopersKey, providerKey, sessionId, sessionBudget, maxSteps, sessionTtl, maxTools, maxServers, baseFetch);
122
131
  super({
123
132
  ...anthropicOptions,
124
133
  baseURL: `${loopersUrl.replace(/\/$/, '')}/anthropic`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopers/client",
3
- "version": "1.0.0",
3
+ "version": "1.3.0",
4
4
  "description": "A premium TypeScript client wrapper for the Loopers AI budget & rate-limit proxy.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/client.ts CHANGED
@@ -8,6 +8,9 @@ export interface LoopersClientOptions {
8
8
  sessionId?: string;
9
9
  sessionBudget?: number;
10
10
  maxSteps?: number;
11
+ sessionTtl?: number;
12
+ maxTools?: number;
13
+ maxServers?: number;
11
14
  }
12
15
 
13
16
  function createLoopersFetch(
@@ -16,6 +19,9 @@ function createLoopersFetch(
16
19
  sessionId?: string,
17
20
  sessionBudget?: number,
18
21
  maxSteps?: number,
22
+ sessionTtl?: number,
23
+ maxTools?: number,
24
+ maxServers?: number,
19
25
  customFetch?: typeof fetch
20
26
  ) {
21
27
  const originalFetch = customFetch || (typeof fetch !== 'undefined' ? fetch : undefined);
@@ -41,6 +47,15 @@ function createLoopersFetch(
41
47
  if (maxSteps !== undefined) {
42
48
  headers.set('X-Loopers-Session-Max-Steps', String(maxSteps));
43
49
  }
50
+ if (sessionTtl !== undefined) {
51
+ headers.set('X-Loopers-Session-TTL', String(sessionTtl));
52
+ }
53
+ if (maxTools !== undefined) {
54
+ headers.set('X-Loopers-Session-Max-Tools', String(maxTools));
55
+ }
56
+ if (maxServers !== undefined) {
57
+ headers.set('X-Loopers-Session-Max-Servers', String(maxServers));
58
+ }
44
59
 
45
60
  const modifiedInit = {
46
61
  ...init,
@@ -108,6 +123,9 @@ export class LoopersOpenAI extends OpenAI {
108
123
  sessionId,
109
124
  sessionBudget,
110
125
  maxSteps,
126
+ sessionTtl,
127
+ maxTools,
128
+ maxServers,
111
129
  _providerPath,
112
130
  ...openaiOptions
113
131
  } = options;
@@ -119,6 +137,9 @@ export class LoopersOpenAI extends OpenAI {
119
137
  sessionId,
120
138
  sessionBudget,
121
139
  maxSteps,
140
+ sessionTtl,
141
+ maxTools,
142
+ maxServers,
122
143
  baseFetch
123
144
  );
124
145
 
@@ -168,6 +189,9 @@ export class LoopersAnthropic extends Anthropic {
168
189
  sessionId,
169
190
  sessionBudget,
170
191
  maxSteps,
192
+ sessionTtl,
193
+ maxTools,
194
+ maxServers,
171
195
  ...anthropicOptions
172
196
  } = options;
173
197
 
@@ -178,6 +202,9 @@ export class LoopersAnthropic extends Anthropic {
178
202
  sessionId,
179
203
  sessionBudget,
180
204
  maxSteps,
205
+ sessionTtl,
206
+ maxTools,
207
+ maxServers,
181
208
  baseFetch
182
209
  );
183
210
 
@@ -1,16 +1,40 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { LoopersOpenAI, LoopersAnthropic } from '../src/client';
2
+ import { LoopersOpenAI, LoopersAnthropic, LoopersGroq } from '../src/client';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeMockFetch(responseHeaders: Record<string, string> = {}) {
9
+ return vi.fn().mockResolvedValue({
10
+ json: async () => ({ id: 'chatcmpl-123', choices: [], object: 'chat.completion' }),
11
+ text: async () => JSON.stringify({ id: 'chatcmpl-123', choices: [], object: 'chat.completion' }),
12
+ headers: new Headers(responseHeaders),
13
+ ok: true,
14
+ status: 200,
15
+ });
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // LoopersOpenAI
20
+ // ---------------------------------------------------------------------------
3
21
 
4
22
  describe('LoopersOpenAI', () => {
5
- it('should override baseURL and inject headers', async () => {
6
- const mockFetch = vi.fn().mockResolvedValue({
7
- json: async () => ({ id: 'chatcmpl-123' }),
8
- text: async () => JSON.stringify({ id: 'chatcmpl-123' }),
9
- headers: new Headers(),
10
- ok: true,
11
- status: 200,
23
+ it('should route to the correct URL', async () => {
24
+ const mockFetch = makeMockFetch();
25
+ const client = new LoopersOpenAI({
26
+ loopersUrl: 'http://localhost:8080',
27
+ loopersKey: 'lp-123',
28
+ fetch: mockFetch as any,
12
29
  });
30
+ await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] });
13
31
 
32
+ const [url] = mockFetch.mock.calls[0];
33
+ expect(url.toString()).toBe('http://localhost:8080/openai/v1/chat/completions');
34
+ });
35
+
36
+ it('should inject all governance headers', async () => {
37
+ const mockFetch = makeMockFetch();
14
38
  const client = new LoopersOpenAI({
15
39
  loopersUrl: 'http://localhost:8080',
16
40
  loopersKey: 'lp-123',
@@ -18,60 +42,70 @@ describe('LoopersOpenAI', () => {
18
42
  sessionId: 'sess-1',
19
43
  sessionBudget: 5.0,
20
44
  maxSteps: 10,
45
+ sessionTtl: 3600,
46
+ maxTools: 5,
47
+ maxServers: 2,
21
48
  fetch: mockFetch as any,
22
49
  });
50
+ await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] });
23
51
 
24
- await client.chat.completions.create({
25
- model: 'gpt-4',
26
- messages: [{ role: 'user', content: 'hi' }],
27
- });
28
-
29
- expect(mockFetch).toHaveBeenCalledTimes(1);
30
- const [url, init] = mockFetch.mock.calls[0];
31
- expect(url.toString()).toBe('http://localhost:8080/openai/v1/chat/completions');
32
-
52
+ const [, init] = mockFetch.mock.calls[0];
33
53
  const headers = new Headers(init.headers);
34
54
  expect(headers.get('Authorization')).toBe('Bearer lp-123');
35
55
  expect(headers.get('X-Loopers-Provider-Key')).toBe('sk-openai');
36
56
  expect(headers.get('X-Loopers-Session-ID')).toBe('sess-1');
37
57
  expect(headers.get('X-Loopers-Session-Budget')).toBe('5');
38
58
  expect(headers.get('X-Loopers-Session-Max-Steps')).toBe('10');
59
+ expect(headers.get('X-Loopers-Session-TTL')).toBe('3600');
60
+ expect(headers.get('X-Loopers-Session-Max-Tools')).toBe('5');
61
+ expect(headers.get('X-Loopers-Session-Max-Servers')).toBe('2');
39
62
  });
40
63
 
41
- // BUG: The TS SDK intercepts res.json() to attach metrics, but the underlying
42
- // OpenAI SDK uses res.text() and JSON.parse() internally. This means the metrics
43
- // are never attached to the returned object. Skipping this test until the SDK is redesigned.
44
- it.skip('should parse loopers metrics from headers', async () => {
45
-
46
- const mockFetch = vi.fn().mockResolvedValue({
47
- json: async () => ({ id: 'chatcmpl-123' }),
48
- text: async () => JSON.stringify({ id: 'chatcmpl-123' }),
49
- headers: mockHeaders,
50
- ok: true,
51
- status: 200,
52
- });
53
-
64
+ it('should NOT send optional headers when they are not set', async () => {
65
+ const mockFetch = makeMockFetch();
54
66
  const client = new LoopersOpenAI({
55
67
  loopersUrl: 'http://localhost:8080',
56
68
  loopersKey: 'lp-123',
57
69
  fetch: mockFetch as any,
58
70
  });
71
+ await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] });
59
72
 
60
- const res = await client.chat.completions.create({
61
- model: 'gpt-4',
62
- messages: [{ role: 'user', content: 'hi' }],
63
- }) as any;
73
+ const [, init] = mockFetch.mock.calls[0];
74
+ const headers = new Headers(init.headers);
75
+ expect(headers.get('X-Loopers-Session-Budget')).toBeNull();
76
+ expect(headers.get('X-Loopers-Session-Max-Steps')).toBeNull();
77
+ expect(headers.get('X-Loopers-Session-TTL')).toBeNull();
78
+ expect(headers.get('X-Loopers-Session-Max-Tools')).toBeNull();
79
+ expect(headers.get('X-Loopers-Session-Max-Servers')).toBeNull();
80
+ });
64
81
 
82
+ // NOTE: The TS SDK intercepts res.json() but the underlying OpenAI SDK
83
+ // internally uses res.text() + JSON.parse(), so metrics are not attached
84
+ // to the returned typed object. This is a known limitation documented as
85
+ // a skipped test — the raw fetch response does expose the headers correctly.
86
+ it.skip('should parse loopers metrics from response headers', async () => {
87
+ const mockFetch = vi.fn().mockResolvedValue({
88
+ json: async () => ({ id: 'chatcmpl-123', choices: [], object: 'chat.completion' }),
89
+ text: async () => JSON.stringify({ id: 'chatcmpl-123', choices: [], object: 'chat.completion' }),
90
+ headers: new Headers({ 'X-Loopers-Request-Cost': '0.01', 'X-Loopers-Session-Spend': '0.05' }),
91
+ ok: true,
92
+ status: 200,
93
+ });
94
+ const client = new LoopersOpenAI({ loopersUrl: 'http://localhost:8080', loopersKey: 'lp-123', fetch: mockFetch as any });
95
+ const res = await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'hi' }] }) as any;
65
96
  expect(res.loopers_cost).toBe(0.01);
66
- expect(res.loopers_session_spend).toBe(0.05);
67
97
  });
68
98
  });
69
99
 
100
+ // ---------------------------------------------------------------------------
101
+ // LoopersAnthropic
102
+ // ---------------------------------------------------------------------------
103
+
70
104
  describe('LoopersAnthropic', () => {
71
- it('should override baseURL and inject headers', async () => {
105
+ it('should route to the correct URL and inject governance headers', async () => {
72
106
  const mockFetch = vi.fn().mockResolvedValue({
73
- json: async () => ({ id: 'msg_123', type: 'message' }),
74
- text: async () => JSON.stringify({ id: 'msg_123', type: 'message' }),
107
+ json: async () => ({ id: 'msg_123', type: 'message', role: 'assistant', content: [], model: 'claude-3', stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } }),
108
+ text: async () => JSON.stringify({ id: 'msg_123', type: 'message', role: 'assistant', content: [], model: 'claude-3', stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 } }),
75
109
  headers: new Headers(),
76
110
  ok: true,
77
111
  status: 200,
@@ -81,6 +115,12 @@ describe('LoopersAnthropic', () => {
81
115
  loopersUrl: 'http://localhost:8080',
82
116
  loopersKey: 'lp-123',
83
117
  providerKey: 'sk-ant',
118
+ sessionId: 'sess-ant-1',
119
+ sessionBudget: 10.0,
120
+ maxSteps: 5,
121
+ sessionTtl: 1800,
122
+ maxTools: 3,
123
+ maxServers: 1,
84
124
  fetch: mockFetch as any,
85
125
  });
86
126
 
@@ -90,42 +130,43 @@ describe('LoopersAnthropic', () => {
90
130
  max_tokens: 10,
91
131
  });
92
132
 
93
- expect(mockFetch).toHaveBeenCalledTimes(1);
94
133
  const [url, init] = mockFetch.mock.calls[0];
95
134
  expect(url.toString()).toBe('http://localhost:8080/anthropic/v1/messages');
96
-
97
135
  const headers = new Headers(init.headers);
98
136
  expect(headers.get('Authorization')).toBe('Bearer lp-123');
99
137
  expect(headers.get('X-Loopers-Provider-Key')).toBe('sk-ant');
138
+ expect(headers.get('X-Loopers-Session-ID')).toBe('sess-ant-1');
139
+ expect(headers.get('X-Loopers-Session-Budget')).toBe('10');
140
+ expect(headers.get('X-Loopers-Session-Max-Steps')).toBe('5');
141
+ expect(headers.get('X-Loopers-Session-TTL')).toBe('1800');
142
+ expect(headers.get('X-Loopers-Session-Max-Tools')).toBe('3');
143
+ expect(headers.get('X-Loopers-Session-Max-Servers')).toBe('1');
100
144
  });
101
145
  });
102
146
 
103
- import { LoopersGroq } from '../src/client';
147
+ // ---------------------------------------------------------------------------
148
+ // Provider subclasses
149
+ // ---------------------------------------------------------------------------
104
150
 
105
151
  describe('LoopersGroq', () => {
106
- it('should override baseURL to groq/v1', async () => {
107
- const mockFetch = vi.fn().mockResolvedValue({
108
- json: async () => ({ id: 'chatcmpl-123' }),
109
- text: async () => JSON.stringify({ id: 'chatcmpl-123' }),
110
- headers: new Headers(),
111
- ok: true,
112
- status: 200,
113
- });
114
-
152
+ it('should route to groq/v1 path with all new headers', async () => {
153
+ const mockFetch = makeMockFetch();
115
154
  const client = new LoopersGroq({
116
155
  loopersUrl: 'http://localhost:8080',
117
156
  loopersKey: 'lp-123',
118
157
  providerKey: 'gsk_123',
158
+ sessionTtl: 600,
159
+ maxTools: 10,
160
+ maxServers: 3,
119
161
  fetch: mockFetch as any,
120
162
  });
163
+ await client.chat.completions.create({ model: 'llama-3', messages: [{ role: 'user', content: 'hi' }] });
121
164
 
122
- await client.chat.completions.create({
123
- model: 'llama-3',
124
- messages: [{ role: 'user', content: 'hi' }],
125
- });
126
-
127
- expect(mockFetch).toHaveBeenCalledTimes(1);
128
165
  const [url, init] = mockFetch.mock.calls[0];
129
166
  expect(url.toString()).toBe('http://localhost:8080/groq/v1/chat/completions');
167
+ const headers = new Headers(init.headers);
168
+ expect(headers.get('X-Loopers-Session-TTL')).toBe('600');
169
+ expect(headers.get('X-Loopers-Session-Max-Tools')).toBe('10');
170
+ expect(headers.get('X-Loopers-Session-Max-Servers')).toBe('3');
130
171
  });
131
172
  });