Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d9d081030 | ||
|
|
6bc862e051 | ||
|
|
fff021283d | ||
|
|
f4f785e0bb | ||
|
|
c88fdcc2ea | ||
|
|
98762e764a | ||
|
|
5507192ee6 |
+5
-19
@@ -21,31 +21,17 @@ 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.
|
||||
CLAUDE_CODE_OAUTH_TOKEN=
|
||||
|
||||
# --- 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 "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) ---
|
||||
# --- litellm (local LLM gateway — used by Hermes, see litellm-config.yaml) ---
|
||||
OPENROUTER_API_KEY=
|
||||
# Any random string; also used as litellm's general_settings.master_key.
|
||||
LITELLM_MASTER_KEY=
|
||||
|
||||
# --- hermes (autonomous agent with its own native Matrix presence) ---
|
||||
# --- hermes (the only agent with a Matrix presence — see README) ---
|
||||
# Your own Matrix ID — Hermes only responds to this user, and only when @mentioned
|
||||
# in a shared room (e.g. "@hermes <task>" in the control room).
|
||||
# in a room (free-response in DMs).
|
||||
MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu
|
||||
# Access token for the @hermes bot account (register it the same way as claude-bot —
|
||||
# see README — then log in as it via /_matrix/client/v3/login to get this token).
|
||||
# Access token for the @hermes bot account — register it on the homeserver, then log
|
||||
# in as it via /_matrix/client/v3/login to get this token (see README).
|
||||
HERMES_MATRIX_ACCESS_TOKEN=
|
||||
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
|
||||
# (internal network only, not published anywhere).
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
# gitops-automation
|
||||
|
||||
Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
|
||||
`~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale.
|
||||
Claude Code + Hermes automation wired into Gitea + Portainer + Matrix on this VPS. See
|
||||
`~/.claude/plans/cozy-honking-lantern.md` on the host for the original design rationale
|
||||
(some of it — the Matrix chat bot on claude-agent — has since been superseded, see below).
|
||||
|
||||
Three things this gives you:
|
||||
What this gives you today:
|
||||
- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored
|
||||
review comment.
|
||||
review comment (`claude-agent`, triggered by a Gitea webhook — nothing to do with Matrix).
|
||||
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
|
||||
(Gitea Actions) and redeploys the stack (Portainer webhook).
|
||||
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
|
||||
room clones the repo, runs Claude Code, and opens a PR with the result.
|
||||
- **Ask other models**: `!ai <prompt>` (default model) or `!ai provider/model <prompt>`
|
||||
(e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on
|
||||
OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike
|
||||
`!claude` which is the only command that can edit files and open PRs.
|
||||
(Gitea Actions) and redeploys the stack, chained as the last step of that same workflow.
|
||||
- **Matrix**: **Hermes is the only agent present in Matrix.** `claude-agent` used to also
|
||||
run a Matrix bot (`!claude`/`!ai` commands, then no-prefix auto-routing) — that's been
|
||||
removed entirely (by request: one agent in Matrix, not several). Hermes has its own
|
||||
native Matrix connection, responds to `@hermes <message>` in shared rooms (no mention
|
||||
needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see
|
||||
its docs at https://hermes-agent.nousresearch.com for what it can do. It does not (yet)
|
||||
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. Every path stops at a comment or an open PR — a human clicks merge.
|
||||
Nothing here auto-merges PRs. Every path stops at a comment or an open PR — a human clicks
|
||||
merge (enforced by branch protection on `main`, not just by convention — see below).
|
||||
|
||||
## Prerequisites (one-time, on the VPS)
|
||||
|
||||
@@ -125,17 +129,17 @@ docker network create web
|
||||
## Smoke test
|
||||
|
||||
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
|
||||
- In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a
|
||||
"working on it" reply, then a PR link.
|
||||
- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
|
||||
- In the Matrix control room: `@hermes hello` → expect a reply from Hermes.
|
||||
- `git push` to `main` on this repo (touching `agent/**`) → expect a Gitea Actions run,
|
||||
then a chained Portainer redeploy at the end of that same workflow.
|
||||
|
||||
## Notes
|
||||
|
||||
- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
|
||||
checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
|
||||
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.
|
||||
- `agent/src/runner.js` only ever runs read-only `git clone`/`fetch`/`checkout` for the PR
|
||||
diff it reviews — Claude Code itself is denied `Edit`/`Write`/commit/push tools
|
||||
(`--disallowedTools`), so this path can never modify a repo, only comment on it.
|
||||
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
|
||||
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
|
||||
|
||||
<!-- gitops loop smoke test 2026-08-23T11:23:34Z -->
|
||||
- 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
|
||||
worth keeping.
|
||||
|
||||
+1
-2
@@ -8,7 +8,6 @@
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"matrix-bot-sdk": "^0.7.1"
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,27 +24,6 @@ export async function postPRComment(owner, repo, index, body) {
|
||||
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.
|
||||
export function authenticatedCloneUrl(cloneUrl) {
|
||||
const u = new URL(cloneUrl);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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);
|
||||
}
|
||||
+8
-42
@@ -2,8 +2,7 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
|
||||
import { authenticatedCloneUrl } from "./gitea.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const WORKSPACE_ROOT = "/workspace";
|
||||
@@ -29,10 +28,12 @@ async function withWorkspace(fn) {
|
||||
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
|
||||
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
|
||||
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
|
||||
// Claude can never push/commit/checkout itself — this script owns those steps.
|
||||
async function runClaude(cwd, prompt, { allowEdits }) {
|
||||
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
|
||||
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
|
||||
// Claude can never push/commit/checkout, or edit files — this is read-only review.
|
||||
async function runClaude(cwd, prompt) {
|
||||
const disallowed = [
|
||||
"Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)",
|
||||
"Edit", "Write", "NotebookEdit",
|
||||
];
|
||||
|
||||
const args = [
|
||||
"-p", prompt,
|
||||
@@ -59,41 +60,6 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p
|
||||
`PR description:\n${prBody}`,
|
||||
].join("\n");
|
||||
|
||||
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}`,
|
||||
});
|
||||
return runClaude(dir, prompt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import express from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { postPRComment } from "./gitea.js";
|
||||
import { reviewPullRequest } from "./runner.js";
|
||||
import { startMatrixBot } from "./matrixBot.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -60,7 +59,3 @@ app.post("/webhooks/gitea", async (req, res) => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`claude-agent listening on :${PORT}`);
|
||||
});
|
||||
|
||||
startMatrixBot().catch((err) => {
|
||||
console.error("matrix bot failed to start:", err);
|
||||
});
|
||||
|
||||
+13
-17
@@ -78,20 +78,26 @@ services:
|
||||
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
||||
MATRIX_REQUIRE_MENTION: "true"
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
# Exposed on the internal network only (see claude-agent's LITELLM_BASE_URL-style
|
||||
# usage pattern) — nothing publishes this port externally.
|
||||
API_SERVER_ENABLED: "true"
|
||||
API_SERVER_HOST: 0.0.0.0
|
||||
API_SERVER_KEY: ${HERMES_API_SERVER_KEY}
|
||||
# Left disabled: Hermes itself warns that a network-reachable API server combined
|
||||
# with the default unsandboxed ('local') terminal backend gives any caller full
|
||||
# terminal/file access within the container. Matrix is the actual interface in use;
|
||||
# re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if
|
||||
# claude-agent ever needs to call Hermes programmatically.
|
||||
API_SERVER_ENABLED: "false"
|
||||
volumes:
|
||||
- /home/william/hermes-data:/opt/data
|
||||
networks:
|
||||
- web
|
||||
# Without this the image's default command launches the interactive CLI, which
|
||||
# immediately exits ("Input is not a terminal") since a detached container has no
|
||||
# stdin — the container then just sits there having done nothing, every restart.
|
||||
command: ["gateway", "run"]
|
||||
|
||||
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}
|
||||
depends_on:
|
||||
- litellm
|
||||
container_name: claude-agent
|
||||
restart: unless-stopped
|
||||
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
|
||||
@@ -104,16 +110,6 @@ services:
|
||||
# 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_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}
|
||||
KNOWN_REPOS: ${KNOWN_REPOS}
|
||||
# All model calls now go through the local litellm service, not OpenRouter directly —
|
||||
# one gateway for OpenRouter's models (incl. its auto-router) and, for the
|
||||
# claude-subscription route, Anthropic itself via the forwarded OAuth token above.
|
||||
LITELLM_BASE_URL: http://litellm:4000
|
||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
||||
volumes:
|
||||
- agent_workspace:/workspace
|
||||
networks:
|
||||
|
||||
+13
-7
@@ -11,13 +11,19 @@ model_list:
|
||||
model: openrouter/openai/gpt-4o-mini
|
||||
api_key: os.environ/OPENROUTER_API_KEY
|
||||
|
||||
# NOT included: a "claude-subscription" route forwarding the Claude Pro/Max OAuth token
|
||||
# (from `claude setup-token`) through to Anthropic's raw API. Tested and confirmed
|
||||
# non-functional — Anthropic returns a generic rate_limit_error for ANY direct API call
|
||||
# using this token type outside the real Claude Code CLI client (reproduced with plain
|
||||
# curl straight to api.anthropic.com, bypassing LiteLLM entirely, same result). The
|
||||
# subscription token only works through the actual Claude Code CLI, which is what
|
||||
# claude-agent already uses directly for code tasks — it was never routed through here.
|
||||
# Routes to Anthropic using the CALLER's forwarded Authorization header (the Claude
|
||||
# Pro/Max subscription OAuth token) instead of a LiteLLM-held API key — billed against
|
||||
# the subscription, not per-token. CONFIRMED WORKING, but only for the real `claude`
|
||||
# CLI binary as caller (tested: `claude -p` with ANTHROPIC_BASE_URL pointed here
|
||||
# returned a real completion). An earlier test with plain curl replicating the same
|
||||
# request shape failed — Anthropic apparently requires header/fingerprint details only
|
||||
# the real CLI sends, which LiteLLM faithfully relays but a hand-built request won't
|
||||
# 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
|
||||
|
||||
general_settings:
|
||||
forward_client_headers_to_llm_api: true
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
|
||||
Reference in New Issue
Block a user