fix(orchestrator): make provider-aware Claude key startup requirements
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# Orchestrator Configuration
|
||||
ORCHESTRATOR_PORT=3001
|
||||
NODE_ENV=development
|
||||
# AI provider for orchestrator agents: ollama, claude, openai
|
||||
AI_PROVIDER=ollama
|
||||
|
||||
# Valkey
|
||||
VALKEY_HOST=localhost
|
||||
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
|
||||
# Claude API
|
||||
# Required only when AI_PROVIDER=claude.
|
||||
CLAUDE_API_KEY=your-api-key-here
|
||||
|
||||
# Docker
|
||||
|
||||
@@ -186,17 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
|
||||
|
||||
Environment variables loaded via `@nestjs/config`. Key variables:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------- | -------------------------------------------------- |
|
||||
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
|
||||
| `CLAUDE_API_KEY` | Claude API key for agents |
|
||||
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
|
||||
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
|
||||
| `COORDINATOR_URL` | Quality Coordinator base URL |
|
||||
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
|
||||
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
|
||||
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
|
||||
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
|
||||
| Variable | Description |
|
||||
| -------------------------------- | ------------------------------------------------------------ |
|
||||
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
|
||||
| `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
|
||||
| `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
|
||||
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
|
||||
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
|
||||
| `COORDINATOR_URL` | Quality Coordinator base URL |
|
||||
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
|
||||
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
|
||||
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
|
||||
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
|
||||
|
||||
Sensitive configuration is passed via environment variables:
|
||||
|
||||
- `CLAUDE_API_KEY`: Claude API credentials
|
||||
- `AI_PROVIDER`: Orchestrator LLM provider
|
||||
- `CLAUDE_API_KEY`: Claude credentials (required only for `AI_PROVIDER=claude`)
|
||||
- `VALKEY_URL`: Cache connection string
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
@@ -181,4 +181,30 @@ describe("orchestratorConfig", () => {
|
||||
expect(config.spawner.maxConcurrentAgents).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI provider config", () => {
|
||||
it("should default aiProvider to ollama when unset", () => {
|
||||
delete process.env.AI_PROVIDER;
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.aiProvider).toBe("ollama");
|
||||
});
|
||||
|
||||
it("should normalize AI provider to lowercase", () => {
|
||||
process.env.AI_PROVIDER = " cLaUdE ";
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.aiProvider).toBe("claude");
|
||||
});
|
||||
|
||||
it("should fallback unsupported AI provider to ollama", () => {
|
||||
process.env.AI_PROVIDER = "bad-provider";
|
||||
|
||||
const config = orchestratorConfig();
|
||||
|
||||
expect(config.aiProvider).toBe("ollama");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
|
||||
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
|
||||
|
||||
if (!provider) {
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
if (provider !== "ollama" && provider !== "claude" && provider !== "openai") {
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
|
||||
@@ -14,6 +27,7 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
claude: {
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
},
|
||||
aiProvider: normalizeAiProvider(),
|
||||
docker: {
|
||||
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
|
||||
},
|
||||
|
||||
@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
|
||||
// Create mock ConfigService
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.aiProvider") {
|
||||
return "ollama";
|
||||
}
|
||||
if (key === "orchestrator.claude.apiKey") {
|
||||
return "test-api-key";
|
||||
}
|
||||
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it("should initialize with Claude API key from config", () => {
|
||||
it("should initialize with default AI provider when API key is omitted", () => {
|
||||
const noClaudeConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.aiProvider") {
|
||||
return "ollama";
|
||||
}
|
||||
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||
return 20;
|
||||
}
|
||||
if (key === "orchestrator.spawner.sessionCleanupDelayMs") {
|
||||
return 30000;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const serviceNoKey = new AgentSpawnerService(noClaudeConfigService);
|
||||
expect(serviceNoKey).toBeDefined();
|
||||
});
|
||||
|
||||
it("should initialize with Claude provider when key is present", () => {
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
|
||||
});
|
||||
|
||||
it("should throw error if Claude API key is missing", () => {
|
||||
it("should initialize with CLAUDE provider when API key is present", () => {
|
||||
const claudeConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.aiProvider") {
|
||||
return "claude";
|
||||
}
|
||||
if (key === "orchestrator.claude.apiKey") {
|
||||
return "test-api-key";
|
||||
}
|
||||
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||
return 20;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const claudeService = new AgentSpawnerService(claudeConfigService);
|
||||
expect(claudeService).toBeDefined();
|
||||
});
|
||||
|
||||
it("should throw error if Claude API key is missing when provider is claude", () => {
|
||||
const badConfigService = {
|
||||
get: vi.fn(() => undefined),
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.aiProvider") {
|
||||
return "claude";
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
|
||||
"CLAUDE_API_KEY is not configured"
|
||||
"CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'"
|
||||
);
|
||||
});
|
||||
|
||||
it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => {
|
||||
const nonClaudeConfigService = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "orchestrator.aiProvider") {
|
||||
return "ollama";
|
||||
}
|
||||
if (key === "orchestrator.spawner.maxConcurrentAgents") {
|
||||
return 20;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnAgent", () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
* This allows time for status queries before the session is removed
|
||||
*/
|
||||
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
|
||||
const SUPPORTED_AI_PROVIDERS = ["ollama", "claude", "openai"] as const;
|
||||
type SupportedAiProvider = (typeof SUPPORTED_AI_PROVIDERS)[number];
|
||||
|
||||
/**
|
||||
* Service responsible for spawning Claude agents using Anthropic SDK
|
||||
@@ -21,22 +23,38 @@ const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
|
||||
@Injectable()
|
||||
export class AgentSpawnerService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AgentSpawnerService.name);
|
||||
private readonly anthropic: Anthropic;
|
||||
private readonly anthropic: Anthropic | undefined;
|
||||
private readonly aiProvider: SupportedAiProvider;
|
||||
private readonly sessions = new Map<string, AgentSession>();
|
||||
private readonly maxConcurrentAgents: number;
|
||||
private readonly sessionCleanupDelayMs: number;
|
||||
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
|
||||
this.aiProvider = this.normalizeAiProvider(configuredProvider);
|
||||
|
||||
this.logger.log(`AgentSpawnerService resolved AI provider: ${this.aiProvider}`);
|
||||
|
||||
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("CLAUDE_API_KEY is not configured");
|
||||
}
|
||||
if (this.aiProvider === "claude") {
|
||||
if (!apiKey) {
|
||||
throw new Error("CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'");
|
||||
}
|
||||
|
||||
this.anthropic = new Anthropic({
|
||||
apiKey,
|
||||
});
|
||||
this.logger.log("CLAUDE_API_KEY is configured. Initializing Anthropic client.");
|
||||
this.anthropic = new Anthropic({ apiKey });
|
||||
} else {
|
||||
if (apiKey) {
|
||||
this.logger.debug(
|
||||
`CLAUDE_API_KEY is set but ignored because AI provider is '${this.aiProvider}'`
|
||||
);
|
||||
} else {
|
||||
this.logger.log(`CLAUDE_API_KEY not required for AI provider '${this.aiProvider}'.`);
|
||||
}
|
||||
this.anthropic = undefined;
|
||||
}
|
||||
|
||||
// Default to 20 if not configured
|
||||
this.maxConcurrentAgents =
|
||||
@@ -48,10 +66,27 @@ export class AgentSpawnerService implements OnModuleDestroy {
|
||||
DEFAULT_SESSION_CLEANUP_DELAY_MS;
|
||||
|
||||
this.logger.log(
|
||||
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
|
||||
`AgentSpawnerService initialized with ${this.aiProvider} AI provider (max concurrent agents: ${String(
|
||||
this.maxConcurrentAgents
|
||||
)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAiProvider(provider?: string): SupportedAiProvider {
|
||||
const normalizedProvider = provider?.trim().toLowerCase();
|
||||
|
||||
if (!normalizedProvider) {
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
if (!SUPPORTED_AI_PROVIDERS.includes(normalizedProvider as SupportedAiProvider)) {
|
||||
this.logger.warn(`Unsupported AI provider '${normalizedProvider}'. Defaulting to 'ollama'.`);
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
return normalizedProvider as SupportedAiProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all pending cleanup timers on module destroy
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user