fix(orchestrator): make provider-aware Claude key startup requirements
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful

This commit is contained in:
Jason Woltje
2026-02-17 17:15:42 -06:00
parent d3474cdd74
commit 6fd8e85266
13 changed files with 185 additions and 35 deletions

View File

@@ -406,8 +406,7 @@ AI_PROVIDER=ollama
OLLAMA_MODEL=llama3.1:latest OLLAMA_MODEL=llama3.1:latest
# Claude API Key # Claude API Key
# Required by the orchestrator service in swarm deployment. # Required only when AI_PROVIDER=claude.
# Also used when AI_PROVIDER=claude for other services.
# Get your API key from: https://console.anthropic.com/ # Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY

View File

@@ -1,6 +1,8 @@
# Orchestrator Configuration # Orchestrator Configuration
ORCHESTRATOR_PORT=3001 ORCHESTRATOR_PORT=3001
NODE_ENV=development NODE_ENV=development
# AI provider for orchestrator agents: ollama, claude, openai
AI_PROVIDER=ollama
# Valkey # Valkey
VALKEY_HOST=localhost VALKEY_HOST=localhost
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
VALKEY_URL=redis://localhost:6379 VALKEY_URL=redis://localhost:6379
# Claude API # Claude API
# Required only when AI_PROVIDER=claude.
CLAUDE_API_KEY=your-api-key-here CLAUDE_API_KEY=your-api-key-here
# Docker # Docker

View File

@@ -186,17 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
Environment variables loaded via `@nestjs/config`. Key variables: Environment variables loaded via `@nestjs/config`. Key variables:
| Variable | Description | | Variable | Description |
| -------------------------------- | -------------------------------------------------- | | -------------------------------- | ------------------------------------------------------------ |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) | | `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `CLAUDE_API_KEY` | Claude API key for agents | | `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) | | `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) | | `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `COORDINATOR_URL` | Quality Coordinator base URL | | `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) | | `COORDINATOR_URL` | Quality Coordinator base URL |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) | | `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) | | `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) | | `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
## Related Documentation ## Related Documentation

View File

@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
Sensitive configuration is passed via environment variables: 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 - `VALKEY_URL`: Cache connection string
**Best Practices:** **Best Practices:**

View File

@@ -181,4 +181,30 @@ describe("orchestratorConfig", () => {
expect(config.spawner.maxConcurrentAgents).toBe(10); 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");
});
});
}); });

View File

@@ -1,4 +1,17 @@
import { registerAs } from "@nestjs/config"; 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", () => ({ export const orchestratorConfig = registerAs("orchestrator", () => ({
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1", host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
@@ -14,6 +27,7 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
claude: { claude: {
apiKey: process.env.CLAUDE_API_KEY, apiKey: process.env.CLAUDE_API_KEY,
}, },
aiProvider: normalizeAiProvider(),
docker: { docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock", socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
}, },

View File

@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
// Create mock ConfigService // Create mock ConfigService
mockConfigService = { mockConfigService = {
get: vi.fn((key: string) => { get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.claude.apiKey") { if (key === "orchestrator.claude.apiKey") {
return "test-api-key"; return "test-api-key";
} }
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
expect(service).toBeDefined(); 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"); 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 = { const badConfigService = {
get: vi.fn(() => undefined), get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
return undefined;
}),
} as unknown as ConfigService; } as unknown as ConfigService;
expect(() => new AgentSpawnerService(badConfigService)).toThrow( 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", () => { describe("spawnAgent", () => {

View File

@@ -14,6 +14,8 @@ import {
* This allows time for status queries before the session is removed * This allows time for status queries before the session is removed
*/ */
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds 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 * Service responsible for spawning Claude agents using Anthropic SDK
@@ -21,22 +23,38 @@ const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
@Injectable() @Injectable()
export class AgentSpawnerService implements OnModuleDestroy { export class AgentSpawnerService implements OnModuleDestroy {
private readonly logger = new Logger(AgentSpawnerService.name); 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 sessions = new Map<string, AgentSession>();
private readonly maxConcurrentAgents: number; private readonly maxConcurrentAgents: number;
private readonly sessionCleanupDelayMs: number; private readonly sessionCleanupDelayMs: number;
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>(); private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
constructor(private readonly configService: ConfigService) { 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"); const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
if (!apiKey) { if (this.aiProvider === "claude") {
throw new Error("CLAUDE_API_KEY is not configured"); if (!apiKey) {
} throw new Error("CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'");
}
this.anthropic = new Anthropic({ this.logger.log("CLAUDE_API_KEY is configured. Initializing Anthropic client.");
apiKey, 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 // Default to 20 if not configured
this.maxConcurrentAgents = this.maxConcurrentAgents =
@@ -48,10 +66,27 @@ export class AgentSpawnerService implements OnModuleDestroy {
DEFAULT_SESSION_CLEANUP_DELAY_MS; DEFAULT_SESSION_CLEANUP_DELAY_MS;
this.logger.log( 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 * Clean up all pending cleanup timers on module destroy
*/ */

View File

@@ -248,7 +248,9 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
ORCHESTRATOR_PORT: 3001 ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
VALKEY_URL: redis://valkey:6379 VALKEY_URL: redis://valkey:6379
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY} CLAUDE_API_KEY: ${CLAUDE_API_KEY}
DOCKER_SOCKET: /var/run/docker.sock DOCKER_SOCKET: /var/run/docker.sock
GIT_USER_NAME: "Mosaic Orchestrator" GIT_USER_NAME: "Mosaic Orchestrator"

View File

@@ -432,9 +432,10 @@ services:
NODE_ENV: production NODE_ENV: production
# Orchestrator Configuration # Orchestrator Configuration
ORCHESTRATOR_PORT: 3001 ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey # Valkey
VALKEY_URL: redis://valkey:6379 VALKEY_URL: redis://valkey:6379
# Claude API # Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY} CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker # Docker
DOCKER_SOCKET: /var/run/docker.sock DOCKER_SOCKET: /var/run/docker.sock

View File

@@ -441,9 +441,10 @@ services:
NODE_ENV: production NODE_ENV: production
# Orchestrator Configuration # Orchestrator Configuration
ORCHESTRATOR_PORT: 3001 ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey # Valkey
VALKEY_URL: redis://valkey:6379 VALKEY_URL: redis://valkey:6379
# Claude API # Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY} CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker # Docker
DOCKER_SOCKET: /var/run/docker.sock DOCKER_SOCKET: /var/run/docker.sock

View File

@@ -43,7 +43,10 @@ ENCRYPTION_KEY=$(openssl rand -hex 32)
ORCHESTRATOR_API_KEY=$(openssl rand -base64 32) ORCHESTRATOR_API_KEY=$(openssl rand -base64 32)
COORDINATOR_API_KEY=$(openssl rand -base64 32) COORDINATOR_API_KEY=$(openssl rand -base64 32)
# Claude API Key # AI Provider for Orchestrator
AI_PROVIDER=ollama
# Claude API Key (only required when AI_PROVIDER=claude)
CLAUDE_API_KEY=your-claude-api-key CLAUDE_API_KEY=your-claude-api-key
# Authentik Bootstrap # Authentik Bootstrap

View File

@@ -397,9 +397,9 @@
### Tasks ### Tasks
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | | id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------------ | ----------- | ----------------------------------------------------------------------------------------------------- | ----- | ----------------- | ---------------------------------------- | -------------------------------------- | ------------ | ----- | ---------- | ------------ | -------- | ---- | | ------------ | ------ | ----------------------------------------------------------------------------------------------------- | ----- | ----------------- | ---------------------------------------- | -------------------------------------- | ------------ | ----- | ---------- | ------------------------- | -------- | ---- |
| ORCH-424-001 | not-started | Remove hard startup dependency on `CLAUDE_API_KEY` unless provider explicitly requires it | #424 | orchestrator | fix/orchestrator-runtime-provider-config | | ORCH-424-002 | | | | 12K | | | ORCH-424-001 | done | Remove hard startup dependency on `CLAUDE_API_KEY` unless provider explicitly requires it | #424 | orchestrator | fix/orchestrator-runtime-provider-config | | ORCH-424-002 | | | 2026-02-17T17:15:18-06:00 | 12K | |
| ORCH-424-002 | not-started | Add provider/runtime-aware validation and startup diagnostics for required key availability | #424 | orchestrator | fix/orchestrator-runtime-provider-config | ORCH-424-001 | ORCH-424-003 | | | | 10K | | | ORCH-424-002 | done | Add provider/runtime-aware validation and startup diagnostics for required key availability | #424 | orchestrator | fix/orchestrator-runtime-provider-config | ORCH-424-001 | ORCH-424-003 | | | 2026-02-17T17:15:18-06:00 | 10K | |
| ORCH-424-003 | not-started | Update env example docs for Codex/OpenCode/Claude multi-provider startup behavior | #424 | orchestrator,docs | fix/orchestrator-runtime-provider-config | ORCH-424-002 | ORCH-424-V01 | | | | 8K | | | ORCH-424-003 | done | Update env example docs for Codex/OpenCode/Claude multi-provider startup behavior | #424 | orchestrator,docs | fix/orchestrator-runtime-provider-config | ORCH-424-002 | ORCH-424-V01 | | | 2026-02-17T17:15:18-06:00 | 8K | |
| ORCH-424-V01 | not-started | Verification: `pnpm lint && pnpm typecheck && pnpm test` healthy for non-Claude startup/runtime paths | #424 | all | fix/orchestrator-runtime-provider-config | ORCH-424-001,ORCH-424-002,ORCH-424-003 | | | | | 5K | | | ORCH-424-V01 | done | Verification: `pnpm lint && pnpm typecheck && pnpm test` healthy for non-Claude startup/runtime paths | #424 | all | fix/orchestrator-runtime-provider-config | ORCH-424-001,ORCH-424-002,ORCH-424-003 | | | | 2026-02-17T17:15:18-06:00 | 5K | |