Skip to content

Commit feab8be

Browse files
committed
feat: auto-detect supervisor credentials from OpenCode auth
- Provider falls back to ~/.local/share/opencode/auth.json (a keyed provider the user already authenticated) when no RALPH_SUPERVISOR_API_KEY / file key is set. Precedence: env > ralph-provider.json > OpenCode auth > default. - GET /api/health now reports supervisor.ready / model / source (+ hint). - Setup skill is discovery-driven: inspect opencode auth list / models, rely on auto-detect for the supervisor, recommend (not force) a free OpenCode worker model, and guide the user to authenticate if nothing usable is found. - Docs (README, getting-started, config-files) + CHANGELOG. Tests: 96 pass.
1 parent 0585db9 commit feab8be

10 files changed

Lines changed: 269 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to this project are documented here.
44

55
Format based on [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- **Supervisor credential auto-detect** — when no `RALPH_SUPERVISOR_API_KEY` (or `ralph-provider.json` key) is set, the provider falls back to OpenCode's own auth (`~/.local/share/opencode/auth.json`), using a keyed provider you've already authenticated (e.g. Google, OpenCode Zen). So an OpenCode user usually needs no separate supervisor key. Precedence: env → `ralph-provider.json` → OpenCode auth → built-in default.
12+
- **`GET /api/health` reports supervisor readiness**`supervisor.ready`, the resolved `model`, and `source` (`env` / `opencode-auth:<provider>` / `default`), with a `hint` when no credential is found. The setup skill uses this to verify before launching.
13+
14+
### Changed
15+
16+
- Setup skill is now discovery-driven: it inspects `opencode auth list` / `opencode models`, relies on auto-detect for the supervisor, recommends (does not force) a free OpenCode model for workers, and directs the user to authenticate (`opencode auth login`) if nothing usable is found — before starting the provider. Added a re-open-OpenCode step.
17+
718
## [0.3.0] - 2026-06-24
819

920
### Added

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,14 @@ npx @doeixd/opencode-ralph-rlm serve
9191
# --worktree /path/to/your/repo
9292
```
9393

94-
Supervisor LLM credentials (provider process):
94+
Supervisor LLM credentials (provider process). **If you've already authenticated a keyed provider in OpenCode** (e.g. Google, OpenCode Zen via `opencode auth login`), the provider auto-detects it — no extra config needed. To force a specific provider/model instead:
9595

9696
```bash
9797
export RALPH_SUPERVISOR_API_KEY="..."
98-
export RALPH_SUPERVISOR_MODEL="gpt-4o-mini"
98+
export RALPH_SUPERVISOR_MODEL="gpt-4o-mini" # + RALPH_SUPERVISOR_BASE_URL for non-OpenAI endpoints
9999
```
100100

101-
Or `.opencode/ralph-provider.json` — see [`.opencode/ralph-provider.example.json`](.opencode/ralph-provider.example.json).
101+
Or `.opencode/ralph-provider.json` — see [`.opencode/ralph-provider.example.json`](.opencode/ralph-provider.example.json). Check what resolved with `curl http://127.0.0.1:8787/api/health` (`supervisor.ready` / `model` / `source`).
102102

103103
### 4. OpenCode + supervisor model
104104

docs/GETTINGSTARTEDGUIDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,20 +241,23 @@ For most projects, **editing `RLM_INSTRUCTIONS.md` and `PLAN.md`** after the fir
241241

242242
## Step 6 — Supervisor LLM credentials
243243

244-
The provider calls an external LLM for supervisor chat turns (separate from your worker model).
244+
The provider calls an LLM for supervisor chat turns (separate from your worker model).
245245

246-
**Option A — environment variables**
246+
**Option A — reuse OpenCode auth (zero config).** If you've authenticated a keyed provider in OpenCode (e.g. Google or OpenCode Zen via `opencode auth login`), the provider auto-detects it — nothing to set. Verify with `curl http://127.0.0.1:8787/api/health``supervisor.ready: true` (and the resolved `model` / `source`).
247+
248+
**Option B — environment variables** (to force a specific provider/model)
247249

248250
```bash
249251
export RALPH_SUPERVISOR_API_KEY="sk-..."
250252
export RALPH_SUPERVISOR_MODEL="gpt-4o-mini"
253+
# export RALPH_SUPERVISOR_BASE_URL="https://api.openai.com/v1" # for non-OpenAI endpoints
251254
```
252255

253-
**Option B`.opencode/ralph-provider.json`**
256+
**Option C`.opencode/ralph-provider.json`**
254257

255-
See [`.opencode/ralph-provider.example.json`](../.opencode/ralph-provider.example.json) for supervisor + worker agent defaults.
258+
See [`.opencode/ralph-provider.example.json`](../.opencode/ralph-provider.example.json). Workers run *through* OpenCode and use its default model unless you pin `worker.providerID` + `worker.modelID` (e.g. a free model like `opencode` / `deepseek-v4-flash-free`).
256259

257-
Worker sessions use `worker.agent` / `worker.modelID` from this file when the engine spawns them.
260+
Precedence: env → `ralph-provider.json` → auto-detected OpenCode auth → built-in default.
258261

259262
---
260263

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import os from "node:os";
2+
import path from "node:path";
3+
import { fileExists, readTextFile } from "@doeixd/opencode-ralph-rlm-engine";
4+
5+
/**
6+
* Best-effort discovery of supervisor LLM credentials from OpenCode's own auth
7+
* store (`~/.local/share/opencode/auth.json`), so an OpenCode user who has a
8+
* keyed provider authenticated does NOT also need to set RALPH_SUPERVISOR_API_KEY.
9+
*
10+
* Only providers with a static API key (`type: "api"`) and a known
11+
* OpenAI-compatible endpoint are used. OAuth providers (e.g. ChatGPT/Anthropic
12+
* login) are skipped — their tokens are not API keys. Env / ralph-provider.json
13+
* always take precedence over this; it is a fallback only.
14+
*/
15+
16+
export type DetectedSupervisorCreds = {
17+
baseUrl: string;
18+
apiKey: string;
19+
model: string;
20+
source: string;
21+
};
22+
23+
/** Known OpenAI-compatible endpoints + a sensible default model per provider. */
24+
const PROVIDER_ENDPOINTS: Record<string, { baseUrl: string; model: string }> = {
25+
// OpenCode Zen gateway (the user's keyed Zen access).
26+
"opencode-go": { baseUrl: "https://opencode.ai/zen/v1", model: "deepseek-v4-flash" },
27+
// Google Gemini OpenAI-compatible endpoint.
28+
google: {
29+
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
30+
model: "gemini-2.5-flash",
31+
},
32+
// OpenAI (only used if stored as a static api key, not OAuth).
33+
openai: { baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini" },
34+
};
35+
36+
/** Order to prefer when several keyed providers are available. */
37+
const PROVIDER_PRIORITY = ["opencode-go", "google", "openai"];
38+
39+
function authFilePath(): string {
40+
const override = process.env.RALPH_OPENCODE_AUTH_PATH?.trim();
41+
if (override) return override;
42+
const base = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
43+
return path.join(base, "opencode", "auth.json");
44+
}
45+
46+
type AuthEntry = { type?: string; key?: string };
47+
48+
export async function detectOpencodeSupervisorCreds(): Promise<DetectedSupervisorCreds | null> {
49+
const file = authFilePath();
50+
if (!(await fileExists(file))) return null;
51+
52+
let auth: Record<string, AuthEntry>;
53+
try {
54+
auth = JSON.parse(await readTextFile(file)) as Record<string, AuthEntry>;
55+
} catch {
56+
return null;
57+
}
58+
59+
for (const provider of PROVIDER_PRIORITY) {
60+
const entry = auth[provider];
61+
const endpoint = PROVIDER_ENDPOINTS[provider];
62+
if (!entry || !endpoint) continue;
63+
if (entry.type !== "api") continue; // only static API keys
64+
const key = entry.key?.trim();
65+
if (!key) continue;
66+
return {
67+
baseUrl: endpoint.baseUrl,
68+
apiKey: key,
69+
model: endpoint.model,
70+
source: `opencode-auth:${provider}`,
71+
};
72+
}
73+
74+
return null;
75+
}

packages/provider/server/lib/supervisor-agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const SUPERVISOR_SYSTEM_PROMPT = [
7171
"## Models (how to change them)",
7272
"- Two models: the **supervisor** (you — this orchestration LLM) and the **worker** (what spawned sessions code with). You cannot change either yourself; tell the user where to set them.",
7373
"- Supervisor model: env `RALPH_SUPERVISOR_MODEL` (+ `RALPH_SUPERVISOR_API_KEY`, `RALPH_SUPERVISOR_BASE_URL`) on the provider process, or `.opencode/ralph-provider.json` `supervisor.modelID` / `baseUrl`. Restart the provider to apply.",
74+
"- If no supervisor key is set, the provider auto-detects one from the user's OpenCode auth (a keyed provider they've already authenticated, e.g. via `opencode auth login`). So 'I have no API key' usually means: authenticate a provider in OpenCode, or set RALPH_SUPERVISOR_API_KEY.",
7475
"- Worker model: `.opencode/ralph-provider.json` `worker.providerID` + `worker.modelID` (both needed), or env `RALPH_WORKER_PROVIDER_ID` + `RALPH_WORKER_MODEL_ID`. Applies on the next attempt. If unset, workers use OpenCode's default model.",
7576
"- Env vars override the file. Suggest models the user already has in OpenCode.",
7677
].join("\n");

packages/provider/server/lib/supervisor-config.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import path from "node:path";
22
import { readTextFile, fileExists } from "@doeixd/opencode-ralph-rlm-engine";
3+
import { detectOpencodeSupervisorCreds } from "./opencode-auth.js";
34

45
export type SupervisorLlmConfig = {
56
baseUrl: string;
67
apiKey: string;
78
model: string;
89
maxToolRounds: number;
10+
/** Where the credentials came from: "env" | "file" | "opencode-auth:<provider>" | "default". */
11+
source: string;
912
};
1013

1114
export type ProviderConfigFile = {
@@ -31,22 +34,37 @@ export async function loadSupervisorLlmConfig(
3134
): Promise<SupervisorLlmConfig> {
3235
const fileConfig = await readProviderConfigFile(worktree);
3336

37+
const envKey = process.env.RALPH_SUPERVISOR_API_KEY?.trim();
38+
const fileKey = fileConfig.supervisor?.apiKey?.trim();
39+
40+
// If no key is configured via env or ralph-provider.json, fall back to
41+
// OpenCode's own auth (a keyed provider you've already authenticated), so the
42+
// supervisor works without a separate RALPH_SUPERVISOR_API_KEY.
43+
const auto = !envKey && !fileKey ? await detectOpencodeSupervisorCreds() : null;
44+
3445
const baseUrl =
3546
process.env.RALPH_SUPERVISOR_BASE_URL?.trim() ||
3647
fileConfig.supervisor?.baseUrl?.trim() ||
48+
auto?.baseUrl ||
3749
DEFAULT_BASE_URL;
3850

39-
const apiKey =
40-
process.env.RALPH_SUPERVISOR_API_KEY?.trim() ||
41-
fileConfig.supervisor?.apiKey?.trim() ||
42-
"";
51+
const apiKey = envKey || fileKey || auto?.apiKey || "";
4352

4453
const model =
4554
process.env.RALPH_SUPERVISOR_MODEL?.trim() ||
4655
fileConfig.supervisor?.modelID?.trim() ||
4756
fileConfig.supervisor?.model?.trim() ||
57+
auto?.model ||
4858
DEFAULT_MODEL;
4959

60+
const source = envKey
61+
? "env"
62+
: fileKey
63+
? "file"
64+
: auto
65+
? auto.source
66+
: "default";
67+
5068
const maxToolRounds = toBoundedInt(
5169
fileConfig.supervisor?.maxToolRounds ??
5270
Number(process.env.RALPH_SUPERVISOR_MAX_TOOL_ROUNDS),
@@ -55,7 +73,7 @@ export async function loadSupervisorLlmConfig(
5573
24
5674
);
5775

58-
return { baseUrl: baseUrl.replace(/\/$/, ""), apiKey, model, maxToolRounds };
76+
return { baseUrl: baseUrl.replace(/\/$/, ""), apiKey, model, maxToolRounds, source };
5977
}
6078

6179
async function readProviderConfigFile(worktree: string): Promise<ProviderConfigFile> {
Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
import { defineHandler } from "nitro/h3";
22
import { getOpencodeRuntime } from "../../lib/runtime.js";
3+
import { isTestMode, loadSupervisorLlmConfig } from "../../lib/supervisor-config.js";
34

4-
/** OpenAPI: GET /api/health — provider + OpenCode connectivity */
5+
/** OpenAPI: GET /api/health — provider + OpenCode connectivity + supervisor readiness */
56
export default defineHandler(async () => {
67
const runtime = getOpencodeRuntime();
7-
const opencode = await runtime.health();
8+
const worktree = process.env.RALPH_WORKTREE?.trim() || process.cwd();
9+
const [opencode, supervisor] = await Promise.all([
10+
runtime.health(),
11+
loadSupervisorLlmConfig(worktree),
12+
]);
13+
14+
const supervisorReady = supervisor.apiKey.length > 0 || isTestMode();
815

916
return {
1017
healthy: true,
1118
provider: "@doeixd/opencode-ralph-rlm",
12-
version: "0.2.0",
19+
version: "0.3.0",
1320
opencode: {
1421
baseUrl: runtime.baseUrl,
1522
...opencode,
1623
},
24+
supervisor: {
25+
ready: supervisorReady,
26+
model: supervisor.model,
27+
baseUrl: supervisor.baseUrl,
28+
source: supervisor.source,
29+
...(supervisorReady
30+
? {}
31+
: {
32+
hint: "No supervisor API key found (env RALPH_SUPERVISOR_API_KEY, .opencode/ralph-provider.json, or OpenCode auth). Authenticate a provider in OpenCode or set a key before delegating goals.",
33+
}),
34+
},
1735
};
1836
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import path from "node:path";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { describe, expect, test, afterEach } from "bun:test";
5+
import { detectOpencodeSupervisorCreds } from "../lib/opencode-auth.js";
6+
import { loadSupervisorLlmConfig } from "../lib/supervisor-config.js";
7+
8+
const ENV_KEYS = [
9+
"RALPH_OPENCODE_AUTH_PATH",
10+
"RALPH_SUPERVISOR_API_KEY",
11+
"RALPH_SUPERVISOR_MODEL",
12+
"RALPH_SUPERVISOR_BASE_URL",
13+
] as const;
14+
15+
async function writeAuth(contents: unknown): Promise<{ dir: string; authPath: string }> {
16+
const dir = await mkdtemp(path.join(tmpdir(), "ralph-auth-"));
17+
const authPath = path.join(dir, "auth.json");
18+
await writeFile(authPath, JSON.stringify(contents), "utf8");
19+
return { dir, authPath };
20+
}
21+
22+
describe("detectOpencodeSupervisorCreds", () => {
23+
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
24+
afterEach(() => {
25+
for (const k of ENV_KEYS) {
26+
if (saved[k] === undefined) delete process.env[k];
27+
else process.env[k] = saved[k];
28+
}
29+
});
30+
31+
test("uses a keyed provider (opencode-go) and maps the Zen endpoint", async () => {
32+
const { dir, authPath } = await writeAuth({
33+
anthropic: { type: "oauth", access: "x" },
34+
"opencode-go": { type: "api", key: "zen-key-123" },
35+
});
36+
try {
37+
process.env.RALPH_OPENCODE_AUTH_PATH = authPath;
38+
const creds = await detectOpencodeSupervisorCreds();
39+
expect(creds?.apiKey).toBe("zen-key-123");
40+
expect(creds?.baseUrl).toBe("https://opencode.ai/zen/v1");
41+
expect(creds?.source).toBe("opencode-auth:opencode-go");
42+
} finally {
43+
await rm(dir, { recursive: true, force: true });
44+
}
45+
});
46+
47+
test("skips OAuth-only providers", async () => {
48+
const { dir, authPath } = await writeAuth({
49+
openai: { type: "oauth", access: "x" },
50+
anthropic: { type: "oauth", access: "y" },
51+
});
52+
try {
53+
process.env.RALPH_OPENCODE_AUTH_PATH = authPath;
54+
expect(await detectOpencodeSupervisorCreds()).toBeNull();
55+
} finally {
56+
await rm(dir, { recursive: true, force: true });
57+
}
58+
});
59+
60+
test("returns null when no auth file exists", async () => {
61+
process.env.RALPH_OPENCODE_AUTH_PATH = path.join(tmpdir(), "does-not-exist-xyz", "auth.json");
62+
expect(await detectOpencodeSupervisorCreds()).toBeNull();
63+
});
64+
65+
test("loadSupervisorLlmConfig falls back to OpenCode auth when no key is set", async () => {
66+
const { dir, authPath } = await writeAuth({
67+
google: { type: "api", key: "g-key" },
68+
});
69+
const worktree = await mkdtemp(path.join(tmpdir(), "ralph-wt-"));
70+
try {
71+
delete process.env.RALPH_SUPERVISOR_API_KEY;
72+
delete process.env.RALPH_SUPERVISOR_MODEL;
73+
delete process.env.RALPH_SUPERVISOR_BASE_URL;
74+
process.env.RALPH_OPENCODE_AUTH_PATH = authPath;
75+
76+
const cfg = await loadSupervisorLlmConfig(worktree);
77+
expect(cfg.apiKey).toBe("g-key");
78+
expect(cfg.source).toBe("opencode-auth:google");
79+
expect(cfg.model).toBe("gemini-2.5-flash");
80+
} finally {
81+
await rm(dir, { recursive: true, force: true });
82+
await rm(worktree, { recursive: true, force: true });
83+
}
84+
});
85+
86+
test("env key wins over auto-detect", async () => {
87+
const { dir, authPath } = await writeAuth({ google: { type: "api", key: "g-key" } });
88+
const worktree = await mkdtemp(path.join(tmpdir(), "ralph-wt-"));
89+
try {
90+
process.env.RALPH_OPENCODE_AUTH_PATH = authPath;
91+
process.env.RALPH_SUPERVISOR_API_KEY = "my-explicit-key";
92+
const cfg = await loadSupervisorLlmConfig(worktree);
93+
expect(cfg.apiKey).toBe("my-explicit-key");
94+
expect(cfg.source).toBe("env");
95+
} finally {
96+
await rm(dir, { recursive: true, force: true });
97+
await rm(worktree, { recursive: true, force: true });
98+
}
99+
});
100+
});

0 commit comments

Comments
 (0)