Anonymous View
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bare-llm-provider-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@loopstack/llm-provider-module': patch
---

Allow bare `LlmProviderModule` import without `forRoot({})`. The module's static `@Module` decorator now wires the global root, so importing the class directly registers the provider registry, helper services, and tools with default config. `forRoot(config)` and `forFeature(config)` are unchanged.
2 changes: 1 addition & 1 deletion docs/build/ai/llm-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ClaudeModule } from '@loopstack/claude-module';
import { LlmProviderModule } from '@loopstack/llm-provider-module';

@Module({
imports: [LoopstackModule.forRoot(), LlmProviderModule.forRoot({}), ClaudeModule],
imports: [LoopstackModule.forRoot(), LlmProviderModule, ClaudeModule],
})
export class AppModule {}
```
Expand Down
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('ChatWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(ChatWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withToolOverride(LlmGenerateTextTool)
.compile();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('DelegateErrorWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(DelegateErrorWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withToolOverride(LlmGenerateTextTool)
// Real tools — we want to test actual validation and runtime errors
.withProviders(StrictSchemaTool, RuntimeErrorTool, FailingSubWorkflowTool, FailingWorkflow)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('LlmMultiProviderWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(LlmMultiProviderWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule, OpenAiModule)
.withImports(LlmProviderModule, ClaudeModule, OpenAiModule)
.withToolOverride(LlmGenerateTextTool)
.compile();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('MeetingNotesWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(MeetingNotesWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withProvider(MeetingNotesDocument)
.withProvider(OptimizedNotesDocument)
.withToolOverride(LlmGenerateObjectTool)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('PromptWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(PromptWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withToolOverride(LlmGenerateTextTool)
.compile();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('PromptStructuredOutputWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(PromptStructuredOutputWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withProvider(FileDocument)
.withToolOverride(LlmGenerateObjectTool)
.compile();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('ToolCallWorkflow', () => {
beforeEach(async () => {
module = await createWorkflowTest()
.forWorkflow(ToolCallWorkflow)
.withImports(LlmProviderModule.forRoot({}), ClaudeModule)
.withImports(LlmProviderModule, ClaudeModule)
.withToolOverride(LlmGenerateTextTool)
.withToolOverride(LlmDelegateToolCallsTool)
.withProviders(GetWeather)
Expand Down
25 changes: 6 additions & 19 deletions registry/features/llm-provider-module/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"compile": "tsc --noEmit",
"format": "prettier --write .",
"lint": "eslint .",
"test": "jest --passWithNoTests",
"test": "vitest run",
"watch": "nest build --watch"
},
"dependencies": {
Expand All @@ -36,6 +36,11 @@
"@loopstack/core": "^0.33.0"
},
"devDependencies": {
"@nestjs/common": "^11.1.19",
"@nestjs/testing": "^11.1.19",
"@swc/core": "^1.15.33",
"unplugin-swc": "^1.5.9",
"vitest": "^4.1.6",
"zod": "^4.3.6"
},
"peerDependencies": {
Expand All @@ -44,23 +49,5 @@
"files": [
"dist"
],
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"maxWorkers": 1
},
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { DynamicModule, Type } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { LLM_MODULE_CONFIG, type LlmModuleConfig } from '../llm-provider.constants.js';
import { LlmProviderModule } from '../llm-provider.module.js';
import { LlmProviderRegistry } from '../services/llm-provider-registry.js';

// Cross-package deps (TOOL_REGISTRY, ToolPipeline, etc. from @loopstack/core/common) are out of scope
// for this test — we only care that LlmProviderModule wires its OWN providers and config correctly.
// useMocker stubs any unresolved injection token with an empty object.
function build(imports: Array<Type<unknown> | DynamicModule>) {
return Test.createTestingModule({ imports })
.useMocker(() => ({}))
.compile();
}

describe('LlmProviderModule import forms', () => {
it('bare import registers the registry and applies the default empty config globally', async () => {
const moduleRef = await build([LlmProviderModule]);

expect(moduleRef.get(LlmProviderRegistry, { strict: false })).toBeInstanceOf(LlmProviderRegistry);
expect(moduleRef.get<LlmModuleConfig>(LLM_MODULE_CONFIG, { strict: false })).toEqual({});

await moduleRef.close();
});

it('forRoot({}) registers the registry and applies an empty global config', async () => {
const moduleRef = await build([LlmProviderModule.forRoot({})]);

expect(moduleRef.get(LlmProviderRegistry, { strict: false })).toBeInstanceOf(LlmProviderRegistry);
expect(moduleRef.get<LlmModuleConfig>(LLM_MODULE_CONFIG, { strict: false })).toEqual({});

await moduleRef.close();
});

it('forRoot(config) makes the global config visible everywhere', async () => {
const moduleRef = await build([LlmProviderModule.forRoot({ model: 'claude-sonnet-4-6' })]);

expect(moduleRef.get<LlmModuleConfig>(LLM_MODULE_CONFIG, { strict: false })).toEqual({
model: 'claude-sonnet-4-6',
});

await moduleRef.close();
});

it('bare import alongside forRoot(config) — forRoot wins (bare import is a no-op overlap)', async () => {
// Edge case: a user could write both bare and forRoot(config) in the same imports list
// (e.g. via a refactor). We don't endorse this combination, but verify it doesn't silently
// shadow the explicit forRoot config with the bare import's default.
const moduleRef = await build([LlmProviderModule, LlmProviderModule.forRoot({ model: 'claude-sonnet-4-6' })]);

expect(moduleRef.get<LlmModuleConfig>(LLM_MODULE_CONFIG, { strict: false })).toEqual({
model: 'claude-sonnet-4-6',
});

await moduleRef.close();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ class LlmProviderRootModule {}
/**
* LLM Provider Module — configures LLM tools with provider/model defaults.
*
* - `forRoot(config)` — sets the global default config. Optional; empty defaults apply if omitted.
* - Bare import (`LlmProviderModule`) — registers the global root with default config.
* - `forRoot(config)` — sets the global default config.
* - `forFeature(config)` — overrides config for a specific module's tools.
*/
@Module({})
@Module({ imports: [LlmProviderRootModule] })
export class LlmProviderModule {
static forRoot(config: LlmModuleConfig): DynamicModule {
return {
Expand Down
19 changes: 19 additions & 0 deletions registry/features/llm-provider-module/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
root: './src',
include: ['**/*.spec.ts'],
},
plugins: [
swc.vite({
module: { type: 'es6' },
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
target: 'es2023',
},
}),
],
});
Loading