From 2edcdb044a216b7702a51d09155c918331588a48 Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Thu, 23 Jul 2026 11:48:31 -0600 Subject: [PATCH] fix(ai-featherless): require clean api base urls --- packages/ai/featherless/src/index.test.ts | 20 ++++++++++++++++++-- packages/ai/featherless/src/index.ts | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/ai/featherless/src/index.test.ts b/packages/ai/featherless/src/index.test.ts index 807b8d5b..72789410 100644 --- a/packages/ai/featherless/src/index.test.ts +++ b/packages/ai/featherless/src/index.test.ts @@ -54,14 +54,14 @@ describe('Featherless OpenAI-compatible generation', () => { temperature: 0.5, extra: { top_p: 0.9, min_p: 0.05 }, }, - {} + { baseUrl: 'https://api.featherless.test/v1/' } ); expect(fetchMock).toHaveBeenCalledOnce(); const call = fetchMock.mock.calls[0]; expect(call).toBeDefined(); const [url, request] = call!; - expect(url).toBe('https://api.featherless.ai/v1/chat/completions'); + expect(url).toBe('https://api.featherless.test/v1/chat/completions'); expect(request.headers.authorization).toBe('Bearer test-key'); expect(request.headers['HTTP-Referer']).toBe('https://github.com/profullstack/sh1pt'); expect(request.headers['X-Title']).toBe('sh1pt'); @@ -84,6 +84,22 @@ describe('Featherless OpenAI-compatible generation', () => { }); }); + it.each([ + ['missing scheme', 'api.featherless.test/v1'], + ['ftp scheme', 'ftp://api.featherless.test/v1'], + ['credentials', 'https://user:pass@api.featherless.test/v1'], + ['query string', 'https://api.featherless.test/v1?token=secret'], + ['fragment', 'https://api.featherless.test/v1#chat'], + ])('rejects unclean custom baseUrl values: %s', async (_label, baseUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow( + /Featherless baseUrl/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('includes status and response body excerpt on errors', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, diff --git a/packages/ai/featherless/src/index.ts b/packages/ai/featherless/src/index.ts index 7686890b..2e237f0b 100644 --- a/packages/ai/featherless/src/index.ts +++ b/packages/ai/featherless/src/index.ts @@ -24,7 +24,8 @@ export default defineAi({ if (opts.system) messages.push({ role: 'system', content: opts.system }); messages.push({ role: 'user', content: prompt }); - const res = await fetch(`${config.baseUrl ?? DEFAULT_BASE}/chat/completions`, { + const baseUrl = cleanBaseUrl(config.baseUrl ?? DEFAULT_BASE); + const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, @@ -82,3 +83,22 @@ interface FeatherlessChatResponse { completion_tokens?: number; }; } + +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Featherless baseUrl must be a valid URL'); + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Featherless baseUrl must use http or https'); + } + + if (url.username || url.password || url.search || url.hash) { + throw new Error('Featherless baseUrl must be a clean API base without credentials, query, or hash'); + } + + return url.toString().replace(/\/+$/, ''); +}