Skip to content

Commit 0585558

Browse files
committed
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.
1 parent c4ffd93 commit 0585558

File tree

13 files changed

+274
-26
lines changed

13 files changed

+274
-26
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { text } from "node:stream/consumers"
2+
import { Auth } from "."
3+
import { Process } from "../util/process"
4+
import { Log } from "../util/log"
5+
6+
export namespace WellknownAuth {
7+
const log = Log.create({ service: "auth.wellknown" })
8+
9+
export async function login(url: string) {
10+
const response = await fetch(`${url}/.well-known/opencode`)
11+
if (!response.ok) throw new Error(`failed to fetch well-known from ${url}: ${response.status}`)
12+
13+
const wellknown = (await response.json()) as any
14+
if (!wellknown?.auth?.command) throw new Error(`no auth command in well-known from ${url}`)
15+
16+
log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
17+
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe" })
18+
if (!proc.stdout) throw new Error(`failed to spawn auth command for ${url}`)
19+
20+
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
21+
if (exit !== 0) throw new Error(`auth command failed for ${url} (exit ${exit})`)
22+
23+
await Auth.set(url, {
24+
type: "wellknown",
25+
key: wellknown.auth.env,
26+
token: token.trim(),
27+
})
28+
}
29+
30+
export async function refreshAll() {
31+
const auth = await Auth.all()
32+
for (const [url, entry] of Object.entries(auth)) {
33+
if (entry.type !== "wellknown") continue
34+
try {
35+
await login(url)
36+
log.info("refreshed wellknown auth", { url })
37+
} catch (e) {
38+
log.warn("failed to refresh wellknown auth", { url, error: e })
39+
}
40+
}
41+
}
42+
}

packages/opencode/src/cli/cmd/auth.ts

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import { Global } from "../../global"
1111
import { Plugin } from "../../plugin"
1212
import { Instance } from "../../project/instance"
1313
import type { Hooks } from "@opencode-ai/plugin"
14-
import { Process } from "../../util/process"
15-
import { text } from "node:stream/consumers"
14+
import { WellknownAuth } from "../../auth/wellknown"
1615

1716
type PluginAuth = NonNullable<Hooks["auth"]>
1817

@@ -264,28 +263,12 @@ export const AuthLoginCommand = cmd({
264263
prompts.intro("Add credential")
265264
if (args.url) {
266265
const url = args.url.replace(/\/+$/, "")
267-
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
268-
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
269-
const proc = Process.spawn(wellknown.auth.command, {
270-
stdout: "pipe",
271-
})
272-
if (!proc.stdout) {
273-
prompts.log.error("Failed")
274-
prompts.outro("Done")
275-
return
276-
}
277-
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
278-
if (exit !== 0) {
279-
prompts.log.error("Failed")
280-
prompts.outro("Done")
281-
return
266+
try {
267+
await WellknownAuth.login(url)
268+
prompts.log.success("Logged into " + url)
269+
} catch (e: any) {
270+
prompts.log.error(e.message ?? "Failed")
282271
}
283-
await Auth.set(url, {
284-
type: "wellknown",
285-
key: wellknown.auth.env,
286-
token: token.trim(),
287-
})
288-
prompts.log.success("Logged into " + url)
289272
prompts.outro("Done")
290273
return
291274
}

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,20 @@ function App() {
512512
},
513513
category: "Provider",
514514
},
515+
{
516+
title: "Reload provider state",
517+
value: "provider.reload",
518+
slash: {
519+
name: "reload",
520+
aliases: ["refresh"],
521+
},
522+
onSelect: async (dialog) => {
523+
dialog.clear()
524+
await sdk.client.provider.reload()
525+
await sync.bootstrap()
526+
},
527+
category: "Provider",
528+
},
515529
{
516530
title: "View status",
517531
keybind: "status_view",

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,6 +1279,10 @@ export namespace Config {
12791279
}),
12801280
)
12811281

1282+
export async function reset() {
1283+
await state.reset()
1284+
}
1285+
12821286
export async function get() {
12831287
return state().then((x) => x.config)
12841288
}

packages/opencode/src/project/instance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const Instance = {
6363
if (Instance.worktree === "/") return false
6464
return Filesystem.contains(Instance.worktree, filepath)
6565
},
66-
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
66+
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
6767
return State.create(() => Instance.directory, init, dispose)
6868
},
6969
async dispose() {

packages/opencode/src/project/state.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export namespace State {
1010
const recordsByKey = new Map<string, Map<any, Entry>>()
1111

1212
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
13-
return () => {
13+
const result = () => {
1414
const key = root()
1515
let entries = recordsByKey.get(key)
1616
if (!entries) {
@@ -26,6 +26,20 @@ export namespace State {
2626
})
2727
return state
2828
}
29+
result.reset = async () => {
30+
const key = root()
31+
const entries = recordsByKey.get(key)
32+
if (!entries) return
33+
const entry = entries.get(init)
34+
if (!entry) return
35+
if (entry.dispose) {
36+
await Promise.resolve(entry.state)
37+
.then((s) => entry.dispose!(s))
38+
.catch(() => {})
39+
}
40+
entries.delete(init)
41+
}
42+
return result
2943
}
3044

3145
export async function dispose(key: string) {

packages/opencode/src/provider/provider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Plugin } from "../plugin"
1010
import { ModelsDev } from "./models"
1111
import { NamedError } from "@opencode-ai/util/error"
1212
import { Auth } from "../auth"
13+
import { WellknownAuth } from "../auth/wellknown"
1314
import { Env } from "../env"
1415
import { Instance } from "../project/instance"
1516
import { Flag } from "../flag/flag"
@@ -1055,6 +1056,13 @@ export namespace Provider {
10551056
}
10561057
})
10571058

1059+
export async function reload() {
1060+
log.info("reloading provider state")
1061+
await WellknownAuth.refreshAll()
1062+
await Config.reset()
1063+
await state.reset()
1064+
}
1065+
10581066
export async function list() {
10591067
return state().then((state) => state.providers)
10601068
}

packages/opencode/src/server/routes/provider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,5 +161,27 @@ export const ProviderRoutes = lazy(() =>
161161
})
162162
return c.json(true)
163163
},
164+
)
165+
.post(
166+
"/reload",
167+
describeRoute({
168+
summary: "Reload providers",
169+
description: "Reload provider auth state, picking up any changes to auth credentials without restarting.",
170+
operationId: "provider.reload",
171+
responses: {
172+
200: {
173+
description: "Providers reloaded",
174+
content: {
175+
"application/json": {
176+
schema: resolver(z.boolean()),
177+
},
178+
},
179+
},
180+
},
181+
}),
182+
async (c) => {
183+
await Provider.reload()
184+
return c.json(true)
185+
},
164186
),
165187
)

packages/opencode/src/session/processor.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Bus } from "@/bus"
99
import { SessionRetry } from "./retry"
1010
import { SessionStatus } from "./status"
1111
import { Plugin } from "@/plugin"
12-
import type { Provider } from "@/provider/provider"
12+
import { Provider } from "@/provider/provider"
1313
import { LLM } from "./llm"
1414
import { Config } from "@/config/config"
1515
import { SessionCompaction } from "./compaction"
@@ -34,6 +34,7 @@ export namespace SessionProcessor {
3434
let blocked = false
3535
let attempt = 0
3636
let needsCompaction = false
37+
let reloaded = false
3738

3839
const result = {
3940
get message() {
@@ -362,6 +363,18 @@ export namespace SessionProcessor {
362363
sessionID: input.sessionID,
363364
error,
364365
})
366+
} else if (MessageV2.APIError.isInstance(error) && error.data.statusCode === 401 && !reloaded) {
367+
reloaded = true
368+
attempt++
369+
log.info("reloading provider state after 401", { providerID: input.model.providerID })
370+
await Provider.reload()
371+
SessionStatus.set(input.sessionID, {
372+
type: "retry",
373+
attempt,
374+
message: "Reloading provider auth state",
375+
next: Date.now(),
376+
})
377+
continue
365378
} else {
366379
const retry = SessionRetry.retryable(error)
367380
if (retry !== undefined) {

packages/opencode/test/provider/provider.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture"
55
import { Instance } from "../../src/project/instance"
66
import { Provider } from "../../src/provider/provider"
77
import { Env } from "../../src/env"
8+
import { Auth } from "../../src/auth"
89

910
test("provider loaded from env variable", async () => {
1011
await using tmp = await tmpdir({
@@ -60,6 +61,42 @@ test("provider loaded from config with apiKey option", async () => {
6061
})
6162
})
6263

64+
test("provider.reload picks up api auth changes", async () => {
65+
await using tmp = await tmpdir({
66+
init: async (dir) => {
67+
await Bun.write(
68+
path.join(dir, "opencode.json"),
69+
JSON.stringify({
70+
$schema: "https://opencode.ai/config.json",
71+
}),
72+
)
73+
},
74+
})
75+
76+
await Instance.provide({
77+
directory: tmp.path,
78+
fn: async () => {
79+
const first = await Provider.list()
80+
expect(first["anthropic"]).toBeUndefined()
81+
82+
await Auth.set("anthropic", {
83+
type: "api",
84+
key: "test-api-key",
85+
})
86+
87+
const stale = await Provider.list()
88+
expect(stale["anthropic"]).toBeUndefined()
89+
90+
await Provider.reload()
91+
92+
const updated = await Provider.list()
93+
expect(updated["anthropic"]).toBeDefined()
94+
expect(updated["anthropic"].source).toBe("api")
95+
expect(updated["anthropic"].key).toBe("test-api-key")
96+
},
97+
})
98+
})
99+
63100
test("disabled_providers excludes provider", async () => {
64101
await using tmp = await tmpdir({
65102
init: async (dir) => {

0 commit comments

Comments
 (0)