Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f785e0bb | ||
|
|
c88fdcc2ea | ||
|
|
98762e764a | ||
|
|
5507192ee6 | ||
|
|
9fa025f7d5 | ||
|
|
5461754f23 | ||
|
|
f93bfb25a2 | ||
|
|
4fc4433833 | ||
|
|
8db8347df0 |
+23
-2
@@ -25,10 +25,31 @@ CLAUDE_CODE_OAUTH_TOKEN=
|
|||||||
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
|
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
|
||||||
MATRIX_BOT_TOKEN=
|
MATRIX_BOT_TOKEN=
|
||||||
MATRIX_CONTROL_ROOM_ID=
|
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
|
||||||
|
|
||||||
# --- openrouter (the "!ai" chat command — other models, not the coding agent) ---
|
# --- litellm (local LLM gateway — see litellm-config.yaml) ---
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini
|
# Any random string; also used as litellm's general_settings.master_key.
|
||||||
|
LITELLM_MASTER_KEY=
|
||||||
|
|
||||||
|
# --- hermes (autonomous agent with its own native Matrix presence) ---
|
||||||
|
# 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).
|
||||||
|
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).
|
||||||
|
HERMES_MATRIX_ACCESS_TOKEN=
|
||||||
|
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
|
||||||
|
# (internal network only, not published anywhere).
|
||||||
|
HERMES_API_SERVER_KEY=
|
||||||
|
|
||||||
# --- portainer (GitOps redeploy) ---
|
# --- portainer (GitOps redeploy) ---
|
||||||
PORTAINER_STACK_WEBHOOK_URL=
|
PORTAINER_STACK_WEBHOOK_URL=
|
||||||
|
|||||||
@@ -31,3 +31,10 @@ jobs:
|
|||||||
IMAGE="${{ vars.REGISTRY_HOST }}/${{ gitea.repository }}/claude-agent:latest"
|
IMAGE="${{ vars.REGISTRY_HOST }}/${{ gitea.repository }}/claude-agent:latest"
|
||||||
docker build -t "$IMAGE" ./agent
|
docker build -t "$IMAGE" ./agent
|
||||||
docker push "$IMAGE"
|
docker push "$IMAGE"
|
||||||
|
|
||||||
|
- name: Trigger Portainer redeploy
|
||||||
|
# Deliberately NOT a separate Gitea repo webhook firing in parallel on the same
|
||||||
|
# push — that raced with this build and could redeploy before the new image was
|
||||||
|
# actually pushed, silently keeping the old code running. Chaining it here as the
|
||||||
|
# last step guarantees the image exists before Portainer goes to pull it.
|
||||||
|
run: curl -f -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+30
-52
@@ -1,37 +1,23 @@
|
|||||||
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
|
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
|
||||||
import { runChatTask } from "./runner.js";
|
import { runChatTask } from "./runner.js";
|
||||||
import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.js";
|
import { routeMessage, chatReply } from "./router.js";
|
||||||
|
|
||||||
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
|
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
|
||||||
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
|
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
|
||||||
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
|
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
|
||||||
const GITEA_URL = process.env.GITEA_URL;
|
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;
|
const MAX_REPLY_LENGTH = 4000;
|
||||||
|
|
||||||
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
|
|
||||||
function parseClaudeCommand(text) {
|
|
||||||
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
|
|
||||||
if (!match) return null;
|
|
||||||
const [, repoFullName, instruction] = match;
|
|
||||||
return { repoFullName, instruction: instruction.trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// "!ai <prompt>" uses the default model. "!ai provider/model <prompt>" (first token
|
|
||||||
// contains a "/") picks a specific OpenRouter model, e.g. "!ai google/gemini-2.0-flash-001
|
|
||||||
// summarize this repo's README".
|
|
||||||
function parseAiCommand(text) {
|
|
||||||
const match = text.match(/^!ai\s+(.+)$/s);
|
|
||||||
if (!match) return null;
|
|
||||||
const rest = match[1].trim();
|
|
||||||
const firstSpace = rest.search(/\s/);
|
|
||||||
const firstToken = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
|
|
||||||
if (firstToken.includes("/") && firstSpace !== -1) {
|
|
||||||
return { model: firstToken, prompt: rest.slice(firstSpace + 1).trim() };
|
|
||||||
}
|
|
||||||
return { model: DEFAULT_MODEL, prompt: rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text) {
|
function truncate(text) {
|
||||||
if (text.length <= MAX_REPLY_LENGTH) return text;
|
if (text.length <= MAX_REPLY_LENGTH) return text;
|
||||||
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
|
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
|
||||||
@@ -42,15 +28,14 @@ export async function startMatrixBot() {
|
|||||||
console.warn("Matrix env vars not set — skipping bot startup");
|
console.warn("Matrix env vars not set — skipping bot startup");
|
||||||
return;
|
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 storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
|
||||||
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
|
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
|
||||||
|
|
||||||
// Not using AutojoinRoomsMixin: it calls /joined_rooms at startup to build its initial
|
|
||||||
// state, which — like the /whoami call removed earlier — 404s against Continuwuity for
|
|
||||||
// reasons unrelated to the endpoint itself (curling it directly works fine). This
|
|
||||||
// simpler handler does the one thing we actually need — auto-join on invite — without
|
|
||||||
// that startup scan.
|
|
||||||
client.on("room.invite", async (roomId) => {
|
client.on("room.invite", async (roomId) => {
|
||||||
try {
|
try {
|
||||||
await client.joinRoom(roomId);
|
await client.joinRoom(roomId);
|
||||||
@@ -60,38 +45,31 @@ export async function startMatrixBot() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.on("room.message", async (roomId, event) => {
|
client.on("room.message", async (roomId, event) => {
|
||||||
// Invite-only control room enforces who can reach the bot at all; this just scopes
|
|
||||||
// command handling to that one room. No need to fetch/compare the bot's own user ID
|
|
||||||
// to filter out its own messages — its replies never match either command pattern
|
|
||||||
// below, so they're ignored the same as any other non-command message.
|
|
||||||
if (roomId !== CONTROL_ROOM_ID) return;
|
if (roomId !== CONTROL_ROOM_ID) return;
|
||||||
|
if (event.sender === BOT_USER_ID) return;
|
||||||
const body = event.content?.body;
|
const body = event.content?.body;
|
||||||
if (!body) return;
|
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;
|
||||||
|
|
||||||
const aiCmd = parseAiCommand(body);
|
|
||||||
if (aiCmd) {
|
|
||||||
try {
|
try {
|
||||||
const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt);
|
const decision = await routeMessage(body, KNOWN_REPOS);
|
||||||
await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`);
|
|
||||||
} catch (err) {
|
if (decision.type === "code_task") {
|
||||||
console.error("openrouter query failed", err);
|
const [owner, repo] = decision.repo.split("/");
|
||||||
await client.sendText(roomId, `Failed: ${err.message}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = parseClaudeCommand(body);
|
const reply = await chatReply(body);
|
||||||
if (!cmd) return;
|
await client.sendText(roomId, truncate(reply));
|
||||||
|
|
||||||
const [owner, repo] = cmd.repoFullName.split("/");
|
|
||||||
await client.sendText(roomId, `Working on it: ${cmd.repoFullName} — ${cmd.instruction}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
|
|
||||||
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
|
|
||||||
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("chat task failed", err);
|
console.error("message handling failed", err);
|
||||||
await client.sendText(roomId, `Failed: ${err.message}`);
|
await client.sendText(roomId, `Failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
||||||
const DEFAULT_MODEL = process.env.OPENROUTER_DEFAULT_MODEL || "openai/gpt-4o-mini";
|
|
||||||
|
|
||||||
export async function askOpenRouter(model, prompt) {
|
|
||||||
if (!OPENROUTER_API_KEY) {
|
|
||||||
throw new Error("OPENROUTER_API_KEY is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: model || DEFAULT_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(`OpenRouter request failed: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const content = data.choices?.[0]?.message?.content;
|
|
||||||
if (!content) {
|
|
||||||
throw new Error(`OpenRouter returned no content: ${JSON.stringify(data)}`);
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DEFAULT_MODEL };
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+66
-2
@@ -36,8 +36,67 @@ services:
|
|||||||
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
|
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
# Pinned deliberately, not :latest or :main-latest — litellm==1.82.7/1.82.8 on PyPI
|
||||||
|
# were compromised with credential-stealing malware in March 2026 (fixed within the
|
||||||
|
# hour, but a floating tag could still land on a bad release in the future). v1.98.0
|
||||||
|
# verified clean as of this writing.
|
||||||
|
image: ghcr.io/berriai/litellm:v1.98.0
|
||||||
|
container_name: litellm
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
||||||
|
volumes:
|
||||||
|
# Absolute host path, NOT a repo-relative one — Portainer's git-stack deploy clones
|
||||||
|
# into its own directory (/data/compose/N/) whose checkout doesn't reliably persist
|
||||||
|
# for the container's runtime (see the act_runner config comment below for the same
|
||||||
|
# failure mode). An absolute path on the actual host filesystem always resolves the
|
||||||
|
# same way regardless of which tool ran `docker compose up`. Keep this local clone
|
||||||
|
# (/home/william/gitops-automation) pulled to latest when the config changes.
|
||||||
|
- /home/william/gitops-automation/litellm-config.yaml:/app/config.yaml:ro
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
|
||||||
|
# master key and OAuth-forwarding config to the public internet.
|
||||||
|
|
||||||
|
hermes:
|
||||||
|
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
|
||||||
|
image: nousresearch/hermes-agent:v2026.8.19
|
||||||
|
container_name: hermes
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
HERMES_UID: "1000"
|
||||||
|
HERMES_GID: "1000"
|
||||||
|
# Internal container address, not the public HTTPS one — same docker network as
|
||||||
|
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
|
||||||
|
MATRIX_HOMESERVER: http://matrix-homeserver:8008
|
||||||
|
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
|
||||||
|
# Only you can trigger it; and only with an explicit @hermes mention in shared
|
||||||
|
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
|
||||||
|
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
|
||||||
|
MATRIX_REQUIRE_MENTION: "true"
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_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:
|
claude-agent:
|
||||||
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
|
||||||
@@ -53,8 +112,13 @@ services:
|
|||||||
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
|
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
|
||||||
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
|
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
|
||||||
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
|
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
|
||||||
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini}
|
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:
|
volumes:
|
||||||
- agent_workspace:/workspace
|
- agent_workspace:/workspace
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
model_list:
|
||||||
|
# General chat — OpenRouter's own auto-router picks the best underlying model per prompt.
|
||||||
|
- model_name: auto
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/openrouter/auto
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
# Cheap/fast model used by the agent's own chat-vs-code-task classifier, not by users directly.
|
||||||
|
- model_name: router-classifier
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/openai/gpt-4o-mini
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
# 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