From 8df4d672dd0f18dc3a3a31db0a5dbe2a722bc897 Mon Sep 17 00:00:00 2001 From: Knee Grow Date: Tue, 14 Jul 2026 01:28:15 +0200 Subject: [PATCH 1/2] 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 --- packages/core/src/v1/config/provider.ts | 1 + packages/opencode/src/auth/index.ts | 86 ++++++- packages/opencode/src/cli/cmd/providers.ts | 266 ++++++++++++++++----- packages/opencode/src/provider/auth.ts | 33 ++- packages/opencode/src/provider/provider.ts | 35 ++- 5 files changed, 330 insertions(+), 91 deletions(-) diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..8f3917855a68 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -79,6 +79,7 @@ export const Info = Schema.Struct({ env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), id: Schema.optional(Schema.String), npm: Schema.optional(Schema.String), + profile: Schema.optional(Schema.String.annotate({ description: "Default auth profile to use for this provider" })), whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), options: Schema.optional( diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a133e88498d5..1af602115ab5 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -9,6 +9,30 @@ export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" const file = path.join(Global.Path.data, "auth.json") +const PROFILE_DELIMITER = ":" +const PROFILE_REGEX = /^[a-zA-Z0-9_-]+$/ + +/** Parse composite key into provider and profile */ +export function parseKey(key: string): { providerID: string; profile?: string } { + const idx = key.indexOf(PROFILE_DELIMITER) + if (idx === -1) return { providerID: key } + return { + providerID: key.slice(0, idx), + profile: key.slice(idx + 1), + } +} + +/** Build composite key from provider and profile */ +export function buildKey(providerID: string, profile?: string): string { + if (!profile) return providerID + return `${providerID}${PROFILE_DELIMITER}${profile}` +} + +/** Validate profile name (alphanumeric, hyphen, underscore) */ +export function validateProfileName(name: string): boolean { + return PROFILE_REGEX.test(name) +} + const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) export class Oauth extends Schema.Class("OAuth")({ @@ -41,10 +65,13 @@ export class AuthError extends Schema.TaggedErrorClass()("AuthError", }) {} export interface Interface { - readonly get: (providerID: string) => Effect.Effect + readonly get: (providerID: string, profile?: string) => Effect.Effect readonly all: () => Effect.Effect, AuthError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect + readonly set: (providerID: string, info: Info, profile?: string) => Effect.Effect + readonly remove: (providerID: string, profile?: string) => Effect.Effect + readonly profiles: (providerID: string) => Effect.Effect, AuthError> + readonly hasDefault: (providerID: string) => Effect.Effect + readonly setDefault: (providerID: string, profile: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Auth") {} @@ -66,11 +93,13 @@ const layer = Layer.effect( return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) }) - const get = Effect.fn("Auth.get")(function* (providerID: string) { - return (yield* all())[providerID] + const get = Effect.fn("Auth.get")(function* (providerID: string, profile?: string) { + const key = buildKey(providerID, profile) + return (yield* all())[key] }) - const set = Effect.fn("Auth.set")(function* (key: string, info: Info) { + const set = Effect.fn("Auth.set")(function* (providerID: string, info: Info, profile?: string) { + const key = buildKey(providerID, profile) const norm = key.replace(/\/+$/, "") const data = yield* all() if (norm !== key) delete data[key] @@ -80,7 +109,8 @@ const layer = Layer.effect( .pipe(Effect.mapError(fail("Failed to write auth data"))) }) - const remove = Effect.fn("Auth.remove")(function* (key: string) { + const remove = Effect.fn("Auth.remove")(function* (providerID: string, profile?: string) { + const key = buildKey(providerID, profile) const norm = key.replace(/\/+$/, "") const data = yield* all() delete data[key] @@ -88,7 +118,47 @@ const layer = Layer.effect( yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) }) - return Service.of({ get, all, set, remove }) + const profiles = Effect.fn("Auth.profiles")(function* (providerID: string) { + const data = yield* all() + const result: Array<{ profile?: string; info: Info }> = [] + for (const [key, info] of Object.entries(data)) { + const parsed = parseKey(key) + if (parsed.providerID === providerID) { + result.push({ profile: parsed.profile, info }) + } + } + return result + }) + + const hasDefault = Effect.fn("Auth.hasDefault")(function* (providerID: string) { + const data = yield* all() + return providerID in data + }) + + const setDefault = Effect.fn("Auth.setDefault")(function* (providerID: string, profile: string) { + const data = yield* all() + const namedKey = buildKey(providerID, profile) + const defaultKey = providerID + + const namedInfo = data[namedKey] + if (!namedInfo) { + return yield* new AuthError({ message: `Profile "${profile}" not found for provider "${providerID}"` }) + } + + const defaultInfo = data[defaultKey] + + // Swap: named becomes default, old default becomes named + const newData = { ...data } + newData[defaultKey] = namedInfo + if (defaultInfo) { + newData[namedKey] = defaultInfo + } else { + delete newData[namedKey] + } + yield* fsys.writeJson(file, newData, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + }) + + return Service.of({ get, all, set, remove, profiles, hasDefault, setDefault }) }), ) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3775123d83bd..a9639f66d3fe 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -25,9 +25,35 @@ const promptValue = (value: Option.Option) => { return Effect.succeed(value.value) } -const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) { +const put = Effect.fn("Cli.providers.put")(function* (providerID: string, info: Auth.Info, profile?: string) { const auth = yield* Auth.Service - yield* Effect.orDie(auth.set(key, info)) + yield* Effect.orDie(auth.set(providerID, info, profile)) +}) + +/** + * Prompt for profile name if provider already has a default profile. + * Returns undefined for default profile, or the profile name. + */ +const promptForProfile = Effect.fn("Cli.providers.promptForProfile")(function* (provider: string) { + const authSvc = yield* Auth.Service + const hasDefault = yield* Effect.orDie(authSvc.hasDefault(provider)) + if (!hasDefault) return undefined + + const existingProfiles = yield* Effect.orDie(authSvc.profiles(provider)) + const existingNames = existingProfiles.map((p) => p.profile ?? "default").join(", ") + yield* Prompt.log.info(`Existing profiles: ${existingNames}`) + + const profileName = yield* Prompt.text({ + message: "Enter profile name (leave empty for default)", + placeholder: "e.g. work, personal", + validate: (x) => { + if (!x || x.length === 0) return undefined + if (!Auth.validateProfileName(x)) return "Only letters, numbers, hyphens, and underscores allowed" + return undefined + }, + }) + const value = yield* promptValue(profileName) + return value || undefined }) const cliTry = (message: string, fn: () => PromiseLike) => @@ -40,6 +66,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( plugin: { auth: PluginAuth }, provider: string, methodName?: string, + profile?: string, ) { const index = yield* Effect.gen(function* () { if (!methodName) { @@ -113,20 +140,28 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - yield* put(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + yield* put( + saveProvider, + { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }, + profile, + ) } if ("key" in result) { - yield* put(saveProvider, { - type: "api", - key: result.key, - ...(result.metadata ? { metadata: result.metadata } : {}), - }) + yield* put( + saveProvider, + { + type: "api", + key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), + }, + profile, + ) } yield* spinner.stop("Login successful") } @@ -146,20 +181,28 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - yield* put(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + yield* put( + saveProvider, + { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }, + profile, + ) } if ("key" in result) { - yield* put(saveProvider, { - type: "api", - key: result.key, - ...(result.metadata ? { metadata: result.metadata } : {}), - }) + yield* put( + saveProvider, + { + type: "api", + key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), + }, + profile, + ) } yield* Prompt.log.success("Login successful") } @@ -179,11 +222,15 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} const authorizeApi = method.authorize if (!authorizeApi) { - yield* put(provider, { - type: "api", - key: apiKey, - ...metadata, - }) + yield* put( + provider, + { + type: "api", + key: apiKey, + ...metadata, + }, + profile, + ) yield* Prompt.outro("Done") return true } @@ -195,11 +242,15 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( if (result.type === "success") { const saveProvider = result.provider ?? provider const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) } - yield* put(saveProvider, { - type: "api", - key: result.key ?? apiKey, - ...(Object.keys(merged).length ? { metadata: merged } : {}), - }) + yield* put( + saveProvider, + { + type: "api", + key: result.key ?? apiKey, + ...(Object.keys(merged).length ? { metadata: merged } : {}), + }, + profile, + ) yield* Prompt.log.success("Login successful") } yield* Prompt.outro("Done") @@ -236,12 +287,90 @@ export function resolvePluginProviders(input: { return result } +export const ProvidersSetDefaultCommand = effectCmd({ + command: "set-default", + describe: "set a named profile as the default for a provider", + instance: false, + handler: Effect.fn("Cli.providers.setDefault")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + + UI.empty() + yield* Prompt.intro("Set default profile") + const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + const database = yield* modelsDev.get() + + // Group by provider + const grouped: Record> = {} + for (const [key] of credentials) { + const parsed = Auth.parseKey(key) + if (!grouped[parsed.providerID]) grouped[parsed.providerID] = [] + grouped[parsed.providerID].push({ profile: parsed.profile, key }) + } + + // Filter to providers with named profiles + const eligibleProviders = Object.entries(grouped).filter(([, profiles]) => + profiles.some((p) => p.profile !== undefined), + ) + + if (eligibleProviders.length === 0) { + yield* Prompt.log.error("No providers with multiple profiles found") + yield* Prompt.outro("Done") + return + } + + // Select provider + const providerID = yield* promptValue( + yield* Prompt.autocomplete({ + message: "Select provider", + maxItems: 8, + options: eligibleProviders.map(([id, profiles]) => { + const name = database[id]?.name || id + const count = profiles.length + return { + label: `${name} ${UI.Style.TEXT_DIM}(${count} profiles)`, + value: id, + } + }), + }), + ) + + // Get named profiles for this provider + const namedProfiles = grouped[providerID].filter((p) => p.profile !== undefined) + if (namedProfiles.length === 0) { + yield* Prompt.log.error("No named profiles found for this provider") + yield* Prompt.outro("Done") + return + } + + // Select profile to promote + const profile = yield* promptValue( + yield* Prompt.select({ + message: "Select profile to set as default", + options: namedProfiles.map((p) => ({ + label: p.profile!, + value: p.profile!, + })), + }), + ) + + yield* Effect.orDie(authSvc.setDefault(providerID, profile)) + yield* Prompt.log.success(`Profile "${profile}" is now the default for ${database[providerID]?.name || providerID}`) + yield* Prompt.outro("Done") + }), +}) + export const ProvidersCommand = cmd({ command: "providers", aliases: ["auth"], describe: "manage AI providers and credentials", builder: (yargs) => - yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(), + yargs + .command(ProvidersListCommand) + .command(ProvidersLoginCommand) + .command(ProvidersLogoutCommand) + .command(ProvidersSetDefaultCommand) + .demandCommand(), async handler() {}, }) @@ -263,9 +392,20 @@ export const ProvidersListCommand = effectCmd({ const results = Object.entries(yield* Effect.orDie(authSvc.all())) const database = yield* modelsDev.get() - for (const [providerID, result] of results) { + // Group by provider + const grouped: Record> = {} + for (const [key, result] of results) { + const parsed = Auth.parseKey(key) + if (!grouped[parsed.providerID]) grouped[parsed.providerID] = [] + grouped[parsed.providerID].push({ profile: parsed.profile, type: result.type }) + } + + for (const [providerID, profiles] of Object.entries(grouped)) { const name = database[providerID]?.name || providerID - yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + for (const p of profiles) { + const profileLabel = p.profile ? `:${p.profile}` : " (default)" + yield* Prompt.log.info(`${name}${profileLabel} ${UI.Style.TEXT_DIM}${p.type}`) + } } yield* Prompt.outro(`${results.length} credentials`) @@ -428,12 +568,6 @@ export const ProvidersLoginCommand = effectCmd({ ) } - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method) - if (handled) return - } - if (provider === "other") { provider = (yield* promptValue( yield* Prompt.text({ @@ -442,17 +576,20 @@ export const ProvidersLoginCommand = effectCmd({ }), )).replace(/^@ai-sdk\//, "") - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method) - if (handled) return - } - yield* Prompt.log.warn( `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) } + // Prompt for profile name if provider already has credentials + const profile = yield* promptForProfile(provider) + + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method, profile) + if (handled) return + } + if (provider === "amazon-bedrock") { yield* Prompt.log.info( "Amazon Bedrock authentication priority:\n" + @@ -482,7 +619,7 @@ export const ProvidersLoginCommand = effectCmd({ validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) const apiKey = yield* promptValue(key) - yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey })) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }, profile)) yield* Prompt.outro("Done") }), @@ -510,25 +647,32 @@ export const ProvidersLogoutCommand = effectCmd({ return } const database = yield* modelsDev.get() - const options = credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })) - const provider = args.provider + const options = credentials.map(([key, value]) => { + const parsed = Auth.parseKey(key) + const providerName = database[parsed.providerID]?.name || parsed.providerID + const profileLabel = parsed.profile ? `:${parsed.profile}` : " (default)" + return { + label: providerName + profileLabel + UI.Style.TEXT_DIM + " " + value.type, + value: key, + } + }) + const selection = args.provider ? options.find( (option) => option.value === args.provider || - database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(), + Auth.parseKey(option.value).providerID === args.provider || + database[Auth.parseKey(option.value).providerID]?.name?.toLowerCase() === args.provider?.toLowerCase(), )?.value : yield* promptValue( yield* Prompt.autocomplete({ - message: "Select provider", + message: "Select credential", maxItems: 8, options, }), ) - if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) - yield* Effect.orDie(authSvc.remove(provider)) + if (!selection) return yield* fail(`Unknown configured provider "${args.provider}"`) + const parsed = Auth.parseKey(selection) + yield* Effect.orDie(authSvc.remove(parsed.providerID, parsed.profile)) yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 416a3ea7b0db..eb4dde328e34 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -62,6 +62,7 @@ export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), + profile: Schema.optional(Schema.String).annotate({ description: "Auth profile name" }), }) export type CallbackInput = Schema.Schema.Type @@ -201,22 +202,30 @@ const layer: Layer.Layer = Layer. if (!result || result.type !== "success") return yield* new OauthCallbackFailed({}) if ("key" in result) { - yield* auth.set(input.providerID, { - type: "api", - key: result.key, - ...(result.metadata ? { metadata: result.metadata } : {}), - }) + yield* auth.set( + input.providerID, + { + type: "api", + key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), + }, + input.profile, + ) } if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extra } = result - yield* auth.set(input.providerID, { - type: "oauth", - access, - refresh, - expires, - ...extra, - }) + yield* auth.set( + input.providerID, + { + type: "oauth", + access, + refresh, + expires, + ...extra, + }, + input.profile, + ) } }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2b1b53c231bb..acd9894b3125 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -145,7 +145,7 @@ type CustomLoader = (provider: Info) => Effect.Effect<{ }> type CustomDep = { - auth: (id: string) => Effect.Effect + auth: (id: string, profile?: string) => Effect.Effect config: () => Effect.Effect env: () => Effect.Effect> get: (key: string) => Effect.Effect @@ -1354,7 +1354,7 @@ const layer = Layer.effect( [providerID: string]: CustomDiscoverModels } = {} const dep = { - auth: (id: string) => auth.get(id).pipe(Effect.orDie), + auth: (id: string, profile?: string) => auth.get(id, profile).pipe(Effect.orDie), config: () => config.get(), env: () => env.all(), get: (key: string) => env.get(key), @@ -1525,16 +1525,21 @@ const layer = Layer.effect( }) } - // load apikeys + // load apikeys (supports composite keys like "provider:profile") const auths = yield* auth.all().pipe(Effect.orDie) - for (const [id, provider] of Object.entries(auths)) { - const providerID = ProviderV2.ID.make(id) + for (const [key, provider] of Object.entries(auths)) { + const parsed = Auth.parseKey(key) + const providerID = ProviderV2.ID.make(parsed.providerID) if (disabled.has(providerID)) continue if (provider.type === "api") { - mergeProvider(providerID, { - source: "api", - key: provider.key, - }) + // For the default profile (no profile suffix), set the key directly + if (!parsed.profile) { + mergeProvider(providerID, { + source: "api", + key: provider.key, + }) + } + // Named profiles are available via auth.get(providerID, profile) } } @@ -1988,9 +1993,19 @@ export function sort(models: T[]) { } export function parseModel(model: string) { - const [providerID, ...rest] = model.split("/") + const [providerAndProfile, ...rest] = model.split("/") + const colonIdx = providerAndProfile.indexOf(":") + let providerID: string + let profile: string | undefined + if (colonIdx === -1) { + providerID = providerAndProfile + } else { + providerID = providerAndProfile.slice(0, colonIdx) + profile = providerAndProfile.slice(colonIdx + 1) + } return { providerID: ProviderV2.ID.make(providerID), + profile, modelID: ModelV2.ID.make(rest.join("/")), } } From f7e31f05125d66f8f0bc96030b0a134c396deac8 Mon Sep 17 00:00:00 2001 From: Knee Grow Date: Tue, 14 Jul 2026 01:31:22 +0200 Subject: [PATCH 2/2] test(auth): add tests for multi-profile auth - Auth profiles: set/get with profiles, list profiles, hasDefault, setDefault swap, remove with profile - parseKey/buildKey/validateProfileName utilities - parseModel with provider:profile/model format --- packages/opencode/test/auth/auth.test.ts | 113 ++++++++++++++++++ .../opencode/test/provider/provider.test.ts | 16 +++ 2 files changed, 129 insertions(+) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index bb72be66e57c..fe0b4eb18bdd 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -73,3 +73,116 @@ describe("Auth", () => { }), ) }) + +describe("Auth Profiles", () => { + it.instance("set and get with profile", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("openrouter", { type: "api", key: "sk-default" }) + yield* auth.set("openrouter", { type: "api", key: "sk-work" }, "work") + yield* auth.set("openrouter", { type: "api", key: "sk-personal" }, "personal") + + const def = yield* auth.get("openrouter") + expect(def?.type).toBe("api") + if (def?.type === "api") expect(def.key).toBe("sk-default") + + const work = yield* auth.get("openrouter", "work") + expect(work?.type).toBe("api") + if (work?.type === "api") expect(work.key).toBe("sk-work") + + const personal = yield* auth.get("openrouter", "personal") + expect(personal?.type).toBe("api") + if (personal?.type === "api") expect(personal.key).toBe("sk-personal") + }), + ) + + it.instance("profiles lists all profiles for a provider", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("openrouter", { type: "api", key: "sk-default" }) + yield* auth.set("openrouter", { type: "api", key: "sk-work" }, "work") + + const profiles = yield* auth.profiles("openrouter") + expect(profiles.length).toBe(2) + const names = profiles.map((p) => p.profile).sort() + expect(names).toEqual([undefined, "work"]) + }), + ) + + it.instance("hasDefault returns true when default exists", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("openrouter", { type: "api", key: "sk-default" }) + + expect(yield* auth.hasDefault("openrouter")).toBe(true) + expect(yield* auth.hasDefault("anthropic")).toBe(false) + }), + ) + + it.instance("setDefault swaps named profile to default", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("openrouter", { type: "api", key: "sk-old-default" }) + yield* auth.set("openrouter", { type: "api", key: "sk-work" }, "work") + + yield* auth.setDefault("openrouter", "work") + + const def = yield* auth.get("openrouter") + expect(def?.type).toBe("api") + if (def?.type === "api") expect(def.key).toBe("sk-work") + + const old = yield* auth.get("openrouter", "work") + expect(old?.type).toBe("api") + if (old?.type === "api") expect(old.key).toBe("sk-old-default") + }), + ) + + it.instance("remove with profile only removes that profile", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("openrouter", { type: "api", key: "sk-default" }) + yield* auth.set("openrouter", { type: "api", key: "sk-work" }, "work") + + yield* auth.remove("openrouter", "work") + + const def = yield* auth.get("openrouter") + expect(def?.type).toBe("api") + + const work = yield* auth.get("openrouter", "work") + expect(work).toBeUndefined() + }), + ) +}) + +describe("Auth parseKey / buildKey", () => { + it("parseKey returns providerID only for simple keys", () => { + expect(Auth.parseKey("openrouter")).toEqual({ providerID: "openrouter" }) + expect(Auth.parseKey("anthropic")).toEqual({ providerID: "anthropic" }) + }) + + it("parseKey splits providerID and profile", () => { + expect(Auth.parseKey("openrouter:work")).toEqual({ providerID: "openrouter", profile: "work" }) + expect(Auth.parseKey("anthropic:personal")).toEqual({ providerID: "anthropic", profile: "personal" }) + }) + + it("buildKey creates simple key when no profile", () => { + expect(Auth.buildKey("openrouter")).toBe("openrouter") + expect(Auth.buildKey("openrouter", undefined)).toBe("openrouter") + }) + + it("buildKey creates composite key with profile", () => { + expect(Auth.buildKey("openrouter", "work")).toBe("openrouter:work") + }) + + it("validateProfileName accepts valid names", () => { + expect(Auth.validateProfileName("work")).toBe(true) + expect(Auth.validateProfileName("my-profile")).toBe(true) + expect(Auth.validateProfileName("profile_1")).toBe(true) + }) + + it("validateProfileName rejects invalid names", () => { + expect(Auth.validateProfileName("")).toBe(false) + expect(Auth.validateProfileName("my profile")).toBe(false) + expect(Auth.validateProfileName("my@profile")).toBe(false) + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index e656890b01b0..9b1b4118c49f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -335,12 +335,28 @@ test("parseModel correctly parses provider/model string", () => { const result = Provider.parseModel("anthropic/claude-sonnet-4") expect(String(result.providerID)).toBe("anthropic") expect(String(result.modelID)).toBe("claude-sonnet-4") + expect(result.profile).toBeUndefined() }) test("parseModel handles model IDs with slashes", () => { const result = Provider.parseModel("openrouter/anthropic/claude-3-opus") expect(String(result.providerID)).toBe("openrouter") expect(String(result.modelID)).toBe("anthropic/claude-3-opus") + expect(result.profile).toBeUndefined() +}) + +test("parseModel extracts profile from provider:profile/model format", () => { + const result = Provider.parseModel("openrouter:work/claude-sonnet-4") + expect(String(result.providerID)).toBe("openrouter") + expect(result.profile).toBe("work") + expect(String(result.modelID)).toBe("claude-sonnet-4") +}) + +test("parseModel handles profile with model IDs containing slashes", () => { + const result = Provider.parseModel("openrouter:personal/anthropic/claude-3-opus") + expect(String(result.providerID)).toBe("openrouter") + expect(result.profile).toBe("personal") + expect(String(result.modelID)).toBe("anthropic/claude-3-opus") }) it.instance("defaultModel returns first available model when no config set", () =>