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?: { /**