Skip to content

Commit caa39d8

Browse files
committed
fix: resolve auth from OpenCode auth.json for live provider switching
Read credentials from OpenCode's auth.json (~/.local/share/opencode/) as highest priority source, so /connect account switches are reflected immediately in HUD usage data. Cross-platform: XDG_DATA_HOME > LOCALAPPDATA (win32) > ~/.local/share Also fixes resolveProviderKey to trust providerID over modelID heuristics, and triggers forceRefresh on provider/model change in chat.params hook.
1 parent 5983de7 commit caa39d8

7 files changed

Lines changed: 391 additions & 11 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-status-hud",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "Real-time usage HUD plugin for OpenCode CLI — shows provider API utilization, context tokens, cost, and agent info",
55
"type": "module",
66
"main": "dist/src/index.js",

src/auth-resolver-openai.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { readFile } from "node:fs/promises"
1616
import { join } from "node:path"
1717
import { homedir } from "node:os"
1818
import type { ResolvedOpenAIAuthToken } from "./provider-usage.types.js"
19+
import { readOpenCodeAuth } from "./opencode-auth.js"
1920

2021
export interface ReadFileFn {
2122
(path: string, encoding: BufferEncoding): Promise<string>
@@ -110,5 +111,28 @@ export async function resolveOpenAIAuthToken(
110111
): Promise<ResolvedOpenAIAuthToken | null> {
111112
const opts: OpenAIAuthResolverOptions = options ?? {}
112113

114+
const ocEntry = await readOpenCodeAuth("openai", {
115+
readFileFn: opts.readFileFn,
116+
env: opts.env ?? undefined,
117+
})
118+
if (ocEntry !== null) {
119+
if (ocEntry.type === "oauth" && typeof ocEntry.access === "string" && ocEntry.access.length > 0) {
120+
return {
121+
accessToken: ocEntry.access,
122+
accountId: typeof ocEntry.accountId === "string" ? ocEntry.accountId : undefined,
123+
refreshToken: typeof ocEntry.refresh === "string" ? ocEntry.refresh : undefined,
124+
source: "opencode-auth",
125+
kind: "jwt",
126+
}
127+
}
128+
if (ocEntry.type === "api" && typeof ocEntry.key === "string" && ocEntry.key.length > 0) {
129+
return {
130+
accessToken: ocEntry.key,
131+
source: "opencode-auth",
132+
kind: "api-key",
133+
}
134+
}
135+
}
136+
113137
return resolveFromAuthJson(opts)
114138
}

src/auth-resolver.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { join } from "node:path"
1919
import { homedir } from "node:os"
2020
import { promisify } from "node:util"
2121
import type { ResolvedAuthToken, AuthTokenSource } from "./provider-usage.types.js"
22+
import { readOpenCodeAuth } from "./opencode-auth.js"
2223

2324
const execFileAsync = promisify(execFile)
2425

@@ -206,7 +207,20 @@ export async function resolveAuthToken(
206207
): Promise<ResolvedAuthToken | null> {
207208
const opts: AuthResolverOptions = options ?? {}
208209

209-
// Priority 1: macOS Keychain
210+
const ocEntry = await readOpenCodeAuth("anthropic", {
211+
readFileFn: opts.readFileFn,
212+
env: opts.env ?? undefined,
213+
platform: opts.platform ?? undefined,
214+
})
215+
if (ocEntry !== null) {
216+
if (ocEntry.type === "oauth" && typeof ocEntry.access === "string" && ocEntry.access.length > 0) {
217+
return { token: ocEntry.access, source: "opencode-auth", kind: "oauth" }
218+
}
219+
if (ocEntry.type === "api" && typeof ocEntry.key === "string" && ocEntry.key.length > 0) {
220+
return { token: ocEntry.key, source: "opencode-auth", kind: "oauth" }
221+
}
222+
}
223+
210224
const keychainResult = await resolveFromKeychain(opts)
211225
if (keychainResult !== null) {
212226
return {

src/opencode-auth.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { readFile } from "node:fs/promises"
2+
import { join } from "node:path"
3+
import { homedir } from "node:os"
4+
5+
export interface OpenCodeAuthEntry {
6+
type: "oauth" | "api" | "wellknown"
7+
access?: string | undefined
8+
refresh?: string | undefined
9+
expires?: number | undefined
10+
accountId?: string | undefined
11+
key?: string | undefined
12+
token?: string | undefined
13+
enterpriseUrl?: string | undefined
14+
}
15+
16+
export interface OpenCodeAuthReaderOptions {
17+
readFileFn?: ((path: string, encoding: BufferEncoding) => Promise<string>) | undefined
18+
env?: NodeJS.ProcessEnv | undefined
19+
platform?: string | undefined
20+
}
21+
22+
export function resolveOpenCodeDataDir(
23+
env?: NodeJS.ProcessEnv,
24+
platform?: string
25+
): string {
26+
const e = env ?? process.env
27+
const p = platform ?? process.platform
28+
29+
if (e.XDG_DATA_HOME) {
30+
return join(e.XDG_DATA_HOME, "opencode")
31+
}
32+
33+
if (p === "win32" && e.LOCALAPPDATA) {
34+
return join(e.LOCALAPPDATA, "opencode")
35+
}
36+
37+
return join(homedir(), ".local", "share", "opencode")
38+
}
39+
40+
export async function readOpenCodeAuth(
41+
providerKey: string,
42+
options?: OpenCodeAuthReaderOptions
43+
): Promise<OpenCodeAuthEntry | null> {
44+
const read = options?.readFileFn ?? readFile
45+
const dataDir = resolveOpenCodeDataDir(options?.env, options?.platform)
46+
const authJsonPath = join(dataDir, "auth.json")
47+
48+
let content: string
49+
try {
50+
content = await read(authJsonPath, "utf-8")
51+
} catch {
52+
return null
53+
}
54+
55+
let parsed: unknown
56+
try {
57+
parsed = JSON.parse(content)
58+
} catch {
59+
return null
60+
}
61+
62+
if (typeof parsed !== "object" || parsed === null) {
63+
return null
64+
}
65+
66+
const entry = (parsed as Record<string, unknown>)[providerKey]
67+
if (typeof entry !== "object" || entry === null) {
68+
return null
69+
}
70+
71+
const data = entry as Record<string, unknown>
72+
const entryType = data.type
73+
if (entryType !== "oauth" && entryType !== "api" && entryType !== "wellknown") {
74+
return null
75+
}
76+
77+
return {
78+
type: entryType,
79+
access: typeof data.access === "string" ? data.access : undefined,
80+
refresh: typeof data.refresh === "string" ? data.refresh : undefined,
81+
expires: typeof data.expires === "number" ? data.expires : undefined,
82+
accountId: typeof data.accountId === "string" ? data.accountId : undefined,
83+
key: typeof data.key === "string" ? data.key : undefined,
84+
token: typeof data.token === "string" ? data.token : undefined,
85+
enterpriseUrl: typeof data.enterpriseUrl === "string" ? data.enterpriseUrl : undefined,
86+
}
87+
}

src/plugin.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,27 @@ function parseUsagePromptIntervalMs(value: string | undefined): number | null {
170170
* Returns null if provider is unknown or doesn't have a usage API.
171171
*/
172172
function resolveProviderKey(providerID: string, modelID: string): ProviderKey | null {
173-
const lowerProvider = providerID.toLowerCase()
173+
const lowerProvider = providerID.trim().toLowerCase()
174+
175+
if (lowerProvider.length > 0) {
176+
if (lowerProvider.includes("anthropic")) {
177+
return "anthropic"
178+
}
179+
180+
if (lowerProvider.includes("openai")) {
181+
return "openai"
182+
}
183+
184+
return null
185+
}
186+
174187
const lowerModel = modelID.toLowerCase()
175188

176-
// Anthropic models
177-
if (lowerProvider.includes("anthropic") || lowerModel.includes("claude") || lowerModel.includes("opus") || lowerModel.includes("sonnet") || lowerModel.includes("haiku")) {
189+
if (lowerModel.includes("claude") || lowerModel.includes("opus") || lowerModel.includes("sonnet") || lowerModel.includes("haiku")) {
178190
return "anthropic"
179191
}
180192

181-
// OpenAI models
182-
if (lowerProvider.includes("openai") || lowerModel.includes("gpt") || lowerModel.includes("codex") || lowerModel.includes("o1") || lowerModel.includes("o3") || lowerModel.includes("o4")) {
193+
if (lowerModel.includes("gpt") || lowerModel.includes("codex") || lowerModel.includes("o1") || lowerModel.includes("o3") || lowerModel.includes("o4")) {
183194
return "openai"
184195
}
185196

@@ -522,6 +533,8 @@ export function createHudPluginHooks(
522533
const sessionRuntimes = new Map<string, SessionRuntime>()
523534
const sessionAgentMap = new Map<string, string>()
524535
let latestSessionKey: string | null = null
536+
let lastSeenProviderID: string | null = null
537+
let lastSeenModelID: string | null = null
525538

526539
function pruneMap<V>(map: Map<string, V>): void {
527540
if (map.size <= MAX_SESSION_ENTRIES) return
@@ -938,10 +951,25 @@ export function createHudPluginHooks(
938951
if (model.id && model.cost && model.limit) {
939952
registry.updateFromChatParams(model, provider)
940953
}
954+
955+
const incomingProviderID = provider.id ?? provider.info?.id ?? ""
956+
const incomingModelID = model.id ?? ""
957+
const providerChanged = incomingProviderID !== lastSeenProviderID
958+
const modelChanged = incomingModelID !== lastSeenModelID
959+
960+
if (providerChanged || modelChanged) {
961+
lastSeenProviderID = incomingProviderID
962+
lastSeenModelID = incomingModelID
963+
964+
const providerKey = resolveProviderKey(incomingProviderID, incomingModelID)
965+
if (providerKey === "anthropic") {
966+
void anthropicPoller.forceRefresh()
967+
} else if (providerKey === "openai") {
968+
void openaiPoller.forceRefresh()
969+
}
970+
}
941971
}
942-
} catch {
943-
// Silently ignore chat.params errors — registry is best-effort
944-
}
972+
} catch { }
945973
},
946974

947975
"experimental.text.complete": async (input, output) => {

src/provider-usage.types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface ProviderUsageSnapshot {
4747

4848
/** Where the auth token was found */
4949
export type AuthTokenSource =
50+
| "opencode-auth"
5051
| "keychain"
5152
| "credentials-file"
5253
| "env-oauth"
@@ -177,7 +178,7 @@ export interface OpenAIRateWindow {
177178
}
178179

179180
/** Where the OpenAI auth token was found */
180-
export type OpenAIAuthTokenSource = "codex-auth-file" | "env-openai-key"
181+
export type OpenAIAuthTokenSource = "opencode-auth" | "codex-auth-file" | "env-openai-key"
181182

182183
/** Resolved OpenAI auth token with metadata */
183184
export interface ResolvedOpenAIAuthToken {

0 commit comments

Comments
 (0)