From d3474cdd74d428ffc12510d791ae0297ccecd6c2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Feb 2026 17:05:09 -0600 Subject: [PATCH 1/2] chore(orchestrator): bootstrap issue 424 --- docs/tasks.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/tasks.md b/docs/tasks.md index ed114ba..8abc280 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -388,3 +388,18 @@ | ORCH-OBS-009 | done | Seed default/reset local HUD layout with orchestration widgets so visibility works out-of-box | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-008 | | orch | 2026-02-17T17:10Z | 2026-02-17T17:14Z | 8K | 6K | | ORCH-OBS-010 | done | Enrich `TaskProgressWidget` with latest recent-event context from `/api/orchestrator/events/recent` | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-009 | | orch | 2026-02-17T17:15Z | 2026-02-17T17:20Z | 8K | 6K | | ORCH-OBS-011 | done | Add orchestrator health proxy and readiness badge (`ready/degraded`) in events widget | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-010 | | orch | 2026-02-17T17:22Z | 2026-02-17T17:27Z | 8K | 6K | + +## 2026-02-17 Issue 424 — Orchestrator Provider-Aware Startup + +**Orchestrator:** Jarvis (Codex runtime) +**Issue:** #424 +**Branch:** `fix/orchestrator-runtime-provider-config` + +### Tasks + +| 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-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-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-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 | | -- 2.49.1 From 6fd8e85266e75ea7c7eb6f1f5de219e4541172ba Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Feb 2026 17:15:42 -0600 Subject: [PATCH 2/2] fix(orchestrator): make provider-aware Claude key startup requirements --- .env.example | 3 +- apps/orchestrator/.env.example | 3 + apps/orchestrator/README.md | 23 +++--- apps/orchestrator/SECURITY.md | 3 +- .../src/config/orchestrator.config.spec.ts | 26 +++++++ .../src/config/orchestrator.config.ts | 14 ++++ .../src/spawner/agent-spawner.service.spec.ts | 72 +++++++++++++++++-- .../src/spawner/agent-spawner.service.ts | 51 ++++++++++--- docker-compose.swarm.portainer.yml | 2 + docker-compose.yml | 3 +- docker/docker-compose.build.yml | 3 +- docs/DOCKER-SWARM.md | 5 +- docs/tasks.md | 12 ++-- 13 files changed, 185 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index 37ca636..8c656a4 100644 --- a/.env.example +++ b/.env.example @@ -406,8 +406,7 @@ AI_PROVIDER=ollama OLLAMA_MODEL=llama3.1:latest # Claude API Key -# Required by the orchestrator service in swarm deployment. -# Also used when AI_PROVIDER=claude for other services. +# Required only when AI_PROVIDER=claude. # Get your API key from: https://console.anthropic.com/ CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY diff --git a/apps/orchestrator/.env.example b/apps/orchestrator/.env.example index b17fe0d..383b4d3 100644 --- a/apps/orchestrator/.env.example +++ b/apps/orchestrator/.env.example @@ -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 diff --git a/apps/orchestrator/README.md b/apps/orchestrator/README.md index ada1408..529e3ad 100644 --- a/apps/orchestrator/README.md +++ b/apps/orchestrator/README.md @@ -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 diff --git a/apps/orchestrator/SECURITY.md b/apps/orchestrator/SECURITY.md index 02e719a..d750b18 100644 --- a/apps/orchestrator/SECURITY.md +++ b/apps/orchestrator/SECURITY.md @@ -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:** diff --git a/apps/orchestrator/src/config/orchestrator.config.spec.ts b/apps/orchestrator/src/config/orchestrator.config.spec.ts index da46d1d..9cb944f 100644 --- a/apps/orchestrator/src/config/orchestrator.config.spec.ts +++ b/apps/orchestrator/src/config/orchestrator.config.spec.ts @@ -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"); + }); + }); }); diff --git a/apps/orchestrator/src/config/orchestrator.config.ts b/apps/orchestrator/src/config/orchestrator.config.ts index 116553b..e905938 100644 --- a/apps/orchestrator/src/config/orchestrator.config.ts +++ b/apps/orchestrator/src/config/orchestrator.config.ts @@ -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", }, diff --git a/apps/orchestrator/src/spawner/agent-spawner.service.spec.ts b/apps/orchestrator/src/spawner/agent-spawner.service.spec.ts index 6cc0ff0..a6fb0de 100644 --- a/apps/orchestrator/src/spawner/agent-spawner.service.spec.ts +++ b/apps/orchestrator/src/spawner/agent-spawner.service.spec.ts @@ -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", () => { diff --git a/apps/orchestrator/src/spawner/agent-spawner.service.ts b/apps/orchestrator/src/spawner/agent-spawner.service.ts index 5279f46..c91971a 100644 --- a/apps/orchestrator/src/spawner/agent-spawner.service.ts +++ b/apps/orchestrator/src/spawner/agent-spawner.service.ts @@ -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(); private readonly maxConcurrentAgents: number; private readonly sessionCleanupDelayMs: number; private readonly cleanupTimers = new Map(); constructor(private readonly configService: ConfigService) { + const configuredProvider = this.configService.get("orchestrator.aiProvider"); + this.aiProvider = this.normalizeAiProvider(configuredProvider); + + this.logger.log(`AgentSpawnerService resolved AI provider: ${this.aiProvider}`); + const apiKey = this.configService.get("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 */ diff --git a/docker-compose.swarm.portainer.yml b/docker-compose.swarm.portainer.yml index 559886c..0636064 100644 --- a/docker-compose.swarm.portainer.yml +++ b/docker-compose.swarm.portainer.yml @@ -248,7 +248,9 @@ services: environment: NODE_ENV: production ORCHESTRATOR_PORT: 3001 + AI_PROVIDER: ${AI_PROVIDER:-ollama} VALKEY_URL: redis://valkey:6379 + # Claude API (required only when AI_PROVIDER=claude) CLAUDE_API_KEY: ${CLAUDE_API_KEY} DOCKER_SOCKET: /var/run/docker.sock GIT_USER_NAME: "Mosaic Orchestrator" diff --git a/docker-compose.yml b/docker-compose.yml index 56eddd9..255e9fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -432,9 +432,10 @@ services: NODE_ENV: production # Orchestrator Configuration ORCHESTRATOR_PORT: 3001 + AI_PROVIDER: ${AI_PROVIDER:-ollama} # Valkey VALKEY_URL: redis://valkey:6379 - # Claude API + # Claude API (required only when AI_PROVIDER=claude) CLAUDE_API_KEY: ${CLAUDE_API_KEY} # Docker DOCKER_SOCKET: /var/run/docker.sock diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index 5e455d2..bd45fa9 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -441,9 +441,10 @@ services: NODE_ENV: production # Orchestrator Configuration ORCHESTRATOR_PORT: 3001 + AI_PROVIDER: ${AI_PROVIDER:-ollama} # Valkey VALKEY_URL: redis://valkey:6379 - # Claude API + # Claude API (required only when AI_PROVIDER=claude) CLAUDE_API_KEY: ${CLAUDE_API_KEY} # Docker DOCKER_SOCKET: /var/run/docker.sock diff --git a/docs/DOCKER-SWARM.md b/docs/DOCKER-SWARM.md index 77c69ee..30bc3fe 100644 --- a/docs/DOCKER-SWARM.md +++ b/docs/DOCKER-SWARM.md @@ -43,7 +43,10 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) ORCHESTRATOR_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 # Authentik Bootstrap diff --git a/docs/tasks.md b/docs/tasks.md index 8abc280..3026d4f 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -397,9 +397,9 @@ ### Tasks -| 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-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-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-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 | | +| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | +| ------------ | ------ | ----------------------------------------------------------------------------------------------------- | ----- | ----------------- | ---------------------------------------- | -------------------------------------- | ------------ | ----- | ---------- | ------------------------- | -------- | ---- | +| 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 | 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 | 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 | 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 | | -- 2.49.1