feat(agents): multi-provider POC — DeepSeek + Ollama (server-side) #547

Merged
charles merged 2 commits from feat/multi-provider into main 2026-04-29 11:44:09 +00:00
Collaborator

Summary

Per-agent LLM provider selection on agents.json::types.<name>.provider. All providers expose the Anthropic Messages API so the in-container Claude CLI is unchanged — only ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN flip per provider at container start.

Server-side wiring only. Web UI + /agents/models HTTP route registration deferred to follow-up PRs (route registration is entangled with an in-flight auth refactor in main.ts; UI is entangled with a routes/config.tsxfeatures/agents/sections.tsx file move).

Changes

  • Schema (agents-config-schema.ts, webhook-config.ts): provider enum anthropic | deepseek | ollama, default anthropic. Loader rejects provider != "anthropic" on host-mode types (foreman) since host bypasses container env injection.
  • Container env injection (container-reconcile.ts): hardcoded provider conventions — DeepSeek https://api.deepseek.com/anthropic + DEEPSEEK_API_KEY, Ollama OLLAMA_BASE_URL + dummy token. Newline-rejects every injected env value (--env-file line termination cannot be hijacked). Allocates envTmp path before try so cleanup is reliable on partial-write throws.
  • Model listing (sdk-adapter.ts): listModels(provider) per-provider catalogues — Anthropic /v1/models, DeepSeek /v1/models Bearer, Ollama /api/tags. 24 h cache, hardcoded fallback so UI combobox never goes blank. clearListModelsCache() invoked on reloadWebhookConfig().
  • Provider-flip cleanup (db.ts, http/handlers/config.ts): on PUT /config/agents provider change, scrub per-instance model_override rows so dispatchers don't push a Claude model name through DeepSeek (silent gibberish at the alt endpoint).
  • Port surface (claude-port.ts, resolved-agent.ts): ModelInfo, AgentProvider, agentProvider() defaulter on the shared port.
  • Tests (agent-runner.test.ts): FakeClaudeAgent.setModels() / listModels() stubs.
  • Spec (docs/providers.md): operator setup (systemd drop-ins for DEEPSEEK_API_KEY / OLLAMA_BASE_URL), known caveats (no prompt cache for non-Anthropic, foreman host-mode limitation, Ollama tool-call model dependence).

Test plan

  • just typecheck clean across all 4 packages.
  • Pre-commit Biome format + lint clean.
  • Server tests: 2509 pass, 4 fail. The 4 failures (session JSONL pruning ×3, foreman session CRUD ×1) pre-exist on main and are unrelated.
  • Manual: set provider: "deepseek" on a type, supply DEEPSEEK_API_KEY, dispatch a task, confirm it routes to DeepSeek endpoint.
  • Manual: flip a type's provider via PUT /config/agents, confirm [config] type X provider A→B: cleared N per-instance model override(s) log line.
  • Manual: host-mode type + provider: "deepseek" → loader rejects with clear error.
  • Manual: env var with embedded \n → reconciler rejects with clear error.

🤖 Generated with Claude Code

## Summary Per-agent LLM provider selection on `agents.json::types.<name>.provider`. All providers expose the Anthropic Messages API so the in-container Claude CLI is unchanged — only `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` flip per provider at container start. Server-side wiring only. Web UI + `/agents/models` HTTP route registration deferred to follow-up PRs (route registration is entangled with an in-flight auth refactor in `main.ts`; UI is entangled with a `routes/config.tsx` → `features/agents/sections.tsx` file move). ## Changes - **Schema** (`agents-config-schema.ts`, `webhook-config.ts`): `provider` enum `anthropic | deepseek | ollama`, default `anthropic`. Loader rejects `provider != "anthropic"` on host-mode types (foreman) since host bypasses container env injection. - **Container env injection** (`container-reconcile.ts`): hardcoded provider conventions — DeepSeek `https://api.deepseek.com/anthropic` + `DEEPSEEK_API_KEY`, Ollama `OLLAMA_BASE_URL` + dummy token. Newline-rejects every injected env value (`--env-file` line termination cannot be hijacked). Allocates `envTmp` path before `try` so cleanup is reliable on partial-write throws. - **Model listing** (`sdk-adapter.ts`): `listModels(provider)` per-provider catalogues — Anthropic `/v1/models`, DeepSeek `/v1/models` Bearer, Ollama `/api/tags`. 24 h cache, hardcoded fallback so UI combobox never goes blank. `clearListModelsCache()` invoked on `reloadWebhookConfig()`. - **Provider-flip cleanup** (`db.ts`, `http/handlers/config.ts`): on `PUT /config/agents` provider change, scrub per-instance `model_override` rows so dispatchers don't push a Claude model name through DeepSeek (silent gibberish at the alt endpoint). - **Port surface** (`claude-port.ts`, `resolved-agent.ts`): `ModelInfo`, `AgentProvider`, `agentProvider()` defaulter on the shared port. - **Tests** (`agent-runner.test.ts`): `FakeClaudeAgent.setModels()` / `listModels()` stubs. - **Spec** (`docs/providers.md`): operator setup (systemd drop-ins for `DEEPSEEK_API_KEY` / `OLLAMA_BASE_URL`), known caveats (no prompt cache for non-Anthropic, foreman host-mode limitation, Ollama tool-call model dependence). ## Test plan - [x] `just typecheck` clean across all 4 packages. - [x] Pre-commit Biome format + lint clean. - [x] Server tests: 2509 pass, 4 fail. The 4 failures (session JSONL pruning ×3, foreman session CRUD ×1) pre-exist on `main` and are unrelated. - [ ] Manual: set `provider: "deepseek"` on a type, supply `DEEPSEEK_API_KEY`, dispatch a task, confirm it routes to DeepSeek endpoint. - [ ] Manual: flip a type's provider via `PUT /config/agents`, confirm `[config] type X provider A→B: cleared N per-instance model override(s)` log line. - [ ] Manual: host-mode type + `provider: "deepseek"` → loader rejects with clear error. - [ ] Manual: env var with embedded `\n` → reconciler rejects with clear error. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(agents): multi-provider POC — DeepSeek + Ollama via ANTHROPIC_BASE_URL flip
All checks were successful
qa / qa (pull_request) Successful in 12m42s
qa / dockerfile (pull_request) Successful in 15s
f03871444a
Per-agent LLM provider selection on `agents.json::types.<name>.provider`.
All providers expose the Anthropic Messages API so the in-container
Claude CLI is unchanged — only `ANTHROPIC_BASE_URL` and
`ANTHROPIC_AUTH_TOKEN` flip per provider at container start.

- `agents-config-schema.ts`: `provider` enum `anthropic | deepseek | ollama`,
  default `anthropic`, optional on the wire.
- `webhook-config.ts`: parse + validate; reject `provider != "anthropic"`
  on host-mode types (foreman) since host bypasses container env injection.
- `container-reconcile.ts`: hardcoded provider conventions (DeepSeek
  `https://api.deepseek.com/anthropic` + `DEEPSEEK_API_KEY`; Ollama
  `OLLAMA_BASE_URL` + dummy token). Newline-rejects every injected env
  value so `--env-file` line termination cannot be hijacked. Allocates
  `envTmp` path before `try` so cleanup is reliable on partial-write throws.
- `sdk-adapter.ts`: `listModels(provider)` with per-provider catalogues
  (Anthropic `/v1/models`, DeepSeek `/v1/models` Bearer, Ollama `/api/tags`),
  24 h cache, hardcoded fallback so the UI combobox never goes blank.
  `clearListModelsCache()` invoked on `reloadWebhookConfig()`.
- `db.ts` + `http/handlers/config.ts`: on `PUT /config/agents` provider
  flip, scrub per-instance `model_override` rows so dispatchers don't
  push a Claude model name through DeepSeek (silent gibberish at the
  alt endpoint).
- `claude-port.ts` + `resolved-agent.ts`: `ModelInfo`, `AgentProvider`,
  `agentProvider()` defaulter on the shared port surface.
- `agent-runner.test.ts`: `FakeClaudeAgent` gains `setModels()` /
  `listModels()` stubs.
- `docs/providers.md`: spec covering shape, operator setup (systemd
  drop-ins for `DEEPSEEK_API_KEY` / `OLLAMA_BASE_URL`), known caveats
  (no prompt cache for non-Anthropic, foreman host-mode limitation,
  Ollama tool-call model dependence).

Web UI surface and `/agents/models` HTTP route registration deliberately
deferred to follow-up commits — this PR ships server-side wiring only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
test(agents): cover buildProviderEnvLines + host-mode provider rejection; tighten listModels cache
All checks were successful
qa / qa (pull_request) Successful in 12m31s
qa / dockerfile (pull_request) Successful in 15s
d0a94ac771
Review feedback on f038714:

- `sdk-adapter.ts`: skip the 24h listModels cache write when the response is a fallback (missing API key, fetch failure, parse failure, empty upstream). Caching a fallback would pin the hardcoded list for 24h after a transient blip and paper over a missing env var until restart. `listModelsForProvider` now returns `{ models, fromUpstream }`; `SdkClaudeAgent.listModels` only writes the cache when `fromUpstream`.
- `webhook-config.ts`: clarify that the host-mode + non-anthropic provider gate uses `container?.enabled === false` (mirrors `mergeAgent`'s host_mode resolution at L1376). Behaviour unchanged — comment now states the alignment so future readers don't misread the line 188 doc-comment.
- `container-reconcile.ts`: export `buildProviderEnvLines` so it's directly testable (no docker required).
- `container-reconcile.test.ts`: 10 new tests covering anthropic/undefined default, deepseek injection, missing+whitespace+\r+\n DEEPSEEK_API_KEY rejection (env-file injection guard), ollama default URL, OLLAMA_BASE_URL override + trailing-slash strip, OLLAMA_BASE_URL newline rejection.
- `webhook-config.test.ts`: 6 new tests covering provider absent → "anthropic" default, deepseek + ollama load cleanly on containerised types, unknown provider rejected at load, host-mode + provider=deepseek rejected, host-mode + provider=anthropic loads cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
charles deleted branch feat/multi-provider 2026-04-29 11:44:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!547
No description provided.