Skip to content

Commit 9d087bc

Browse files
committed
fix: sanitize proxy body for non-OpenAI providers and improve rate limiting
- Strip OpenAI-only fields (store, max_completion_tokens, metadata, etc.) before forwarding to Mistral, DeepSeek, and other providers that reject extra inputs - Convert max_completion_tokens to max_tokens for provider compatibility - Only count successful provider responses against the rate limit so gateway retries on upstream failures don't exhaust the user's quota - Increase rate limit from 60 to 200 requests/minute - Fix E2E test span name to use recognized openclaw.agent.turn pattern
1 parent d6f679e commit 9d087bc

File tree

7 files changed

+238
-193
lines changed

7 files changed

+238
-193
lines changed

packages/backend/src/routing/proxy/__tests__/provider-client.spec.ts

Lines changed: 103 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,7 @@ describe('ProviderClient', () => {
130130
it('does not include anthropic-beta header (caching is GA)', async () => {
131131
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
132132

133-
await client.forward(
134-
'anthropic',
135-
'sk-ant-test',
136-
'claude-sonnet-4-20250514',
137-
body,
138-
false,
139-
);
133+
await client.forward('anthropic', 'sk-ant-test', 'claude-sonnet-4-20250514', body, false);
140134

141135
const headers = mockFetch.mock.calls[0][1].headers;
142136
expect(headers['anthropic-beta']).toBeUndefined();
@@ -187,13 +181,7 @@ describe('ProviderClient', () => {
187181
it('uses query param auth and Gemini path', async () => {
188182
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
189183

190-
const result = await client.forward(
191-
'google',
192-
'AIza-test',
193-
'gemini-2.0-flash',
194-
body,
195-
false,
196-
);
184+
const result = await client.forward('google', 'AIza-test', 'gemini-2.0-flash', body, false);
197185

198186
const url = mockFetch.mock.calls[0][0] as string;
199187
expect(url).toContain('generativelanguage.googleapis.com');
@@ -259,13 +247,7 @@ describe('ProviderClient', () => {
259247
{ role: 'user', content: 'Hi' },
260248
],
261249
};
262-
await client.forward(
263-
'openrouter',
264-
'sk-or',
265-
'openai/gpt-4o',
266-
bodyWithSystem,
267-
false,
268-
);
250+
await client.forward('openrouter', 'sk-or', 'openai/gpt-4o', bodyWithSystem, false);
269251

270252
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
271253
expect(typeof sentBody.messages[0].content).toBe('string');
@@ -276,15 +258,9 @@ describe('ProviderClient', () => {
276258
it('merges extraHeaders into outgoing request', async () => {
277259
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
278260

279-
await client.forward(
280-
'xai',
281-
'sk-xai',
282-
'grok-2',
283-
body,
284-
false,
285-
undefined,
286-
{ 'x-grok-conv-id': 'session-123' },
287-
);
261+
await client.forward('xai', 'sk-xai', 'grok-2', body, false, undefined, {
262+
'x-grok-conv-id': 'session-123',
263+
});
288264

289265
const fetchOptions = mockFetch.mock.calls[0][1];
290266
expect(fetchOptions.headers['x-grok-conv-id']).toBe('session-123');
@@ -293,15 +269,9 @@ describe('ProviderClient', () => {
293269
it('does not override base headers', async () => {
294270
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
295271

296-
await client.forward(
297-
'openai',
298-
'sk-test',
299-
'gpt-4o',
300-
body,
301-
false,
302-
undefined,
303-
{ 'X-Custom': 'value' },
304-
);
272+
await client.forward('openai', 'sk-test', 'gpt-4o', body, false, undefined, {
273+
'X-Custom': 'value',
274+
});
305275

306276
const fetchOptions = mockFetch.mock.calls[0][1];
307277
expect(fetchOptions.headers['Authorization']).toBe('Bearer sk-test');
@@ -313,13 +283,7 @@ describe('ProviderClient', () => {
313283
it('resolves gemini to google endpoint', async () => {
314284
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
315285

316-
const result = await client.forward(
317-
'gemini',
318-
'AIza-test',
319-
'gemini-2.0-flash',
320-
body,
321-
false,
322-
);
286+
const result = await client.forward('gemini', 'AIza-test', 'gemini-2.0-flash', body, false);
323287

324288
const url = mockFetch.mock.calls[0][0] as string;
325289
expect(url).toContain('generativelanguage.googleapis.com');
@@ -394,10 +358,12 @@ describe('ProviderClient', () => {
394358
describe('convertGoogleResponse', () => {
395359
it('delegates to fromGoogleResponse', () => {
396360
const googleBody = {
397-
candidates: [{
398-
content: { parts: [{ text: 'Hello' }] },
399-
finishReason: 'STOP',
400-
}],
361+
candidates: [
362+
{
363+
content: { parts: [{ text: 'Hello' }] },
364+
finishReason: 'STOP',
365+
},
366+
],
401367
};
402368
const result = client.convertGoogleResponse(googleBody, 'gemini-2.0-flash');
403369

@@ -411,9 +377,11 @@ describe('ProviderClient', () => {
411377
describe('convertGoogleStreamChunk', () => {
412378
it('delegates to transformGoogleStreamChunk', () => {
413379
const chunk = JSON.stringify({
414-
candidates: [{
415-
content: { parts: [{ text: 'Hi' }] },
416-
}],
380+
candidates: [
381+
{
382+
content: { parts: [{ text: 'Hi' }] },
383+
},
384+
],
417385
});
418386
const result = client.convertGoogleStreamChunk(chunk, 'gemini-2.0-flash');
419387

@@ -445,7 +413,8 @@ describe('ProviderClient', () => {
445413

446414
describe('convertAnthropicStreamChunk', () => {
447415
it('delegates to transformAnthropicStreamChunk', () => {
448-
const chunk = 'event: content_block_delta\n{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}';
416+
const chunk =
417+
'event: content_block_delta\n{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}';
449418
const result = client.convertAnthropicStreamChunk(chunk, 'claude-sonnet-4-20250514');
450419

451420
expect(result).toContain('data: ');
@@ -471,12 +440,8 @@ describe('ProviderClient', () => {
471440

472441
await client.forward('google', 'AIzaSyABCDEF12345', 'gemini-2.0-flash', body, false);
473442

474-
expect(debugSpy).toHaveBeenCalledWith(
475-
expect.stringContaining('key=***'),
476-
);
477-
expect(debugSpy).toHaveBeenCalledWith(
478-
expect.not.stringContaining('AIzaSyABCDEF12345'),
479-
);
443+
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('key=***'));
444+
expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining('AIzaSyABCDEF12345'));
480445

481446
debugSpy.mockRestore();
482447
});
@@ -507,19 +472,91 @@ describe('ProviderClient', () => {
507472
});
508473
});
509474

475+
describe('Body sanitization for non-OpenAI providers', () => {
476+
const bodyWithOpenAiFields = {
477+
messages: [{ role: 'user', content: 'Hello' }],
478+
temperature: 0.7,
479+
store: false,
480+
max_completion_tokens: 8192,
481+
metadata: { user: 'test' },
482+
service_tier: 'default',
483+
stream_options: { include_usage: true },
484+
};
485+
486+
it('strips OpenAI-only fields for Mistral', async () => {
487+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
488+
await client.forward('mistral', 'sk-mi', 'mistral-small', bodyWithOpenAiFields, false);
489+
490+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
491+
expect(sentBody.store).toBeUndefined();
492+
expect(sentBody.metadata).toBeUndefined();
493+
expect(sentBody.service_tier).toBeUndefined();
494+
expect(sentBody.stream_options).toBeUndefined();
495+
expect(sentBody.messages).toEqual(bodyWithOpenAiFields.messages);
496+
expect(sentBody.temperature).toBe(0.7);
497+
});
498+
499+
it('converts max_completion_tokens to max_tokens for non-OpenAI providers', async () => {
500+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
501+
await client.forward('mistral', 'sk-mi', 'mistral-small', bodyWithOpenAiFields, false);
502+
503+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
504+
expect(sentBody.max_completion_tokens).toBeUndefined();
505+
expect(sentBody.max_tokens).toBe(8192);
506+
});
507+
508+
it('does not overwrite existing max_tokens when converting', async () => {
509+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
510+
const bodyWithBoth = { ...bodyWithOpenAiFields, max_tokens: 4096 };
511+
await client.forward('deepseek', 'sk-ds', 'deepseek-chat', bodyWithBoth, false);
512+
513+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
514+
expect(sentBody.max_tokens).toBe(4096);
515+
expect(sentBody.max_completion_tokens).toBeUndefined();
516+
});
517+
518+
it('strips OpenAI-only fields for DeepSeek', async () => {
519+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
520+
await client.forward('deepseek', 'sk-ds', 'deepseek-chat', bodyWithOpenAiFields, false);
521+
522+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
523+
expect(sentBody.store).toBeUndefined();
524+
expect(sentBody.service_tier).toBeUndefined();
525+
});
526+
527+
it('preserves all fields for OpenAI', async () => {
528+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
529+
await client.forward('openai', 'sk-test', 'gpt-4o', bodyWithOpenAiFields, false);
530+
531+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
532+
expect(sentBody.store).toBe(false);
533+
expect(sentBody.max_completion_tokens).toBe(8192);
534+
expect(sentBody.metadata).toEqual({ user: 'test' });
535+
});
536+
537+
it('preserves all fields for OpenRouter', async () => {
538+
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
539+
await client.forward('openrouter', 'sk-or', 'openai/gpt-4o', bodyWithOpenAiFields, false);
540+
541+
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
542+
expect(sentBody.store).toBe(false);
543+
expect(sentBody.max_completion_tokens).toBe(8192);
544+
});
545+
});
546+
510547
describe('Error handling', () => {
511548
it('throws for unknown provider', async () => {
512-
await expect(
513-
client.forward('unknown-provider', 'key', 'model', body, false),
514-
).rejects.toThrow('No endpoint configured for provider');
549+
await expect(client.forward('unknown-provider', 'key', 'model', body, false)).rejects.toThrow(
550+
'No endpoint configured for provider',
551+
);
515552
});
516553

517554
it('propagates fetch errors', async () => {
518555
mockFetch.mockRejectedValue(new Error('Network error'));
519556

520-
await expect(
521-
client.forward('openai', 'sk-test', 'gpt-4o', body, false),
522-
).rejects.toThrow('Network error');
557+
await expect(client.forward('openai', 'sk-test', 'gpt-4o', body, false)).rejects.toThrow(
558+
'Network error',
559+
);
523560
});
524561
});
525562
});

0 commit comments

Comments
 (0)