Skip to content

Commit 4ccb82e

Browse files
feat: surface plugin auth providers in the login picker (anomalyco#13921)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 9291221 commit 4ccb82e

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
159159
return false
160160
}
161161

162+
/**
163+
* Build a deduplicated list of plugin-registered auth providers that are not
164+
* already present in models.dev, respecting enabled/disabled provider lists.
165+
* Pure function with no side effects; safe to test without mocking.
166+
*/
167+
export function resolvePluginProviders(input: {
168+
hooks: Hooks[]
169+
existingProviders: Record<string, unknown>
170+
disabled: Set<string>
171+
enabled?: Set<string>
172+
providerNames: Record<string, string | undefined>
173+
}): Array<{ id: string; name: string }> {
174+
const seen = new Set<string>()
175+
const result: Array<{ id: string; name: string }> = []
176+
177+
for (const hook of input.hooks) {
178+
if (!hook.auth) continue
179+
const id = hook.auth.provider
180+
if (seen.has(id)) continue
181+
seen.add(id)
182+
if (Object.hasOwn(input.existingProviders, id)) continue
183+
if (input.disabled.has(id)) continue
184+
if (input.enabled && !input.enabled.has(id)) continue
185+
result.push({
186+
id,
187+
name: input.providerNames[id] ?? id,
188+
})
189+
}
190+
191+
return result
192+
}
193+
162194
export const AuthCommand = cmd({
163195
command: "auth",
164196
describe: "manage credentials",
@@ -277,6 +309,15 @@ export const AuthLoginCommand = cmd({
277309
openrouter: 5,
278310
vercel: 6,
279311
}
312+
const pluginProviders = resolvePluginProviders({
313+
hooks: await Plugin.list(),
314+
existingProviders: providers,
315+
disabled,
316+
enabled,
317+
providerNames: Object.fromEntries(
318+
Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name]),
319+
),
320+
})
280321
let provider = await prompts.autocomplete({
281322
message: "Select provider",
282323
maxItems: 8,
@@ -298,6 +339,11 @@ export const AuthLoginCommand = cmd({
298339
}[x.id],
299340
})),
300341
),
342+
...pluginProviders.map((x) => ({
343+
label: x.name,
344+
value: x.id,
345+
hint: "plugin",
346+
})),
301347
{
302348
value: "other",
303349
label: "Other",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { test, expect, describe } from "bun:test"
2+
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
3+
import type { Hooks } from "@opencode-ai/plugin"
4+
5+
function hookWithAuth(provider: string): Hooks {
6+
return {
7+
auth: {
8+
provider,
9+
methods: [],
10+
},
11+
}
12+
}
13+
14+
function hookWithoutAuth(): Hooks {
15+
return {}
16+
}
17+
18+
describe("resolvePluginProviders", () => {
19+
test("returns plugin providers not in models.dev", () => {
20+
const result = resolvePluginProviders({
21+
hooks: [hookWithAuth("portkey")],
22+
existingProviders: {},
23+
disabled: new Set(),
24+
providerNames: {},
25+
})
26+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
27+
})
28+
29+
test("skips providers already in models.dev", () => {
30+
const result = resolvePluginProviders({
31+
hooks: [hookWithAuth("anthropic")],
32+
existingProviders: { anthropic: {} },
33+
disabled: new Set(),
34+
providerNames: {},
35+
})
36+
expect(result).toEqual([])
37+
})
38+
39+
test("deduplicates across plugins", () => {
40+
const result = resolvePluginProviders({
41+
hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")],
42+
existingProviders: {},
43+
disabled: new Set(),
44+
providerNames: {},
45+
})
46+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
47+
})
48+
49+
test("respects disabled_providers", () => {
50+
const result = resolvePluginProviders({
51+
hooks: [hookWithAuth("portkey")],
52+
existingProviders: {},
53+
disabled: new Set(["portkey"]),
54+
providerNames: {},
55+
})
56+
expect(result).toEqual([])
57+
})
58+
59+
test("respects enabled_providers when provider is absent", () => {
60+
const result = resolvePluginProviders({
61+
hooks: [hookWithAuth("portkey")],
62+
existingProviders: {},
63+
disabled: new Set(),
64+
enabled: new Set(["anthropic"]),
65+
providerNames: {},
66+
})
67+
expect(result).toEqual([])
68+
})
69+
70+
test("includes provider when in enabled set", () => {
71+
const result = resolvePluginProviders({
72+
hooks: [hookWithAuth("portkey")],
73+
existingProviders: {},
74+
disabled: new Set(),
75+
enabled: new Set(["portkey"]),
76+
providerNames: {},
77+
})
78+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
79+
})
80+
81+
test("resolves name from providerNames", () => {
82+
const result = resolvePluginProviders({
83+
hooks: [hookWithAuth("portkey")],
84+
existingProviders: {},
85+
disabled: new Set(),
86+
providerNames: { portkey: "Portkey AI" },
87+
})
88+
expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }])
89+
})
90+
91+
test("falls back to id when no name configured", () => {
92+
const result = resolvePluginProviders({
93+
hooks: [hookWithAuth("portkey")],
94+
existingProviders: {},
95+
disabled: new Set(),
96+
providerNames: {},
97+
})
98+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
99+
})
100+
101+
test("skips hooks without auth", () => {
102+
const result = resolvePluginProviders({
103+
hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()],
104+
existingProviders: {},
105+
disabled: new Set(),
106+
providerNames: {},
107+
})
108+
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
109+
})
110+
111+
test("returns empty for no hooks", () => {
112+
const result = resolvePluginProviders({
113+
hooks: [],
114+
existingProviders: {},
115+
disabled: new Set(),
116+
providerNames: {},
117+
})
118+
expect(result).toEqual([])
119+
})
120+
})

0 commit comments

Comments
 (0)