diff --git a/.dockerignore b/.dockerignore index 4db4f20..b4332e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ !healthcheck.sh !openchamber-healthcheck.sh !AGENTS.md +!config/ !skills/ !opencode.jsonc !orchestra.jsonc diff --git a/Dockerfile b/Dockerfile index 35b205e..0a2a598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" \ diff --git a/README.md b/README.md index d416f65..cd26c6d 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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`. diff --git a/config/quota-toast.jsonc b/config/quota-toast.jsonc new file mode 100644 index 0000000..2a2c977 --- /dev/null +++ b/config/quota-toast.jsonc @@ -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 }] + } + ] +} diff --git a/entrypoint.sh b/entrypoint.sh index 1308481..bdead7e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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. diff --git a/opencode.jsonc b/opencode.jsonc index 8efb645..e5f7a19 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -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": { @@ -62,7 +62,7 @@ "model": "github-copilot/gpt-5.4-mini" }, "compaction": { - "model": "github-copilot/gpt-6-astra" + "model": "anthropic/claude-opus-5-5" } }, "mcp": { @@ -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": [ { diff --git a/scripts/test-router-integration.mjs b/scripts/test-router-integration.mjs index 5c6eac3..8f6cf5c 100644 --- a/scripts/test-router-integration.mjs +++ b/scripts/test-router-integration.mjs @@ -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))); @@ -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; @@ -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"); diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index c3c1e83..6e0f9fb 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -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. @@ -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"); @@ -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")); @@ -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" }));