Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
!healthcheck.sh
!openchamber-healthcheck.sh
!AGENTS.md
!config/
!skills/
!opencode.jsonc
!orchestra.jsonc
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ ENV HOME=/data \

COPY --from=config /build/runtime.json /etc/opencode/opencode.json
COPY AGENTS.md /etc/opencode/AGENTS.md
COPY config/quota-toast.jsonc /etc/opencode/quota-toast.jsonc
COPY skills /etc/opencode/skills
COPY --chmod=755 entrypoint.sh healthcheck.sh openchamber-healthcheck.sh /usr/local/bin/
RUN mkdir -p /data/workspace /data/.config/opencode "$PNPM_HOME" \
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ All state lives in the `/data` volume, and startup exits if `/data` is not a vol
| --- | --- |
| `.local/share/opencode/opencode.db` | Sessions and provider credentials |
| `.local/share/opencode/log/opencode.log` | OpenCode server log |
| `.local/state/opencode` | Service registration and locks |
| `.local/state/opencode` | Service registration, locks, and quota budget counters |
| `.config/opencode`, `.config/gh` | Global `AGENTS.md` (replaced on each start), GitHub CLI login |
| `.config/openchamber` | OpenChamber settings, passkeys, and login secret |
| `workspace` | Repositories, worktrees, and OpenChamber chats in `workspace/.chats` |
Expand Down Expand Up @@ -53,6 +53,12 @@ docker compose exec opencode opencode auth login openai --server http://127.0.0.
- **Git access:** set `GH_TOKEN` or run `docker compose exec opencode gh auth login --hostname github.com --git-protocol https --web`. This is separate from Copilot login; stored credentials persist in `/data/.config/gh`.
- **WakaTime:** put your API key in `/data/.wakatime.cfg`, owned by `1000:1000` with mode `600`, using the [plugin's configuration format](https://github.com/angristan/opencode-wakatime#prerequisites).

## Budgets and fallback

Build and compaction default to `anthropic/claude-opus-5-5`. When a provider rejects a request for quota, rate limit, or availability, [opencode-runtime-fallback](https://github.com/youngbinkim0/opencode-fallback) continues the session on OpenAI, then Google, then Copilot, and retries the failed provider after an hour. Set a $50 monthly spend limit in each provider console; that limit is what stops spending.

`/quota` shows Copilot plus a $50 rolling 30-day row each for Anthropic, OpenAI, and Google, estimated from this server's requests with [models.dev](https://models.dev) prices. The rows come from [`config/quota-toast.jsonc`](config/quota-toast.jsonc), which each start copies to `/data/.config/opencode/opencode-quota/`, replacing local edits.

## MCP integrations

Set the variables below before creating the container. For remote servers, open `/mcps`, select the server, and sign in when prompted; OAuth credentials persist in `/data`.
Expand Down
27 changes: 27 additions & 0 deletions config/quota-toast.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"enabledProviders": ["copilot", "quota-providers"],
"waitForQuotaReset": false,
"quotaProviders": [
{
"id": "anthropic-api",
"providerId": "anthropic",
"label": "Anthropic",
"mode": "local-estimate",
"windows": [{ "id": "rolling-30-days", "label": "30 days", "type": "rolling", "durationMinutes": 43200, "requestLimit": 1000000, "usdBudget": 50 }]
},
{
"id": "openai-api",
"providerId": "openai",
"label": "OpenAI",
"mode": "local-estimate",
"windows": [{ "id": "rolling-30-days", "label": "30 days", "type": "rolling", "durationMinutes": 43200, "requestLimit": 1000000, "usdBudget": 50 }]
},
{
"id": "google-api",
"providerId": "google",
"label": "Google",
"mode": "local-estimate",
"windows": [{ "id": "rolling-30-days", "label": "30 days", "type": "rolling", "durationMinutes": 43200, "requestLimit": 1000000, "usdBudget": 50 }]
}
]
}
2 changes: 2 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ mkdir -p "$XDG_CONFIG_HOME/opencode" "$XDG_CONFIG_HOME/gh" "$XDG_DATA_HOME/openc
# V2 discovers global AGENTS.md; the legacy instructions config is ignored.
# The image owns these rules, so every start replaces any local copy.
cp /etc/opencode/AGENTS.md "$XDG_CONFIG_HOME/opencode/AGENTS.md"
mkdir -p "$XDG_CONFIG_HOME/opencode/opencode-quota"
cp /etc/opencode/quota-toast.jsonc "$XDG_CONFIG_HOME/opencode/opencode-quota/quota-toast.jsonc"

# Use gh credentials for HTTPS Git operations, including before the first login.
# Both the helper configuration and interactive logins persist in /data.
Expand Down
22 changes: 19 additions & 3 deletions opencode.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"update": "disable",
"model": "github-copilot/gpt-6-astra",
"model": "anthropic/claude-opus-5-5",
"default_agent": "build",
"shell": "/usr/bin/zsh",
"worktree": {
Expand Down Expand Up @@ -62,7 +62,7 @@
"model": "github-copilot/gpt-5.4-mini"
},
"compaction": {
"model": "github-copilot/gpt-6-astra"
"model": "anthropic/claude-opus-5-5"
Comment thread
icco marked this conversation as resolved.
}
},
"mcp": {
Expand Down Expand Up @@ -126,7 +126,23 @@
"opencode-gemini-auth@2.0.1",
"opencode-wakatime@1.4.0",
"superpowers@git+https://github.com/obra/superpowers.git#bb92a77741419a4ab5f06e711a283343f1ada0c3",
"@slkiser/opencode-quota@5.0.2"
"@slkiser/opencode-quota@5.0.3",
{
"package": "opencode-runtime-fallback@0.3.0",
"options": {
"fallback_models": [
"openai/gpt-6-astra",
"google/gemini-4-argon",
"github-copilot/gpt-6-astra"
],
"retryable_error_patterns": [
"usage limits",
"exceeded your current quota"
],
"cooldown_seconds": 3600,
"timeout_seconds": 120
}
}
],
"policies": [
{
Expand Down
9 changes: 9 additions & 0 deletions scripts/test-router-integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ try {
await mkdir(join(home, "config/opencode"), { recursive: true });
await writeFile(join(home, "runtime.json"), JSON.stringify({ ...await buildConfig(), skills: [join(root, "skills")] }));
await copyFile(join(root, "AGENTS.md"), join(home, "config/opencode/AGENTS.md"));
await mkdir(join(home, "config/opencode/opencode-quota"), { recursive: true });
await copyFile(join(root, "config/quota-toast.jsonc"), join(home, "config/opencode/opencode-quota/quota-toast.jsonc"));
// Keep the host's config, database overrides, and credentials out of this server.
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) =>
!/^(OPENCODE_|ORCHESTRA_|WAKATIME_|GRAFANA_|LUNCHMONEY_|KARAKEEP_|GOOGLE_|GIT_CONFIG_|GH_|GITHUB_)|API_KEY|TOKEN/.test(key)));
Expand Down Expand Up @@ -77,6 +79,7 @@ try {
assert.ok(packages.some(p => p.id === "superpowers"));
assert.ok(!data.some(p => p.id === "opencode-orchestra"));
assert.ok(packages.some(p => p.id === "@slkiser/opencode-quota.server"), JSON.stringify(packages));
assert.ok(packages.some(p => p.id.includes("fallback")), JSON.stringify(packages));
return true;
});
const commands = (await request("/api/command")).data;
Expand Down Expand Up @@ -147,6 +150,12 @@ try {
const report = item.payload.metadata?.opencodeQuota;
return report?.command === "quota_announcements" && report.document?.sections?.length;
}), `quota command produced a report: ${JSON.stringify(inbox)}`);
await request(`/api/session/${session.id}/command`, { name: "quota_status", text: "" });
const status = (await request(`/api/session/${session.id}/inbox`)).data
.map(item => item.payload.metadata?.opencodeQuota).find(report => report?.command === "quota_status");
assert.ok(!JSON.stringify(status).includes("quotaProviders["), `quota config is valid: ${JSON.stringify(status)}`);
for (const id of ["anthropic-api", "openai-api", "google-api"])
assert.ok(JSON.stringify(status).includes(id), `quota_status lists ${id}: ${JSON.stringify(status)}`);
await request(`/api/experimental/session/${session.id}/wait`, undefined, "POST");
const context = (await request(`/api/session/${session.id}/message`)).data;
assert.ok(!context.some(m => m.type === "assistant"), "no model inference");
Expand Down
16 changes: 13 additions & 3 deletions tests/routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { Config } from "@opencode/schema/config";
import { parse } from "jsonc-parser";
import { buildConfig, readConfig } from "../scripts/build-config.mjs";

// Decode schemas with their own Effect runtime, not an independently updated one.
Expand All @@ -15,13 +16,13 @@ const config = await readConfig();
test("configuration validates strictly against the installed V2 schema", () => {
const parsed = Schema.decodeUnknownSync(Config.Info, { onExcessProperty: "error" })(config);
assert.equal(parsed.default_agent, "build");
assert.equal(parsed.model.providerID, "github-copilot");
assert.equal(parsed.model.providerID, "anthropic");
assert.equal(config.worktree.directory, "/data/workspace/.worktrees");
});

test("Superpowers is immutable-pinned and replaces Orchestra", async () => {
assert.ok(config.plugins.some(p => /^superpowers@git\+https:\/\/github.com\/obra\/superpowers.git#[a-f0-9]{40}$/.test(p)));
assert.ok(!config.plugins.some(p => p.includes("orchestra")));
assert.ok(!config.plugins.some(p => (p.package ?? p).includes("orchestra")));
assert.ok(!Object.keys(config.agents).some(p => p.startsWith("orch-")));
assert.deepEqual(await buildConfig(), config);
const instructions = await readFile(new URL("../AGENTS.md", import.meta.url), "utf8");
Expand All @@ -40,7 +41,7 @@ test("image-owned skills are discoverable", async () => {

test("existing providers, MCP integrations and quota are preserved", () => {
assert.deepEqual(Object.keys(config.mcp.servers).sort(), ["context7", "grafana", "karakeep", "lunchmoney", "notion", "robinhood"]);
assert.ok(config.plugins.includes("@slkiser/opencode-quota@5.0.2"));
assert.ok(config.plugins.includes("@slkiser/opencode-quota@5.0.3"));
const allowed = config.policies.filter(p => p.action === "provider.use" && p.effect === "allow").map(p => p.resource);
assert.deepEqual(allowed.sort(), ["anthropic", "github-copilot", "google", "openai"]);
assert.ok(!config.permissions?.some(rule => rule.action === "*" && rule.effect === "allow"));
Expand All @@ -49,6 +50,15 @@ test("existing providers, MCP integrations and quota are preserved", () => {
assert.ok(config.agents[name].permissions.some(rule => rule.action === "robinhood_*" && rule.resource === "*" && rule.effect === "deny"), `${name} denies Robinhood tools`);
});

test("paid providers fall back to Copilot and show $50 budgets", async () => {
const fallback = config.plugins.find(p => p.package?.startsWith("opencode-runtime-fallback@"));
assert.match(fallback.package, /@\d+\.\d+\.\d+$/);
assert.deepEqual(fallback.options.fallback_models.map(m => m.split("/")[0]), ["openai", "google", "github-copilot"]);
const quota = parse(await readFile(new URL("../config/quota-toast.jsonc", import.meta.url), "utf8"));
assert.deepEqual(quota.quotaProviders.map(p => [p.providerId, p.windows[0].usdBudget]), [["anthropic", 50], ["openai", 50], ["google", 50]]);
assert.equal(quota.waitForQuotaReset, false);
});

test("agents ask before deleting server state under the home directory", async () => {
const asks = ["rm *.config*", "rm *.local*", "rm *HOME*", "rm *~*", "rm */data", "rm */data/", "rm */data/.*"]
.map(resource => ({ action: "shell", resource, effect: "ask" }));
Expand Down
Loading