Skip to content

Commit fd9c1ca

Browse files
committed
feat(auth): add support for multiple profiles per provider
Allow users to store multiple API keys for the same provider with named profiles (e.g., separate OpenRouter keys for different cost tracking). Changes: - Auth module: Added profile support to get/set/remove, plus profiles(), hasDefault(), setDefault() functions - CLI commands: login prompts for profile name when default exists, logout/list show profile names, new set-default command - Provider: parseModel() extracts profile from provider:profile/model format - Config: Added optional profile field to provider schema Key format: 'providerID' for default, 'providerID:profileName' for named. Usage: opencode auth login # first login creates default opencode auth login # second login prompts for profile name opencode auth list # shows all profiles grouped by provider opencode auth set-default # swap a named profile to be the default opencode auth logout # shows profiles in selection Model string format: openrouter:work/claude-sonnet-4 # uses 'work' profile openrouter/claude-sonnet-4 # uses default profile Closes #5391
1 parent 21f8184 commit fd9c1ca

5 files changed

Lines changed: 330 additions & 91 deletions

File tree

packages/core/src/v1/config/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const Info = Schema.Struct({
7979
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
8080
id: Schema.optional(Schema.String),
8181
npm: Schema.optional(Schema.String),
82+
profile: Schema.optional(Schema.String.annotate({ description: "Default auth profile to use for this provider" })),
8283
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
8384
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
8485
options: Schema.optional(

packages/opencode/src/auth/index.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
99

1010
const file = path.join(Global.Path.data, "auth.json")
1111

12+
const PROFILE_DELIMITER = ":"
13+
const PROFILE_REGEX = /^[a-zA-Z0-9_-]+$/
14+
15+
/** Parse composite key into provider and profile */
16+
export function parseKey(key: string): { providerID: string; profile?: string } {
17+
const idx = key.indexOf(PROFILE_DELIMITER)
18+
if (idx === -1) return { providerID: key }
19+
return {
20+
providerID: key.slice(0, idx),
21+
profile: key.slice(idx + 1),
22+
}
23+
}
24+
25+
/** Build composite key from provider and profile */
26+
export function buildKey(providerID: string, profile?: string): string {
27+
if (!profile) return providerID
28+
return `${providerID}${PROFILE_DELIMITER}${profile}`
29+
}
30+
31+
/** Validate profile name (alphanumeric, hyphen, underscore) */
32+
export function validateProfileName(name: string): boolean {
33+
return PROFILE_REGEX.test(name)
34+
}
35+
1236
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
1337

1438
export class Oauth extends Schema.Class<Oauth>("OAuth")({
@@ -41,10 +65,13 @@ export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError",
4165
}) {}
4266

4367
export interface Interface {
44-
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
68+
readonly get: (providerID: string, profile?: string) => Effect.Effect<Info | undefined, AuthError>
4569
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
46-
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
47-
readonly remove: (key: string) => Effect.Effect<void, AuthError>
70+
readonly set: (providerID: string, info: Info, profile?: string) => Effect.Effect<void, AuthError>
71+
readonly remove: (providerID: string, profile?: string) => Effect.Effect<void, AuthError>
72+
readonly profiles: (providerID: string) => Effect.Effect<Array<{ profile?: string; info: Info }>, AuthError>
73+
readonly hasDefault: (providerID: string) => Effect.Effect<boolean, AuthError>
74+
readonly setDefault: (providerID: string, profile: string) => Effect.Effect<void, AuthError>
4875
}
4976

5077
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
@@ -66,11 +93,13 @@ const layer = Layer.effect(
6693
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
6794
})
6895

69-
const get = Effect.fn("Auth.get")(function* (providerID: string) {
70-
return (yield* all())[providerID]
96+
const get = Effect.fn("Auth.get")(function* (providerID: string, profile?: string) {
97+
const key = buildKey(providerID, profile)
98+
return (yield* all())[key]
7199
})
72100

73-
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
101+
const set = Effect.fn("Auth.set")(function* (providerID: string, info: Info, profile?: string) {
102+
const key = buildKey(providerID, profile)
74103
const norm = key.replace(/\/+$/, "")
75104
const data = yield* all()
76105
if (norm !== key) delete data[key]
@@ -80,15 +109,56 @@ const layer = Layer.effect(
80109
.pipe(Effect.mapError(fail("Failed to write auth data")))
81110
})
82111

83-
const remove = Effect.fn("Auth.remove")(function* (key: string) {
112+
const remove = Effect.fn("Auth.remove")(function* (providerID: string, profile?: string) {
113+
const key = buildKey(providerID, profile)
84114
const norm = key.replace(/\/+$/, "")
85115
const data = yield* all()
86116
delete data[key]
87117
delete data[norm]
88118
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
89119
})
90120

91-
return Service.of({ get, all, set, remove })
121+
const profiles = Effect.fn("Auth.profiles")(function* (providerID: string) {
122+
const data = yield* all()
123+
const result: Array<{ profile?: string; info: Info }> = []
124+
for (const [key, info] of Object.entries(data)) {
125+
const parsed = parseKey(key)
126+
if (parsed.providerID === providerID) {
127+
result.push({ profile: parsed.profile, info })
128+
}
129+
}
130+
return result
131+
})
132+
133+
const hasDefault = Effect.fn("Auth.hasDefault")(function* (providerID: string) {
134+
const data = yield* all()
135+
return providerID in data
136+
})
137+
138+
const setDefault = Effect.fn("Auth.setDefault")(function* (providerID: string, profile: string) {
139+
const data = yield* all()
140+
const namedKey = buildKey(providerID, profile)
141+
const defaultKey = providerID
142+
143+
const namedInfo = data[namedKey]
144+
if (!namedInfo) {
145+
return yield* new AuthError({ message: `Profile "${profile}" not found for provider "${providerID}"` })
146+
}
147+
148+
const defaultInfo = data[defaultKey]
149+
150+
// Swap: named becomes default, old default becomes named
151+
const newData = { ...data }
152+
newData[defaultKey] = namedInfo
153+
if (defaultInfo) {
154+
newData[namedKey] = defaultInfo
155+
} else {
156+
delete newData[namedKey]
157+
}
158+
yield* fsys.writeJson(file, newData, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
159+
})
160+
161+
return Service.of({ get, all, set, remove, profiles, hasDefault, setDefault })
92162
}),
93163
)
94164

0 commit comments

Comments
 (0)