diff --git a/packages/ai/infermatic/src/index.test.ts b/packages/ai/infermatic/src/index.test.ts index 87178cde..f077f5e9 100644 --- a/packages/ai/infermatic/src/index.test.ts +++ b/packages/ai/infermatic/src/index.test.ts @@ -87,22 +87,43 @@ describe('Infermatic chat completions generation', () => { }); it('supports text-style choices from compatible responses', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: 'TheDrummer-UnslopNemo-12B-v4.1', choices: [{ text: 'legacy text response' }], }), - })); + }); + vi.stubGlobal('fetch', fetchMock); - const result = await adapter.generate(ctx(), 'hello', {}, { baseUrl: 'https://infermatic.test' }); + const result = await adapter.generate(ctx(), 'hello', {}, { baseUrl: 'https://infermatic.test/' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://infermatic.test/v1/chat/completions', + expect.any(Object), + ); expect(result).toEqual({ text: 'legacy text response', model: 'TheDrummer-UnslopNemo-12B-v4.1', }); }); + it.each([ + ['missing scheme', 'infermatic.test'], + ['unsupported scheme', 'ftp://infermatic.test'], + ['credentials', 'https://user:pass@infermatic.test'], + ['query string', 'https://infermatic.test?debug=true'], + ['fragment', 'https://infermatic.test#v1'], + ])('rejects unclean custom baseUrl values: %s', async (_case, baseUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow( + /Infermatic 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/infermatic/src/index.ts b/packages/ai/infermatic/src/index.ts index 74630a22..034f6077 100644 --- a/packages/ai/infermatic/src/index.ts +++ b/packages/ai/infermatic/src/index.ts @@ -27,8 +27,9 @@ export default defineAi({ const messages: InfermaticMessage[] = []; if (opts.system) messages.push({ role: 'system', content: opts.system }); messages.push({ role: 'user', content: prompt }); + const baseUrl = cleanBaseUrl(config.baseUrl ?? DEFAULT_BASE); - const res = await fetch(`${config.baseUrl ?? DEFAULT_BASE}/v1/chat/completions`, { + const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, @@ -66,6 +67,22 @@ export default defineAi({ }), }); +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Infermatic baseUrl must be a valid URL'); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Infermatic baseUrl must use http or https'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('Infermatic baseUrl must be a clean API base without credentials, query, or hash'); + } + return url.toString().replace(/\/+$/, ''); +} + type InfermaticRole = 'system' | 'user' | 'assistant' | 'tool'; interface InfermaticMessage {