Compare commits

..
Author SHA1 Message Date
william a2c00f6f8b Fix claude-bot replying to Hermes's own messages, route Hermes through LiteLLM
Critical bug: claude-bot only ignored its OWN messages and text explicitly
addressed to @hermes — it did not ignore Hermes's own replies appearing in
the room. A single @hermes mention cascaded into claude-bot replying to
Hermes's thread messages ('Hermes says: ...'), which could itself cascade
further. Fixed with an OTHER_AGENT_USER_IDS allowlist of sender IDs to
always ignore, not just a text-prefix check.

Also point Hermes's model provider at the local litellm gateway
(OPENAI_BASE_URL/OPENAI_API_KEY) instead of OpenRouter directly, matching
claude-agent's own chat path — one place to hold the OpenRouter credential.
This does NOT grant Hermes access to the Claude subscription; that's an
Anthropic-side restriction unrelated to which proxy sits in front of it,
already proven earlier in this session.

Separately (not a code fix): the 'Billing or credits exhausted: HTTP 402'
error Hermes hit is real — the OpenRouter account currently has 0 credits.
2026-08-23 16:02:15 +00:00
11 changed files with 314 additions and 61 deletions
+23 -5
View File
@@ -21,17 +21,35 @@ GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu/<your-gitea-username>/<repo-nam
# to generate this — it's a long-lived OAuth token, not an API key. # to generate this — it's a long-lived OAuth token, not an API key.
CLAUDE_CODE_OAUTH_TOKEN= CLAUDE_CODE_OAUTH_TOKEN=
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) --- # --- matrix bot ---
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
MATRIX_BOT_TOKEN=
MATRIX_CONTROL_ROOM_ID=
# The bot's own Matrix ID (@username:server), e.g. @claude-bot:matrix.apps.williamturner.eu
# — set explicitly rather than fetched via the API (that call 404s against Continuwuity).
# Required: without it the bot can't tell its own messages apart from real ones and would
# reply to itself in a loop, so it refuses to start.
MATRIX_BOT_USER_ID=
# Comma-separated Matrix IDs of OTHER agents sharing the control room (currently just
# @hermes:...) — without this, claude-bot treats every message another bot posts as
# fresh chat input and replies to it, which that bot may then react to in turn.
OTHER_AGENT_USER_IDS=@hermes:matrix.apps.williamturner.eu
# Comma-separated "owner/repo" list the chat router is allowed to open code-change PRs
# against. A plain chat message mentioning a repo NOT in this list is treated as chat,
# never as a code task — the router only matches confidently against known repos.
KNOWN_REPOS=william/gitops-automation
# --- litellm (local LLM gateway — see litellm-config.yaml) ---
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Any random string; also used as litellm's general_settings.master_key. # Any random string; also used as litellm's general_settings.master_key.
LITELLM_MASTER_KEY= LITELLM_MASTER_KEY=
# --- hermes (the only agent with a Matrix presence — see README) --- # --- hermes (autonomous agent with its own native Matrix presence) ---
# Your own Matrix ID — Hermes only responds to this user, and only when @mentioned # Your own Matrix ID — Hermes only responds to this user, and only when @mentioned
# in a room (free-response in DMs). # in a shared room (e.g. "@hermes <task>" in the control room).
MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu
# Access token for the @hermes bot account register it on the homeserver, then log # Access token for the @hermes bot account (register it the same way as claude-bot —
# in as it via /_matrix/client/v3/login to get this token (see README). # see README — then log in as it via /_matrix/client/v3/login to get this token).
HERMES_MATRIX_ACCESS_TOKEN= HERMES_MATRIX_ACCESS_TOKEN=
# Any random string — bearer key for Hermes's own OpenAI-compatible API server # Any random string — bearer key for Hermes's own OpenAI-compatible API server
# (internal network only, not published anywhere). # (internal network only, not published anywhere).
+21 -25
View File
@@ -1,25 +1,21 @@
# gitops-automation # gitops-automation
Claude Code + Hermes automation wired into Gitea + Portainer + Matrix on this VPS. See Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
`~/.claude/plans/cozy-honking-lantern.md` on the host for the original design rationale `~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale.
(some of it — the Matrix chat bot on claude-agent — has since been superseded, see below).
What this gives you today: Three things this gives you:
- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored - **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored
review comment (`claude-agent`, triggered by a Gitea webhook — nothing to do with Matrix). review comment.
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image - **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
(Gitea Actions) and redeploys the stack, chained as the last step of that same workflow. (Gitea Actions) and redeploys the stack (Portainer webhook).
- **Matrix**: **Hermes is the only agent present in Matrix.** `claude-agent` used to also - **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
run a Matrix bot (`!claude`/`!ai` commands, then no-prefix auto-routing) — that's been room clones the repo, runs Claude Code, and opens a PR with the result.
removed entirely (by request: one agent in Matrix, not several). Hermes has its own - **Ask other models**: `!ai <prompt>` (default model) or `!ai provider/model <prompt>`
native Matrix connection, responds to `@hermes <message>` in shared rooms (no mention (e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on
needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike
its docs at https://hermes-agent.nousresearch.com for what it can do. It does not (yet) `!claude` which is the only command that can edit files and open PRs.
have the old branch/PR-opening workflow the Matrix bot used to have; that logic still
exists in git history if it's worth reviving as a Hermes tool/skill later.
Nothing here auto-merges PRs. Every path stops at a comment or an open PR — a human clicks Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
merge (enforced by branch protection on `main`, not just by convention — see below).
## Prerequisites (one-time, on the VPS) ## Prerequisites (one-time, on the VPS)
@@ -129,17 +125,17 @@ docker network create web
## Smoke test ## Smoke test
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment. - Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
- In the Matrix control room: `@hermes hello` → expect a reply from Hermes. - In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a
- `git push` to `main` on this repo (touching `agent/**`) → expect a Gitea Actions run, "working on it" reply, then a PR link.
then a chained Portainer redeploy at the end of that same workflow. - `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
## Notes ## Notes
- `agent/src/runner.js` only ever runs read-only `git clone`/`fetch`/`checkout` for the PR - `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
diff it reviews — Claude Code itself is denied `Edit`/`Write`/commit/push tools checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
(`--disallowedTools`), so this path can never modify a repo, only comment on it. so even a misbehaving prompt can't push directly or touch `main`.
- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only.
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check - `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered. `GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
- Hermes's own config/memory/skills live in `/home/william/hermes-data` on the host
(bind-mounted, not in this repo) — back that up separately if it accumulates anything <!-- gitops loop smoke test 2026-08-23T11:23:34Z -->
worth keeping.
+2 -1
View File
@@ -8,6 +8,7 @@
"start": "node src/server.js" "start": "node src/server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.19.2" "express": "^4.19.2",
"matrix-bot-sdk": "^0.7.1"
} }
} }
+21
View File
@@ -24,6 +24,27 @@ export async function postPRComment(owner, repo, index, body) {
await assertOk(res, "post PR comment"); await assertOk(res, "post PR comment");
} }
export async function createBranch(owner, repo, newBranch, oldBranch = "main") {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }),
});
await assertOk(res, "create branch");
}
export async function createPullRequest(owner, repo, { head, base = "main", title, body }) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ head, base, title, body }),
});
await assertOk(res, "create PR");
return res.json();
}
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys. // Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
export function authenticatedCloneUrl(cloneUrl) { export function authenticatedCloneUrl(cloneUrl) {
const u = new URL(cloneUrl); const u = new URL(cloneUrl);
+37
View File
@@ -0,0 +1,37 @@
const LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || "http://litellm:4000";
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY;
// OpenAI-compatible chat completion, for OpenRouter-backed models routed through the
// local LiteLLM gateway (e.g. "auto" — OpenRouter's own prompt-aware auto-router).
export async function chatCompletion(model, prompt) {
if (!LITELLM_MASTER_KEY) {
throw new Error("LITELLM_MASTER_KEY is not set");
}
const res = await fetch(`${LITELLM_BASE_URL}/v1/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
messages: [{ role: "user", content: prompt }],
// Some models default max_tokens to their full context window (e.g. 65536), which
// can exceed available credit balance before a single token is generated. This is a
// quick chat reply, not a long-form task — cap it.
max_tokens: 1024,
}),
});
if (!res.ok) {
throw new Error(`LiteLLM request failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`LiteLLM returned no content: ${JSON.stringify(data)}`);
}
return content;
}
+90
View File
@@ -0,0 +1,90 @@
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js";
import { routeMessage, chatReply } from "./router.js";
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
const GITEA_URL = process.env.GITEA_URL;
// Set explicitly rather than fetched via client.getUserId() — that call hits /whoami,
// which (like /joined_rooms before it) 404s against Continuwuity for reasons unrelated
// to the endpoint itself. This is also the only reliable way to filter the bot's own
// messages now that there's no command prefix to naturally exclude them by.
const BOT_USER_ID = process.env.MATRIX_BOT_USER_ID;
// Other agents sharing this room (currently just Hermes) — their own messages must be
// ignored the same way claude-bot ignores its own, or claude-bot's classifier treats
// every message another bot posts as fresh chat input and replies to it, which that
// bot may then react to in turn. Found the hard way: a single @hermes mention cascaded
// into claude-bot replying to Hermes's own thread messages ("Hermes says: ...").
const OTHER_AGENT_USER_IDS = (process.env.OTHER_AGENT_USER_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
const KNOWN_REPOS = (process.env.KNOWN_REPOS || "")
.split(",")
.map((r) => r.trim())
.filter(Boolean);
const MAX_REPLY_LENGTH = 4000;
function truncate(text) {
if (text.length <= MAX_REPLY_LENGTH) return text;
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
}
export async function startMatrixBot() {
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
console.warn("Matrix env vars not set — skipping bot startup");
return;
}
if (!BOT_USER_ID) {
console.warn("MATRIX_BOT_USER_ID not set — bot could reply to its own messages, skipping startup");
return;
}
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
client.on("room.invite", async (roomId) => {
try {
await client.joinRoom(roomId);
} catch (err) {
console.error("failed to join invited room", roomId, err.message);
}
});
client.on("room.message", async (roomId, event) => {
if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === BOT_USER_ID) return;
if (OTHER_AGENT_USER_IDS.includes(event.sender)) return;
const body = event.content?.body;
if (!body) return;
// Messages explicitly addressed to another agent in this room (currently just
// @hermes) are that agent's to answer — without this, claude-bot's classifier would
// also see and reply to them, since it otherwise treats every message as its own.
if (/^@hermes\b/i.test(body.trim())) return;
try {
const decision = await routeMessage(body, KNOWN_REPOS);
if (decision.type === "code_task") {
const [owner, repo] = decision.repo.split("/");
await client.sendText(roomId, `Working on it: ${decision.repo}${decision.instruction}`);
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: decision.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
return;
}
const reply = await chatReply(body);
console.log("chat reply sent, length:", reply.length);
await client.sendText(roomId, truncate(reply));
} catch (err) {
console.error("message handling failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
});
await client.start();
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
}
+45
View File
@@ -0,0 +1,45 @@
import { chatCompletion } from "./litellm.js";
const ROUTER_MODEL = "router-classifier";
const CHAT_MODEL = "auto";
function systemPrompt(knownRepos) {
return [
"You are a routing classifier for a chat bot. Given a user message, decide whether it is:",
'- "chat": a question, discussion, or anything that just needs a text reply.',
'- "code_task": a request to change a specific code repository (add/edit/fix something)',
" where the repository is clearly one of the known repositories below.",
"",
`Known repositories: ${knownRepos.join(", ") || "(none configured)"}`,
"",
"Reply with ONLY a JSON object, nothing else:",
'{"type":"chat"}',
'or',
'{"type":"code_task","repo":"owner/repo","instruction":"clear imperative instruction"}',
"",
"If it sounds like a code change but you can't confidently match it to one of the known",
'repositories, reply {"type":"chat"} instead of guessing.',
].join("\n");
}
function parseDecision(raw) {
try {
const cleaned = raw.trim().replace(/^```(?:json)?\n?/, "").replace(/```$/, "");
const parsed = JSON.parse(cleaned);
if (parsed.type === "code_task" && parsed.repo && parsed.instruction) {
return parsed;
}
} catch {
// fall through to chat — an unparseable classification is not a reason to edit a repo
}
return { type: "chat" };
}
export async function routeMessage(text, knownRepos) {
const raw = await chatCompletion(ROUTER_MODEL, `${systemPrompt(knownRepos)}\n\nMessage: ${text}`);
return parseDecision(raw);
}
export async function chatReply(text) {
return chatCompletion(CHAT_MODEL, text);
}
+42 -8
View File
@@ -2,7 +2,8 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises"; import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { authenticatedCloneUrl } from "./gitea.js"; import crypto from "node:crypto";
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace"; const WORKSPACE_ROOT = "/workspace";
@@ -28,12 +29,10 @@ async function withWorkspace(fn) {
// Runs Claude Code headless. Unattended containers have no TTY to answer permission // Runs Claude Code headless. Unattended containers have no TTY to answer permission
// prompts, so this trusts the sandboxing of the throwaway clone dir instead: // prompts, so this trusts the sandboxing of the throwaway clone dir instead:
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so // bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
// Claude can never push/commit/checkout, or edit files — this is read-only review. // Claude can never push/commit/checkout itself — this script owns those steps.
async function runClaude(cwd, prompt) { async function runClaude(cwd, prompt, { allowEdits }) {
const disallowed = [ const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
"Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)", if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
"Edit", "Write", "NotebookEdit",
];
const args = [ const args = [
"-p", prompt, "-p", prompt,
@@ -60,6 +59,41 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p
`PR description:\n${prBody}`, `PR description:\n${prBody}`,
].join("\n"); ].join("\n");
return runClaude(dir, prompt); return runClaude(dir, prompt, { allowEdits: false });
});
}
export async function runChatTask({ owner, repo, cloneUrl, instruction }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
const branch = `claude/${crypto.randomBytes(4).toString("hex")}`;
await createBranch(owner, repo, branch);
await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir });
await run("git", ["checkout", "--quiet", branch], { cwd: dir });
const prompt = [
"Implement the change described below in this repository. Make the smallest",
"correct change that satisfies it. Do not run git commit, git push, or git",
"checkout yourself — just edit files; committing and pushing happens separately.",
`Instruction: ${instruction}`,
].join("\n");
await runClaude(dir, prompt, { allowEdits: true });
await run("git", ["add", "-A"], { cwd: dir });
const status = await run("git", ["status", "--porcelain"], { cwd: dir });
if (!status.trim()) {
throw new Error("Claude made no changes for this instruction");
}
await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir });
await run("git", ["push", "--quiet", "origin", branch], { cwd: dir });
return createPullRequest(owner, repo, {
head: branch,
title: `claude: ${instruction}`.slice(0, 200),
body: `Requested via Matrix:\n\n> ${instruction}`,
});
}); });
} }
+5
View File
@@ -2,6 +2,7 @@ import express from "express";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { postPRComment } from "./gitea.js"; import { postPRComment } from "./gitea.js";
import { reviewPullRequest } from "./runner.js"; import { reviewPullRequest } from "./runner.js";
import { startMatrixBot } from "./matrixBot.js";
const app = express(); const app = express();
app.use( app.use(
@@ -59,3 +60,7 @@ app.post("/webhooks/gitea", async (req, res) => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`claude-agent listening on :${PORT}`); console.log(`claude-agent listening on :${PORT}`);
}); });
startMatrixBot().catch((err) => {
console.error("matrix bot failed to start:", err);
});
+21 -4
View File
@@ -77,7 +77,15 @@ services:
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior). # rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID} MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
MATRIX_REQUIRE_MENTION: "true" MATRIX_REQUIRE_MENTION: "true"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} # Routed through the local litellm gateway, not OpenRouter directly — same pattern
# as claude-agent's chat path, one place to hold the OpenRouter credential and swap
# models. Hermes's "main"/custom-endpoint provider is any OpenAI-compatible API
# reachable via OPENAI_BASE_URL + OPENAI_API_KEY. Note: this does NOT give Hermes
# access to the Claude Pro/Max subscription — that's blocked by Anthropic itself for
# any caller other than the real Claude Code CLI, proven earlier in this session
# (reproduced with plain curl straight to api.anthropic.com, LiteLLM or not).
OPENAI_BASE_URL: http://litellm:4000/v1
OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
# Left disabled: Hermes itself warns that a network-reachable API server combined # Left disabled: Hermes itself warns that a network-reachable API server combined
# with the default unsandboxed ('local') terminal backend gives any caller full # with the default unsandboxed ('local') terminal backend gives any caller full
# terminal/file access within the container. Matrix is the actual interface in use; # terminal/file access within the container. Matrix is the actual interface in use;
@@ -94,10 +102,9 @@ services:
command: ["gateway", "run"] command: ["gateway", "run"]
claude-agent: claude-agent:
# Gitea PR-review only now — no Matrix presence (see hermes above; only one agent
# is meant to be in Matrix). Still triggered by Gitea's pull_request webhook and
# posts review comments there, entirely independent of Matrix/LiteLLM.
image: ${GITEA_REGISTRY_IMAGE} image: ${GITEA_REGISTRY_IMAGE}
depends_on:
- litellm
container_name: claude-agent container_name: claude-agent
restart: unless-stopped restart: unless-stopped
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the # Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
@@ -110,6 +117,16 @@ services:
# Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing — # Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing —
# Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply. # Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply.
CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN}
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
OTHER_AGENT_USER_IDS: ${OTHER_AGENT_USER_IDS}
KNOWN_REPOS: ${KNOWN_REPOS}
# Chat replies go through the local litellm service (OpenRouter's models, incl. its
# auto-router), not OpenRouter directly.
LITELLM_BASE_URL: http://litellm:4000
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
volumes: volumes:
- agent_workspace:/workspace - agent_workspace:/workspace
networks: networks:
+7 -18
View File
@@ -11,24 +11,13 @@ model_list:
model: openrouter/openai/gpt-4o-mini model: openrouter/openai/gpt-4o-mini
api_key: os.environ/OPENROUTER_API_KEY api_key: os.environ/OPENROUTER_API_KEY
# Routes to Anthropic using the CALLER's forwarded Authorization header (the Claude # NOT included: a "claude-subscription" route forwarding the Claude Pro/Max OAuth token
# Pro/Max subscription OAuth token) instead of a LiteLLM-held API key — billed against # (from `claude setup-token`) through to Anthropic's raw API. Tested and confirmed
# the subscription, not per-token. CONFIRMED WORKING, but only for the real `claude` # non-functional — Anthropic returns a generic rate_limit_error for ANY direct API call
# CLI binary as caller (tested: `claude -p` with ANTHROPIC_BASE_URL pointed here # using this token type outside the real Claude Code CLI client (reproduced with plain
# returned a real completion). An earlier test with plain curl replicating the same # curl straight to api.anthropic.com, bypassing LiteLLM entirely, same result). The
# request shape failed — Anthropic apparently requires header/fingerprint details only # subscription token only works through the actual Claude Code CLI, which is what
# the real CLI sends, which LiteLLM faithfully relays but a hand-built request won't # claude-agent already uses directly for code tasks — it was never routed through here.
# have. Do NOT expect this to work for other callers (Hermes, generic HTTP clients) —
# they aren't the real CLI and can't reproduce that fingerprint.
- model_name: anthropic-claude
litellm_params:
model: anthropic/claude-sonnet-5
litellm_settings:
# Callers (Hermes included) send provider-specific params like reasoning_effort that
# not every routed model/provider accepts — drop unsupported ones instead of erroring.
drop_params: true
general_settings: general_settings:
forward_client_headers_to_llm_api: true
master_key: os.environ/LITELLM_MASTER_KEY master_key: os.environ/LITELLM_MASTER_KEY