From 05855581bf2a89d2b03f2a517688eaba5207f34a Mon Sep 17 00:00:00 2001 From: Andrew Berglund Date: Sat, 28 Feb 2026 01:00:44 -0600 Subject: [PATCH] feat(opencode): auto-reload provider auth on 401 and add /reload command Provider auth changes (API keys, wellknown tokens) are now picked up without restarting. On 401, the session processor automatically refreshes auth state and retries once. Users can also trigger this manually via /reload (or /refresh). Wellknown auth token refresh is shared between the CLI login flow and the automatic reload path. --- packages/opencode/src/auth/wellknown.ts | 42 ++++++++++ packages/opencode/src/cli/cmd/auth.ts | 29 ++----- packages/opencode/src/cli/cmd/tui/app.tsx | 14 ++++ packages/opencode/src/config/config.ts | 4 + packages/opencode/src/project/instance.ts | 2 +- packages/opencode/src/project/state.ts | 16 +++- packages/opencode/src/provider/provider.ts | 8 ++ .../opencode/src/server/routes/provider.ts | 22 +++++ packages/opencode/src/session/processor.ts | 15 +++- .../opencode/test/provider/provider.test.ts | 37 +++++++++ packages/opencode/test/util/state.test.ts | 82 +++++++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 9 ++ 13 files changed, 274 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/auth/wellknown.ts create mode 100644 packages/opencode/test/util/state.test.ts diff --git a/packages/opencode/src/auth/wellknown.ts b/packages/opencode/src/auth/wellknown.ts new file mode 100644 index 00000000000..9a46198b9e7 --- /dev/null +++ b/packages/opencode/src/auth/wellknown.ts @@ -0,0 +1,42 @@ +import { text } from "node:stream/consumers" +import { Auth } from "." +import { Process } from "../util/process" +import { Log } from "../util/log" + +export namespace WellknownAuth { + const log = Log.create({ service: "auth.wellknown" }) + + export async function login(url: string) { + const response = await fetch(`${url}/.well-known/opencode`) + if (!response.ok) throw new Error(`failed to fetch well-known from ${url}: ${response.status}`) + + const wellknown = (await response.json()) as any + if (!wellknown?.auth?.command) throw new Error(`no auth command in well-known from ${url}`) + + log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe" }) + if (!proc.stdout) throw new Error(`failed to spawn auth command for ${url}`) + + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) throw new Error(`auth command failed for ${url} (exit ${exit})`) + + await Auth.set(url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), + }) + } + + export async function refreshAll() { + const auth = await Auth.all() + for (const [url, entry] of Object.entries(auth)) { + if (entry.type !== "wellknown") continue + try { + await login(url) + log.info("refreshed wellknown auth", { url }) + } catch (e) { + log.warn("failed to refresh wellknown auth", { url, error: e }) + } + } + } +} diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 4afe7a8224a..7f76849b167 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -11,8 +11,7 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" -import { Process } from "../../util/process" -import { text } from "node:stream/consumers" +import { WellknownAuth } from "../../auth/wellknown" type PluginAuth = NonNullable @@ -264,28 +263,12 @@ export const AuthLoginCommand = cmd({ prompts.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return + try { + await WellknownAuth.login(url) + prompts.log.success("Logged into " + url) + } catch (e: any) { + prompts.log.error(e.message ?? "Failed") } - await Auth.set(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) prompts.outro("Done") return } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 97c910a47d4..d0a863944af 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -512,6 +512,20 @@ function App() { }, category: "Provider", }, + { + title: "Reload provider state", + value: "provider.reload", + slash: { + name: "reload", + aliases: ["refresh"], + }, + onSelect: async (dialog) => { + dialog.clear() + await sdk.client.provider.reload() + await sync.bootstrap() + }, + category: "Provider", + }, { title: "View status", keybind: "status_view", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28c5b239a41..175daa02574 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1279,6 +1279,10 @@ export namespace Config { }), ) + export async function reset() { + await state.reset() + } + export async function get() { return state().then((x) => x.config) } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f..5ea936c07cc 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -63,7 +63,7 @@ export const Instance = { if (Instance.worktree === "/") return false return Filesystem.contains(Instance.worktree, filepath) }, - state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { + state(init: () => S, dispose?: (state: Awaited) => Promise) { return State.create(() => Instance.directory, init, dispose) }, async dispose() { diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5e..e006a960fc2 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -10,7 +10,7 @@ export namespace State { const recordsByKey = new Map>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { - return () => { + const result = () => { const key = root() let entries = recordsByKey.get(key) if (!entries) { @@ -26,6 +26,20 @@ export namespace State { }) return state } + result.reset = async () => { + const key = root() + const entries = recordsByKey.get(key) + if (!entries) return + const entry = entries.get(init) + if (!entry) return + if (entry.dispose) { + await Promise.resolve(entry.state) + .then((s) => entry.dispose!(s)) + .catch(() => {}) + } + entries.delete(init) + } + return result } export async function dispose(key: string) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 81703836524..82fef96e99f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -10,6 +10,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" +import { WellknownAuth } from "../auth/wellknown" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -1055,6 +1056,13 @@ export namespace Provider { } }) + export async function reload() { + log.info("reloading provider state") + await WellknownAuth.refreshAll() + await Config.reset() + await state.reset() + } + export async function list() { return state().then((state) => state.providers) } diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 872b48be79d..fb187110a5a 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -161,5 +161,27 @@ export const ProviderRoutes = lazy(() => }) return c.json(true) }, + ) + .post( + "/reload", + describeRoute({ + summary: "Reload providers", + description: "Reload provider auth state, picking up any changes to auth credentials without restarting.", + operationId: "provider.reload", + responses: { + 200: { + description: "Providers reloaded", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Provider.reload() + return c.json(true) + }, ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe3..1e5d011bde6 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -9,7 +9,7 @@ import { Bus } from "@/bus" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" import { LLM } from "./llm" import { Config } from "@/config/config" import { SessionCompaction } from "./compaction" @@ -34,6 +34,7 @@ export namespace SessionProcessor { let blocked = false let attempt = 0 let needsCompaction = false + let reloaded = false const result = { get message() { @@ -362,6 +363,18 @@ export namespace SessionProcessor { sessionID: input.sessionID, error, }) + } else if (MessageV2.APIError.isInstance(error) && error.data.statusCode === 401 && !reloaded) { + reloaded = true + attempt++ + log.info("reloading provider state after 401", { providerID: input.model.providerID }) + await Provider.reload() + SessionStatus.set(input.sessionID, { + type: "retry", + attempt, + message: "Reloading provider auth state", + next: Date.now(), + }) + continue } else { const retry = SessionRetry.retryable(error) if (retry !== undefined) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 11c943db6f8..b6f28b096ac 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { Env } from "../../src/env" +import { Auth } from "../../src/auth" test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ @@ -60,6 +61,42 @@ test("provider loaded from config with apiKey option", async () => { }) }) +test("provider.reload picks up api auth changes", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const first = await Provider.list() + expect(first["anthropic"]).toBeUndefined() + + await Auth.set("anthropic", { + type: "api", + key: "test-api-key", + }) + + const stale = await Provider.list() + expect(stale["anthropic"]).toBeUndefined() + + await Provider.reload() + + const updated = await Provider.list() + expect(updated["anthropic"]).toBeDefined() + expect(updated["anthropic"].source).toBe("api") + expect(updated["anthropic"].key).toBe("test-api-key") + }, + }) +}) + test("disabled_providers excludes provider", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/util/state.test.ts b/packages/opencode/test/util/state.test.ts new file mode 100644 index 00000000000..7107fd3b610 --- /dev/null +++ b/packages/opencode/test/util/state.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { State } from "../../src/project/state" + +describe("project.state", () => { + test("reset clears cached state", async () => { + let calls = 0 + const key = `state-reset-${Math.random()}` + const state = State.create( + () => key, + () => { + calls++ + return calls + }, + ) + + expect(state()).toBe(1) + expect(state()).toBe(1) + expect(calls).toBe(1) + + await state.reset() + + expect(state()).toBe(2) + expect(calls).toBe(2) + }) + + test("reset preserves other state entries for same key", async () => { + let a = 0 + let b = 0 + const key = `state-shared-${Math.random()}` + const first = State.create( + () => key, + () => { + a++ + return a + }, + ) + const second = State.create( + () => key, + () => { + b++ + return b + }, + ) + + expect(first()).toBe(1) + expect(second()).toBe(1) + + await first.reset() + + expect(first()).toBe(2) + expect(second()).toBe(1) + }) + + test("reset runs dispose handler", async () => { + let disposed = 0 + const key = `state-dispose-${Math.random()}` + const state = State.create( + () => key, + () => ({ value: 1 }), + async () => { + disposed++ + }, + ) + + state() + await state.reset() + + expect(disposed).toBe(1) + }) + + test("reset is no-op when state has not been initialized", async () => { + const key = `state-noop-${Math.random()}` + const state = State.create( + () => key, + () => 1, + ) + + await state.reset() + + expect(state()).toBe(1) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 49ebc847345..4fba3825ff9 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -86,6 +86,7 @@ import type { ProviderOauthAuthorizeResponses, ProviderOauthCallbackErrors, ProviderOauthCallbackResponses, + ProviderReloadResponses, PtyConnectErrors, PtyConnectResponses, PtyCreateErrors, @@ -2601,6 +2602,25 @@ export class Provider extends HeyApiClient { }) } + /** + * Reload providers + * + * Reload provider auth state, picking up any changes to auth credentials without restarting. + */ + public reload( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).post({ + url: "/provider/reload", + ...options, + ...params, + }) + } + private _oauth?: Oauth get oauth(): Oauth { return (this._oauth ??= new Oauth({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 69d10561090..2aa64f934f7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4003,6 +4003,15 @@ export type ProviderAuthResponses = { export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type ProviderReloadResponses = { + /** + * Providers reloaded + */ + 200: boolean +} + +export type ProviderReloadResponse = ProviderReloadResponses[keyof ProviderReloadResponses] + export type ProviderOauthAuthorizeData = { body?: { /**