Addresses the second #177 review: - Architecture (the silent allowlist drift): the writable provider-setting keys were maintained by hand in two TS-uncheckable places — the key-loop in ai-settings.service and the SQL ALLOWED list in the generic workspace repo (a miss there silently dropped a field on persist, exactly what bit chatApiStyle). Introduce one typed source of truth PROVIDER_SETTINGS_KEYS in ai.types (`satisfies readonly (keyof AiProviderSettings)[]`), have the service consume it, and keep the repo's own copy (it can't import AI types) guarded by a parity test so any future drift fails in CI. - Tests: - ai.service.include-usage.spec: mocks @ai-sdk/openai-compatible and asserts the factory is called with { includeUsage: true, baseURL, apiKey, fetch, name } — `.provider` alone could not catch a dropped includeUsage (the token-usage zeroing regression); also asserts the 'openai' style does NOT use it. - ai-provider-settings-keys.spec: the allowlist parity check + DTO validation for chatApiStyle (@IsIn accepts both values, rejects garbage, optional). - CHANGELOG: [Unreleased] entries for the new "Protocol" / chatApiStyle setting and the default provider change (openai -> openai-compatible). (#175, #177) server + client tsc clean; 42 ai/settings specs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
59 lines
2.1 KiB
TypeScript
59 lines
2.1 KiB
TypeScript
// `.provider` alone cannot prove the openai-compatible factory was called with
|
|
// `includeUsage: true` — a regression dropping it (which zeroes streamed token
|
|
// usage / reasoning-token metadata) would still pass. So mock the factory and
|
|
// assert the exact args. jest.mock is module-scoped, hence a dedicated file.
|
|
|
|
const mockCompatibleModel = { provider: 'openai-compatible.chat', modelId: 'm' };
|
|
// jest allows `mock`-prefixed vars inside a jest.mock factory.
|
|
const mockCreateOpenAICompatible = jest.fn(
|
|
(_settings: unknown) => () => mockCompatibleModel,
|
|
);
|
|
|
|
jest.mock('@ai-sdk/openai-compatible', () => ({
|
|
createOpenAICompatible: (settings: unknown) =>
|
|
mockCreateOpenAICompatible(settings),
|
|
}));
|
|
|
|
import { AiService } from './ai.service';
|
|
|
|
describe('AiService.getChatModel openai-compatible factory args', () => {
|
|
function serviceWith(chatApiStyle?: 'openai-compatible' | 'openai') {
|
|
const aiSettings = {
|
|
resolve: jest.fn().mockResolvedValue({
|
|
driver: 'openai',
|
|
chatModel: 'glm-5.2',
|
|
apiKey: 'the-key',
|
|
baseUrl: 'https://api.z.ai/v4',
|
|
chatApiStyle,
|
|
}),
|
|
};
|
|
return new AiService(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aiSettings as any,
|
|
{ find: jest.fn() } as never,
|
|
{ decryptSecret: jest.fn() } as never,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => mockCreateOpenAICompatible.mockClear());
|
|
|
|
it('passes includeUsage:true plus baseURL/apiKey/fetch (default style)', async () => {
|
|
await serviceWith().getChatModel('ws-1'); // unset -> openai-compatible
|
|
expect(mockCreateOpenAICompatible).toHaveBeenCalledTimes(1);
|
|
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'openai-compatible',
|
|
baseURL: 'https://api.z.ai/v4',
|
|
apiKey: 'the-key',
|
|
includeUsage: true,
|
|
fetch: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does NOT use the openai-compatible factory for chatApiStyle 'openai'", async () => {
|
|
await serviceWith('openai').getChatModel('ws-1');
|
|
expect(mockCreateOpenAICompatible).not.toHaveBeenCalled();
|
|
});
|
|
});
|