From bbd3ed0d3aac1fb8fac321d96e26a5d3c7f6a458 Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Thu, 23 Jul 2026 12:03:49 -0600 Subject: [PATCH] fix(ai-novita): require clean api base urls --- packages/ai/novita/src/index.test.ts | 106 +++++++++++++++++++++++++++ packages/ai/novita/src/index.ts | 22 +++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/ai/novita/src/index.test.ts b/packages/ai/novita/src/index.test.ts index f43ad207..2e9bc7b9 100644 --- a/packages/ai/novita/src/index.test.ts +++ b/packages/ai/novita/src/index.test.ts @@ -1,4 +1,110 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'ai' }); + +const ctx = ( + secrets: Record = { NOVITA_API_KEY: 'test-key' }, + dryRun = false, +) => ({ + secret: (key: string) => secrets[key], + log: () => {}, + dryRun, +}); + +describe('Novita AI generation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('short-circuits dry-run before network calls', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx({ NOVITA_API_KEY: 'test-key' }, true), + 'hello', + {}, + {}, + ); + + expect(result).toEqual({ text: '[dry-run]', model: 'meta-llama/llama-3.1-8b-instruct' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts chat completions requests and maps usage tokens', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + model: 'deepseek/deepseek-chat', + choices: [{ message: { content: 'hi from novita' } }], + usage: { prompt_tokens: 9, completion_tokens: 4 }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx(), + 'hello', + { + model: 'deepseek/deepseek-chat', + system: 'be direct', + maxTokens: 64, + temperature: 0.4, + extra: { top_p: 0.8 }, + }, + { baseUrl: 'https://novita.test/v3/openai/' }, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, request] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://novita.test/v3/openai/chat/completions'); + expect(request.headers.authorization).toBe('Bearer test-key'); + expect(request.headers['content-type']).toBe('application/json'); + expect(JSON.parse(request.body)).toEqual({ + model: 'deepseek/deepseek-chat', + messages: [ + { role: 'system', content: 'be direct' }, + { role: 'user', content: 'hello' }, + ], + max_tokens: 64, + temperature: 0.4, + top_p: 0.8, + }); + expect(result).toEqual({ + text: 'hi from novita', + model: 'deepseek/deepseek-chat', + inputTokens: 9, + outputTokens: 4, + }); + }); + + it.each([ + ['missing scheme', 'novita.test/v3/openai'], + ['ftp scheme', 'ftp://novita.test/v3/openai'], + ['credentials', 'https://user:pass@novita.test/v3/openai'], + ['query string', 'https://novita.test/v3/openai?token=secret'], + ['fragment', 'https://novita.test/v3/openai#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( + /Novita baseUrl/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('includes status and response body excerpt on errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'invalid api key'.repeat(30), + })); + + await expect(adapter.generate(ctx(), 'hello', {}, {})).rejects.toThrow( + /novita 401: invalid api key/, + ); + }); +}); diff --git a/packages/ai/novita/src/index.ts b/packages/ai/novita/src/index.ts index 2e4a02b7..1e0bf7c0 100644 --- a/packages/ai/novita/src/index.ts +++ b/packages/ai/novita/src/index.ts @@ -27,7 +27,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}`, @@ -69,3 +70,22 @@ export default defineAi({ ], }), }); + +function cleanBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error('Novita baseUrl must be a valid URL'); + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Novita baseUrl must use http or https'); + } + + if (url.username || url.password || url.search || url.hash) { + throw new Error('Novita baseUrl must be a clean API base without credentials, query, or hash'); + } + + return url.toString().replace(/\/+$/, ''); +}