From c84694340af50ed353e6d13662301795e7c117e3 Mon Sep 17 00:00:00 2001 From: Andrew Berglund Date: Tue, 10 Mar 2026 17:45:20 -0500 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/providers.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 000000000000..9a46198b9e75 --- /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/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 53a9b1280e46..7779658f7bc4 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.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 @@ -274,28 +273,12 @@ export const ProvidersLoginCommand = 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 4f7c94b1d39c..db15278e3809 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -530,6 +530,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 2b8aa9e03010..fd0c00a1daa4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1310,6 +1310,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 df44a3a229c9..f6178a8005cd 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -100,7 +100,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 reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5eb..e006a960fc2d 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 c174ebd9ffa9..29c8980e79e4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -11,6 +11,7 @@ import { Plugin } from "../plugin" import { NamedError } from "@opencode-ai/util/error" import { ModelsDev } from "./models" import { Auth } from "../auth" +import { WellknownAuth } from "../auth/wellknown" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -1107,6 +1108,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 872b48be79dc..fb187110a5ab 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 67edc0ecfe35..1e5d011bde6b 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 4c6eaf8b2272..e856a2283625 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 000000000000..7107fd3b6103 --- /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 2bb2edcd1752..dc92deb042a5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -87,6 +87,7 @@ import type { ProviderOauthAuthorizeResponses, ProviderOauthCallbackErrors, ProviderOauthCallbackResponses, + ProviderReloadResponses, PtyConnectErrors, PtyConnectResponses, PtyCreateErrors, @@ -2633,6 +2634,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 a47b18db2192..d32179c597e9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4024,6 +4024,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?: { /**