diff --git a/packages/ai/ai21/src/index.test.ts b/packages/ai/ai21/src/index.test.ts index 9348325a..530cd50d 100644 --- a/packages/ai/ai21/src/index.test.ts +++ b/packages/ai/ai21/src/index.test.ts @@ -81,6 +81,22 @@ describe('AI21 OpenAI-compatible generation', () => { expect(url).toBe('https://proxy.example.com/v1/chat/completions'); }); + it.each([ + ['missing scheme', 'proxy.example.com'], + ['unsupported scheme', 'ftp://proxy.example.com'], + ['credentials', 'https://user:pass@proxy.example.com'], + ['query string', 'https://proxy.example.com?debug=true'], + ['fragment', 'https://proxy.example.com#v1'], + ])('rejects unclean configured base URLs: %s', async (_case, baseUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.generate(ctx(), 'hello', {}, { baseUrl })).rejects.toThrow( + /AI21 baseUrl/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('includes status and redacted response body excerpt on errors', async () => { const apiKey = 'test-key-crossing-truncation-boundary'; const prefix = 'x'.repeat(190); diff --git a/packages/ai/ai21/src/index.ts b/packages/ai/ai21/src/index.ts index c0b0725b..e1cdd300 100644 --- a/packages/ai/ai21/src/index.ts +++ b/packages/ai/ai21/src/index.ts @@ -7,7 +7,23 @@ interface Config { const DEFAULT_BASE = 'https://api.ai21.com/studio'; function chatCompletionsUrl(baseUrl?: string): string { - return `${(baseUrl ?? DEFAULT_BASE).replace(/\/+$/, '')}/v1/chat/completions`; + return `${cleanBaseUrl(baseUrl ?? DEFAULT_BASE)}/v1/chat/completions`; +} + +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('AI21 baseUrl must be a valid URL'); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('AI21 baseUrl must use http or https'); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('AI21 baseUrl must be a clean API base without credentials, query, or hash'); + } + return url.toString().replace(/\/+$/, ''); } function redact(value: string, apiKey: string): string {