Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/opencode/src/auth/wellknown.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
}
}
29 changes: 6 additions & 23 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hooks["auth"]>

Expand Down Expand Up @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,10 @@ export namespace Config {
}),
)

export async function reset() {
await state.reset()
}

export async function get() {
return state().then((x) => x.config)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const Instance = {
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
Expand Down
16 changes: 15 additions & 1 deletion packages/opencode/src/project/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export namespace State {
const recordsByKey = new Map<string, Map<any, Entry>>()

export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
return () => {
const result = () => {
const key = root()
let entries = recordsByKey.get(key)
if (!entries) {
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 22 additions & 0 deletions packages/opencode/src/server/routes/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
),
)
15 changes: 14 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,6 +34,7 @@ export namespace SessionProcessor {
let blocked = false
let attempt = 0
let needsCompaction = false
let reloaded = false

const result = {
get message() {
Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) => {
Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/test/util/state.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading