diff --git a/docs/custom-providers-and-multi-account.md b/docs/custom-providers-and-multi-account.md index 5324b5557f9..b68170fa62d 100644 --- a/docs/custom-providers-and-multi-account.md +++ b/docs/custom-providers-and-multi-account.md @@ -104,6 +104,8 @@ gjc accounts check anthropic --json gjc accounts pin anthropic me@example.com --persistent gjc accounts pin anthropic id:42 --persistent gjc accounts pin anthropic --clear --persistent +gjc accounts prefer anthropic low-cost@example.com shared@example.com=75 +gjc accounts prefer anthropic --clear gjc accounts logout anthropic --account me@example.com gjc accounts logout anthropic --all ``` @@ -111,6 +113,7 @@ gjc accounts logout anthropic --all - `list` shows stored OAuth rows and configured API-key sources, with safe identity/source/health/usage-freshness fields. It does not probe. - `check` performs the explicit sequential checker. `--json` emits safe machine-readable rows; a failed probe sets a non-zero exit status. - `pin` requires `--persistent`. `` is a bare email, `id:`, `email:`, or `account:`. The command validates an active OAuth row and writes the canonical `id:` selector to global configuration. `--clear` removes the provider's persistent pin. +- `prefer` writes the provider's ordered preference list (see `auth.credentialPreferences` below). Accounts are given in order; append `=` to cap one. Each account accepts the same selectors as `pin`; the command checks that it names exactly one active OAuth row and stores that row's email (as `email:
` when the bare text would not read back as that address, for example one with `:` in its local part or one starting with `account:` or `email:`), or its account id when another row shares the email, so the file never holds a store-bound row id and every written list is read back by startup. A cap is the text after the last `=` that follows the account, so `user=2@example.com` is an account and `user=2@example.com=75` caps it at 75%. Nothing is written when any account is missing, ambiguous, duplicated, or has an invalid cap. `--clear` removes the provider's list. The change applies to new sessions. - `logout` requires exactly one of `--account ` or `--all`; it removes only OAuth rows from the local store. It never removes API-key source rows, and it refuses to mutate a broker-backed store. API-key rows remain visible and checkable so operators can see which source is selected, but they are not part of OAuth account pooling, cannot be pinned by `gjc accounts pin`, and cannot be removed by `gjc accounts logout`. @@ -134,8 +137,10 @@ auth: openai-codex: email:me@example.com credentialPinStoreIdentity: broker:https://broker.example.test:8765 credentialPreferences: - anthropic: email:low-cost-plan@example.com - credentialPreferenceMaxUsedPercent: 90 + anthropic: + - low-cost@example.com + - shared@example.com: 75 + openai-codex: me@example.com ``` - `auth.broker.url` and `auth.broker.token` select direct-broker mode. `GJC_AUTH_BROKER_URL` and `GJC_AUTH_BROKER_TOKEN` take precedence over the nested values; nested values may be literal strings or trusted `$ENV_NAME` references. Resolution order remains explicit env → nested value (after indirection) → the owner-only `/auth-broker.token` file, so an unresolved nested token does not displace the token-file authority. A configured URL without a resolvable token is a hard error; GJC does not silently fall back to local SQLite. An absent config file leaves broker mode disabled, while malformed or unreadable global startup-auth config fails closed with a typed `StartupAuthConfigError`. @@ -143,8 +148,8 @@ auth: - `auth.credentialPins` is a global-only record of provider → selector. Project-scoped pins are ignored; do not place this record in project settings. - Numeric `id:` pins are bound to the credential-store authority fingerprint (`broker:` or `local:`). Changing broker URL or local database invalidates those numeric pins instead of retargeting a row with the same number; `email:` and `account:` selectors remain portable. - `auth.credentialPinStoreIdentity` is managed alongside persistent pins and contains no credential material. Numeric pins are applied only when this value exactly matches the current store authority; missing or mismatched metadata invalidates the numeric pin rather than retargeting a same-number row. Email/account selectors remain portable across store changes. -- `auth.credentialPreferences` is a global-only record of provider → soft preferred account. Unlike a pin, a preference never blocks the provider: the preferred account is tried first while it is usable, and the provider falls back to normal ranking when the account is blocked by a quota or rate-limit failure, is no longer stored, or has reached the usage threshold. Only portable `email:` and `account:` selectors are accepted; numeric `id:` selectors are rejected because a preference has no store-identity binding. -- `auth.credentialPreferenceMaxUsedPercent` (greater than 0 and at most 100, default 100) applies to every preference. The preferred account yields to normal ranking once its most-used known usage window (for example Claude 5h or 7d) reaches this percent. Unknown usage never counts as over the threshold. At 100 the account yields only once it is blocked. +- `auth.credentialPreferences` is a global-only record of provider → ordered soft preference list. `gjc accounts prefer` writes it; hand edits use the same shape. A value is one account or a list. Each entry is an account (`me@example.com`, `email:me@example.com`, or `account:`) or an account with a usage cap in percent (`me@example.com: 75`, greater than 0 and at most 100). Numeric `id:` selectors are rejected because a preference has no store-identity binding. An invalid entry or an account listed twice fails startup closed rather than silently changing the order; this includes two entries that name the same stored account through different selectors (for example `me@example.com` and `account:`), which startup detects once the credential store is loaded. If a later login makes two entries name one account during a running session, that provider's list is ignored and its accounts are ranked normally until the list is fixed. +- Selection walks the list in order and uses the first account that is usable and under its cap. An uncapped account stays first until a quota or rate-limit failure blocks it. A capped account counts as full once its most-used known usage window (for example Claude 5h or 7d, or Codex primary/secondary) reaches the cap; unknown usage never counts as full. When every listed account is blocked or full, the unlisted accounts are ranked normally, and full listed accounts are used only after those. Unlike a pin, a preference never blocks the provider. - Literal dotted root keys such as `auth.broker.url: ...`, `auth.credentialRankingMode: ...`, or `auth.credentialPins: ...` are rejected. Startup reports the keys and prints manual rewrite guidance. Rewrite `config.yml` by hand using the nested shape above; there is no automatic migration, and secret values must not be copied into command output. ### Pin and credential precedence @@ -155,10 +160,10 @@ API-key overrides are outside OAuth pinning: 2. A `models.yml` provider `apiKey` config override comes next and beats stored/broker OAuth. 3. If no API-key override is active, an explicit session selector wins: `--credential ...` or an account selected in `/login `. 4. A global `auth.credentialPins` entry seeds that provider's selector when a new session starts. Session **AUTO** masks it for that session. -5. With no selector, a runtime `--prefer-credential` or, failing that, a global `auth.credentialPreferences` entry moves its account ahead of automatic ranking while that account is usable. +5. With no selector, a runtime `--prefer-credential` moves its account ahead of automatic ranking while that account is usable. Without one, the global `auth.credentialPreferences` list orders the accounts as described above. 6. Otherwise OAuth accounts use automatic ranking (`balanced` or `earliest-reset`). -Ranking is performed at session start, or when the session's preferred account is blocked; a running session keeps its selected credential. The preference usage threshold is therefore evaluated when ranking runs; a session already using the preferred account keeps it until a quota or rate-limit failure rotates it away. Blocked/exhausted accounts sort last. Persistent pins and session selectors are valid only for active OAuth credentials. An API-key override (including a configured environment API-key source when creating a pin) makes OAuth pinning unavailable for that provider. +Ranking is performed at session start, or when the session's preferred account is blocked; a running session keeps its selected credential. Preference caps are therefore evaluated when an account is selected; a session keeps its account after that account crosses its cap, until a quota or rate-limit failure moves the session to the next listed account. Caps are read from the shared usage cache, so concurrent sessions that start before the cache refreshes can each select the same account and carry it somewhat past its cap. Blocked/exhausted accounts sort last. Persistent pins and session selectors are valid only for active OAuth credentials. An API-key override (including a configured environment API-key source when creating a pin) makes OAuth pinning unavailable for that provider. ## Local, direct-broker, and gateway capabilities diff --git a/packages/ai/changelog.d/configured-credential-preference.md b/packages/ai/changelog.d/configured-credential-preference.md index 398735b805a..5f13b498afd 100644 --- a/packages/ai/changelog.d/configured-credential-preference.md +++ b/packages/ai/changelog.d/configured-credential-preference.md @@ -1,6 +1,6 @@ ### Added -- `AuthStorageOptions.credentialPreferences` accepts a persisted soft OAuth account preference per provider (`ConfiguredCredentialPreference`: selector plus optional `maxUsedFraction`). The preferred row is moved ahead of balanced/earliest-reset ranking while it is usable, and yields to the normal ranking when it is blocked, no longer stored, or its most-used known usage window has reached `maxUsedFraction`. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. +- `AuthStorageOptions.credentialPreferences` accepts an ordered, persisted soft OAuth account preference list per provider (`ConfiguredCredentialPreference[]`: selector plus optional `maxUsedFraction` cap per entry). Selection uses the first listed account that is usable and under its cap; when every listed account is blocked or capped, the unlisted accounts are ranked by balanced/earliest-reset ranking, and capped listed accounts are used only after those. Caps read the windows each provider reports, so they also apply to providers without a ranking strategy. Runtime `--credential`/`--prefer-credential` selectors and session stickiness take precedence. A list whose entries name one stored row twice (for example an email and `account:` for the same login) is ignored as a whole and reported by `AuthStorage.findDuplicateCredentialPreferences()`. ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 83a219bda72..851194f2a7d 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -699,19 +699,22 @@ export interface CredentialDisabledEvent { export type CredentialRankingMode = "balanced" | "earliest-reset"; /** - * Persisted soft preference for one provider's OAuth pool. + * One entry of a provider's ordered, persisted soft preference list. * - * The matching credential is moved ahead of the normal ranking while it is - * usable. It yields to the normal ranking when it is blocked, when it cannot be - * found, or when its most-used known usage window has reached - * `maxUsedFraction`. Unknown usage never counts as over the threshold. + * Entries are consulted in list order when a credential is selected. Usable + * entries under their cap are tried first, in list order; the remaining + * credentials follow in the normal ranking order. An entry whose most-used + * known usage window has reached `maxUsedFraction` is demoted behind every + * other credential, so it is used only when nothing else is available. A + * blocked or missing entry is skipped. Unknown usage never counts as over the + * cap. * * Runtime selectors (`--credential`, `--prefer-credential`) and session - * stickiness take precedence over a configured preference. + * stickiness take precedence over configured preferences. */ export interface ConfiguredCredentialPreference { selector: AuthCredentialSelector; - /** Used fraction in (0, 1] at which the preference stops applying. Absent means only blocking rotates away. */ + /** Used fraction in (0, 1] at which this entry stops being preferred. Absent means only blocking moves past it. */ maxUsedFraction?: number; } @@ -731,8 +734,8 @@ export type AuthStorageOptions = { usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; credentialRankingMode?: CredentialRankingMode; - /** Persisted soft preferences keyed by provider id. */ - credentialPreferences?: Readonly>; + /** Persisted ordered soft preferences keyed by provider id. */ + credentialPreferences?: Readonly>; usageFetch?: typeof fetch; usageRequestTimeoutMs?: number; usageLogger?: UsageLogger; @@ -1546,8 +1549,10 @@ export class AuthStorage { #runtimeCredentialSelectors: Map = new Map(); /** Soft runtime credential preference per provider; quota failures may rotate away from it. */ #runtimePreferredCredentialSelectors: Map = new Map(); - /** Persisted soft credential preference per provider; usage threshold and quota failures may rotate away from it. */ - #configuredCredentialPreferences: Map = new Map(); + /** Persisted ordered soft credential preferences per provider; usage caps and quota failures move past entries. */ + #configuredCredentialPreferences: Map = new Map(); + /** Providers already warned that their preference list names one stored row twice. */ + #warnedDuplicateCredentialPreferences = new Set(); /** Credential selectors explicitly attached to a credential session scope. */ #sessionCredentialSelectors: Map> = new Map(); /** Explicit AUTO masks suppress both scoped and process-global selectors for a scope/provider. */ @@ -1638,8 +1643,10 @@ export class AuthStorage { : DEFAULT_USAGE_REQUEST_TIMEOUT_MS; this.#usageFetchLeaseMs = usageTimeoutForLease + USAGE_FETCH_LEASE_GRACE_MS; this.#credentialRankingMode = options.credentialRankingMode ?? "balanced"; - for (const [provider, preference] of Object.entries(options.credentialPreferences ?? {})) { - this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preference); + for (const [provider, preferences] of Object.entries(options.credentialPreferences ?? {})) { + if (preferences.length > 0) { + this.#configuredCredentialPreferences.set(resolveOAuthStorageProvider(provider), preferences); + } } this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential; this.#fetchUsageReportsOverride = options.fetchUsageReports; @@ -3222,10 +3229,41 @@ export class AuthStorage { } /** - * Move the configured preferred credential to the front of `candidates` while it is usable and under its usage - * threshold. A missing row, a blocked row, or a row at its threshold leaves the normal ranking untouched. + * Providers whose configured preference list names one stored credential row through more than one entry, for + * example `email:` and `account:` selectors for the same login. Startup rejects such a list. + */ + findDuplicateCredentialPreferences(): Array<{ provider: string; credentialId: number }> { + const duplicates: Array<{ provider: string; credentialId: number }> = []; + for (const [provider, preferences] of this.#configuredCredentialPreferences) { + const credentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences); + if (credentialId !== undefined) duplicates.push({ provider, credentialId }); + } + return duplicates; + } + + /** Id of the first stored row that two entries of `preferences` both designate; undefined when every entry differs. */ + #duplicateConfiguredPreferenceRow( + provider: string, + preferences: readonly ConfiguredCredentialPreference[], + ): number | undefined { + const designatedIds = new Set(); + for (const { selector } of preferences) { + const credentialId = this.#findCredentialBySelector(provider, selector)?.id; + if (credentialId === undefined) continue; + if (designatedIds.has(credentialId)) return credentialId; + designatedIds.add(credentialId); + } + return undefined; + } + + /** + * Reorder `candidates` by the provider's configured preference list: usable entries under their cap first, in list + * order; then the remaining candidates in their ranked order; then entries at or over their cap, in list order. + * Missing and blocked entries are skipped and keep their ranked position. A list that names one stored row twice + * (startup rejects it, but a later login can make two entries resolve to one row) is ignored as a whole rather than + * applying one entry's position and cap and dropping the other's. */ - async #applyConfiguredCredentialPreference( + async #applyConfiguredCredentialPreferences( provider: string, providerKey: string, candidates: Array<{ @@ -3236,28 +3274,56 @@ export class AuthStorage { strategy: CredentialRankingStrategy | undefined, options?: AuthApiKeyOptions, ): Promise { - const preference = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider)); - if (!preference) return; - const preferredCandidate = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector); - if (preferredCandidate < 0) return; - const { maxUsedFraction } = preference; - if (maxUsedFraction !== undefined) { - const candidate = candidates[preferredCandidate]!; - if (!candidate.usageChecked) { + const preferences = this.#configuredCredentialPreferences.get(resolveOAuthStorageProvider(provider)); + if (!preferences) return; + const duplicateCredentialId = this.#duplicateConfiguredPreferenceRow(provider, preferences); + if (duplicateCredentialId !== undefined) { + const storageProvider = resolveOAuthStorageProvider(provider); + if (!this.#warnedDuplicateCredentialPreferences.has(storageProvider)) { + this.#warnedDuplicateCredentialPreferences.add(storageProvider); + logger.warn("Configured credential preferences name one account twice; using normal ranking", { + provider: storageProvider, + credentialId: duplicateCredentialId, + }); + } + return; + } + type Candidate = (typeof candidates)[number]; + const matched: Array<{ candidate: Candidate; maxUsedFraction: number | undefined }> = []; + const claimed = new Set(); + for (const preference of preferences) { + const position = this.#findUsableOAuthCandidate(provider, providerKey, candidates, preference.selector); + if (position < 0) continue; + const candidate = candidates[position]!; + claimed.add(candidate); + matched.push({ candidate, maxUsedFraction: preference.maxUsedFraction }); + } + if (matched.length === 0) return; + // Ranking already fetched usage for strategy-backed providers; fetch only what a cap still needs. + await Promise.all( + matched.map(async ({ candidate, maxUsedFraction }) => { + if (maxUsedFraction === undefined || candidate.usageChecked) return; candidate.usage = await this.#getUsageReport(provider, candidate.selection.credential, { baseUrl: options?.baseUrl, timeoutMs: this.#usageRequestTimeoutMs, signal: options?.signal, }); candidate.usageChecked = true; + }), + ); + const preferred: Candidate[] = []; + const capped: Candidate[] = []; + for (const { candidate, maxUsedFraction } of matched) { + const usedFraction = + maxUsedFraction === undefined ? undefined : this.#maxKnownUsedFraction(candidate.usage, strategy); + if (maxUsedFraction !== undefined && usedFraction !== undefined && usedFraction >= maxUsedFraction) { + capped.push(candidate); + } else { + preferred.push(candidate); } - const usedFraction = this.#maxKnownUsedFraction(candidate.usage, strategy); - if (usedFraction !== undefined && usedFraction >= maxUsedFraction) return; - } - if (preferredCandidate > 0) { - const [preferred] = candidates.splice(preferredCandidate, 1); - candidates.unshift(preferred); } + const rest = candidates.filter(candidate => !claimed.has(candidate)); + candidates.splice(0, candidates.length, ...preferred, ...rest, ...capped); } /** @@ -6001,8 +6067,10 @@ export class AuthStorage { const [preferred] = candidates.splice(preferredCandidate, 1); candidates.unshift(preferred); } - } else if (!preferredSelector) { - await this.#applyConfiguredCredentialPreference(provider, providerKey, candidates, strategy, options); + } else if (!preferredSelector && !sessionPreferredIsAvailable) { + // Configured preferences choose a credential; a session that already sticks to a usable one keeps it, so + // the list (and any usage fetch its caps need) is consulted only at selection time. + await this.#applyConfiguredCredentialPreferences(provider, providerKey, candidates, strategy, options); } } diff --git a/packages/ai/test/auth-storage-configured-preference.test.ts b/packages/ai/test/auth-storage-configured-preference.test.ts index 99dbd947984..871ba2da714 100644 --- a/packages/ai/test/auth-storage-configured-preference.test.ts +++ b/packages/ai/test/auth-storage-configured-preference.test.ts @@ -10,24 +10,30 @@ import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const CHEAP = "cheap@example.test"; +const SHARED = "shared@example.test"; +const PERSONAL = "personal@example.test"; + const openStores: SqliteAuthCredentialStore[] = []; const usageByEmail = new Map(); -const usageProvider: UsageProvider = { - id: "anthropic", - async fetchUsage(params) { - const email = params.credential.email; - return email ? (usageByEmail.get(email) ?? null) : null; - }, +/** + * Usage-report shapes by provider type. Each provider names its windows differently; the cap must read the windows + * each provider actually reports rather than assume Anthropic's ids. + */ +type ProviderCase = "anthropic" | "openai-codex" | "kimi-code"; +const WINDOW_IDS: Record = { + anthropic: { short: "anthropic:5h", long: "anthropic:7d" }, + "openai-codex": { short: "openai-codex:primary", long: "openai-codex:secondary" }, + "kimi-code": { short: "kimi-code:0", long: "kimi-code:1" }, }; -function limit(provider: string, windowId: "5h" | "7d", usedFraction: number): UsageLimit { - const durationMs = windowId === "5h" ? 5 * HOUR_MS : 7 * DAY_MS; +function limit(id: string, provider: string, durationMs: number, usedFraction: number): UsageLimit { return { - id: `${provider}:${windowId}`, - label: windowId, - scope: { provider, windowId, shared: true }, - window: { id: windowId, label: windowId, durationMs, resetsAt: Date.now() + durationMs / 2 }, + id, + label: id, + scope: { provider, windowId: id, shared: true }, + window: { id, label: id, durationMs, resetsAt: Date.now() + durationMs / 2 }, amount: { unit: "percent", used: usedFraction * 100, @@ -40,45 +46,60 @@ function limit(provider: string, windowId: "5h" | "7d", usedFraction: number): U }; } -function report(email: string, fiveHourUsed: number, sevenDayUsed: number, provider = "anthropic"): UsageReport { - return { +function setUsage(email: string, shortUsed: number, longUsed: number, provider: ProviderCase = "anthropic"): void { + const ids = WINDOW_IDS[provider]; + usageByEmail.set(email, { provider, fetchedAt: Date.now(), - limits: [limit(provider, "5h", fiveHourUsed), limit(provider, "7d", sevenDayUsed)], + limits: [limit(ids.short, provider, 5 * HOUR_MS, shortUsed), limit(ids.long, provider, 7 * DAY_MS, longUsed)], metadata: { email }, + }); +} + +function oauth(email: string, accountId?: string): OAuthCredential { + return { + type: "oauth", + access: `token-${email}`, + refresh: `refresh-${email}`, + expires: Date.now() + HOUR_MS, + email, + ...(accountId ? { accountId } : {}), }; } -function oauth(access: string, email: string): OAuthCredential { - return { type: "oauth", access, refresh: `refresh-${access}`, expires: Date.now() + HOUR_MS, email }; +function token(email: string): string { + return `token-${email}`; +} + +function prefer(email: string, maxUsedPercent?: number): ConfiguredCredentialPreference { + return maxUsedPercent === undefined + ? { selector: { kind: "email", value: email } } + : { selector: { kind: "email", value: email }, maxUsedFraction: maxUsedPercent / 100 }; } async function createStorage( - preference?: ConfiguredCredentialPreference, - rankingStrategyResolver?: () => undefined, - provider: "anthropic" | "kimi-code" = "anthropic", + preferences: readonly ConfiguredCredentialPreference[] | undefined, + options: { provider?: ProviderCase; reportsUsage?: boolean; emails?: readonly string[] } = {}, ): Promise { + const provider = options.provider ?? "anthropic"; const store = await SqliteAuthCredentialStore.open(":memory:"); openStores.push(store); - const usageProviderForStorage: UsageProvider = - provider === "anthropic" - ? usageProvider - : { - id: provider, - async fetchUsage(params) { - const email = params.credential.email; - return email ? (usageByEmail.get(email) ?? null) : null; - }, - }; + const usageProvider: UsageProvider = { + id: provider, + async fetchUsage(params) { + const email = params.credential.email; + return email ? (usageByEmail.get(email) ?? null) : null; + }, + }; const storage = new AuthStorage(store, { - usageProviderResolver: resolvedProvider => (resolvedProvider === provider ? usageProviderForStorage : undefined), - ...(rankingStrategyResolver ? { rankingStrategyResolver } : {}), - ...(preference ? { credentialPreferences: { [provider]: preference } } : {}), - }); - await storage.set(provider, [ - oauth("token-personal", "personal@example.test"), - oauth("token-cheap", "cheap@example.test"), - ]); + usageProviderResolver: resolved => + options.reportsUsage !== false && resolved === provider ? usageProvider : undefined, + ...(preferences ? { credentialPreferences: { [provider]: preferences } } : {}), + }); + await storage.set( + provider, + (options.emails ?? [PERSONAL, CHEAP, SHARED]).map(email => oauth(email)), + ); return storage; } @@ -87,97 +108,190 @@ afterEach(() => { for (const store of openStores.splice(0)) store.close(); }); -describe("configured credential preference", () => { - test("balanced ranking picks the less-used account without a preference", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.5, 0.5)); - const storage = await createStorage(); +describe("ordered credential preferences", () => { + test("balanced ranking picks the least-used account without preferences", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage(undefined); - expect(await storage.getApiKey("anthropic", "no-preference")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "no-preference")).toBe(token(PERSONAL)); }); - test("the preferred account is used ahead of the balanced ranking", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.5, 0.5)); - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); + test("the first listed account is used ahead of balanced ranking", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "preferred")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "first")).toBe(token(CHEAP)); }); - test("the preference yields to balanced ranking once any window reaches maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.9, 0.2)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test("a rate-limited first account moves selection to the second listed account, not to balanced ranking", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 1, 0.4); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "over-threshold")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "second")).toBe(token(SHARED)); }); - test("the long window alone can push the preference over maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.05, 0.95)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test.each([ + [74, SHARED], + [75, PERSONAL], + ])("a listed account at %i%% of a 75%% cap resolves to %s", async (usedPercent, expected) => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(CHEAP, 1, 0.4); + setUsage(SHARED, usedPercent / 100, 0.1); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "long-window-over-threshold")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", `cap-${usedPercent}`)).toBe(token(expected)); }); - test("the threshold applies when usage is available without a ranking strategy", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1, "kimi-code")); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.95, 0.2, "kimi-code")); - const storage = await createStorage( - { - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }, - () => undefined, - "kimi-code", - ); + test("the long window alone can put a listed account over its cap", async () => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(CHEAP, 0.05, 0.95); + const storage = await createStorage([prefer(CHEAP, 90)], { emails: [PERSONAL, CHEAP] }); - expect(await storage.getApiKey("kimi-code")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "long-window")).toBe(token(PERSONAL)); }); - test("the preference still applies just below maxUsedFraction", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.1, 0.1)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.89, 0.2)); - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.9, - }); + test("once every listed account is past its cap the unlisted accounts are ranked normally", async () => { + setUsage(PERSONAL, 0.6, 0.6); + setUsage(CHEAP, 0.8, 0.2); + setUsage(SHARED, 0.9, 0.2); + const storage = await createStorage([prefer(CHEAP, 50), prefer(SHARED, 75)]); - expect(await storage.getApiKey("anthropic", "under-threshold")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "all-capped")).toBe(token(PERSONAL)); }); - test("unknown usage does not count as over the threshold", async () => { - const storage = await createStorage({ - selector: { kind: "email", value: "cheap@example.test" }, - maxUsedFraction: 0.5, - }); + test("an account past its cap is still used when every other account is rate-limited", async () => { + setUsage(PERSONAL, 1, 0.9); + setUsage(CHEAP, 1, 0.5); + setUsage(SHARED, 0.9, 0.2); + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); + + expect(await storage.getApiKey("anthropic", "capped-last-resort")).toBe(token(SHARED)); + }); + + test.each([ + "openai-codex", + "kimi-code", + ] as const)("%s caps read the windows that provider reports", async provider => { + setUsage(PERSONAL, 0.5, 0.5, provider); + setUsage(CHEAP, 0.2, 0.2, provider); + setUsage(SHARED, 0.8, 0.1, provider); + const storage = await createStorage([prefer(SHARED, 75), prefer(CHEAP)], { provider }); - expect(await storage.getApiKey("anthropic", "unknown-usage")).toBe("token-cheap"); + expect(await storage.getApiKey(provider, `${provider}-cap`)).toBe(token(CHEAP)); }); - test("a quota failure on the preferred account rotates away for the rest of the session", async () => { - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); + test("a provider without usage reporting follows list order and treats caps as not reached", async () => { + const storage = await createStorage([prefer(SHARED, 10), prefer(CHEAP)], { reportsUsage: false }); - expect(await storage.getApiKey("anthropic", "quota")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "no-usage")).toBe(token(SHARED)); + }); + + test("unknown usage for one account does not count as over its cap", async () => { + setUsage(PERSONAL, 0.1, 0.1); + const storage = await createStorage([prefer(CHEAP, 50)], { emails: [PERSONAL, CHEAP] }); + + expect(await storage.getApiKey("anthropic", "unknown-usage")).toBe(token(CHEAP)); + }); + + test("a quota failure during a session moves to the next listed account", async () => { + const storage = await createStorage([prefer(CHEAP), prefer(SHARED, 75)]); + + expect(await storage.getApiKey("anthropic", "quota")).toBe(token(CHEAP)); expect(await storage.markUsageLimitReached("anthropic", "quota", { retryAfterMs: 60_000 })).toMatchObject({ state: "marked", }); - expect(await storage.getApiKey("anthropic", "quota")).toBe("token-personal"); - expect(await storage.getApiKey("anthropic", "quota-next-session")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "quota")).toBe(token(SHARED)); }); - test("a preference that matches no stored account leaves ranking untouched", async () => { - usageByEmail.set("personal@example.test", report("personal@example.test", 0.5, 0.5)); - usageByEmail.set("cheap@example.test", report("cheap@example.test", 0.1, 0.1)); - const storage = await createStorage({ selector: { kind: "email", value: "removed@example.test" } }); + test("a session keeps its account after crossing the cap instead of switching mid-session", async () => { + setUsage(PERSONAL, 0.5, 0.5); + setUsage(SHARED, 0.5, 0.1); + const storage = await createStorage([prefer(SHARED, 75)], { emails: [PERSONAL, SHARED] }); - expect(await storage.getApiKey("anthropic", "missing")).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", "sticky")).toBe(token(SHARED)); + setUsage(SHARED, 0.9, 0.1); + expect(await storage.getApiKey("anthropic", "sticky")).toBe(token(SHARED)); + }); + + test("a listed account that is not stored is skipped", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.5, 0.5); + setUsage(SHARED, 0.3, 0.3); + const storage = await createStorage([prefer("removed@example.test"), prefer(CHEAP)]); + + expect(await storage.getApiKey("anthropic", "missing")).toBe(token(CHEAP)); + }); + + describe("a list that names one stored account twice", () => { + const CHEAP_ACCOUNT = "acct-cheap"; + const aliasedPreferences: ConfiguredCredentialPreference[] = [ + { selector: { kind: "email", value: CHEAP }, maxUsedFraction: 0.5 }, + prefer(SHARED), + { selector: { kind: "account", value: CHEAP_ACCOUNT } }, + ]; + + async function createAliasedStorage( + preferences: readonly ConfiguredCredentialPreference[], + credentials: readonly OAuthCredential[], + ): Promise { + const store = await SqliteAuthCredentialStore.open(":memory:"); + openStores.push(store); + const storage = new AuthStorage(store, { + usageProviderResolver: () => ({ + id: "anthropic", + async fetchUsage(params) { + const email = params.credential.email; + return email ? (usageByEmail.get(email) ?? null) : null; + }, + }), + credentialPreferences: { anthropic: preferences }, + }); + await storage.set("anthropic", [...credentials]); + return storage; + } + + test("through email and account selectors is reported with the shared row", async () => { + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL), + oauth(CHEAP, CHEAP_ACCOUNT), + oauth(SHARED), + ]); + const cheapRow = storage.listCredentialInventory("anthropic").find(row => row.email === CHEAP); + + expect(storage.findDuplicateCredentialPreferences()).toEqual([ + { provider: "anthropic", credentialId: cheapRow!.id }, + ]); + }); + + test("is ignored as a whole instead of honoring only the first entry's cap", async () => { + setUsage(PERSONAL, 0.1, 0.1); + setUsage(CHEAP, 0.6, 0.1); + setUsage(SHARED, 0.3, 0.3); + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL), + oauth(CHEAP, CHEAP_ACCOUNT), + oauth(SHARED), + ]); + + // Applied, the list would pick SHARED (CHEAP is over its 50% cap); ignored, balanced ranking picks PERSONAL. + expect(await storage.getApiKey("anthropic", "aliased")).toBe(token(PERSONAL)); + }); + + test("is not reported when the aliases designate different rows", async () => { + const storage = await createAliasedStorage(aliasedPreferences, [ + oauth(PERSONAL, CHEAP_ACCOUNT), + oauth(CHEAP), + oauth(SHARED), + ]); + + expect(storage.findDuplicateCredentialPreferences()).toEqual([]); + }); }); test.each([ @@ -186,26 +300,22 @@ describe("configured credential preference", () => { ] as const)("%s selects the designated OAuth row in a pool that also stores an API key", async (_label, source) => { const store = await SqliteAuthCredentialStore.open(":memory:"); openStores.push(store); - const selector = { kind: "email", value: "cheap@example.test" } as const; + const selector = { kind: "email", value: CHEAP } as const; const storage = new AuthStorage(store, { rankingStrategyResolver: () => undefined, configValueResolver: async value => (value === "unusable-api-key" ? undefined : value), - ...(source === "configured" ? { credentialPreferences: { anthropic: { selector } } } : {}), + ...(source === "configured" ? { credentialPreferences: { anthropic: [{ selector }] } } : {}), }); - await storage.set("anthropic", [ - { type: "api_key", key: "unusable-api-key" }, - oauth("token-cheap", "cheap@example.test"), - oauth("token-personal", "personal@example.test"), - ]); + await storage.set("anthropic", [{ type: "api_key", key: "unusable-api-key" }, oauth(CHEAP), oauth(PERSONAL)]); if (source === "runtime") storage.setRuntimePreferredCredentialSelector("anthropic", selector); - expect(await storage.getApiKey("anthropic", `mixed-${source}`)).toBe("token-cheap"); + expect(await storage.getApiKey("anthropic", `mixed-${source}`)).toBe(token(CHEAP)); }); - test("a runtime --prefer-credential selector outranks the configured preference", async () => { - const storage = await createStorage({ selector: { kind: "email", value: "cheap@example.test" } }); - storage.setRuntimePreferredCredentialSelector("anthropic", { kind: "email", value: "personal@example.test" }); + test("a runtime --prefer-credential selector outranks configured preferences", async () => { + const storage = await createStorage([prefer(CHEAP)]); + storage.setRuntimePreferredCredentialSelector("anthropic", { kind: "email", value: PERSONAL }); - expect(await storage.getApiKey("anthropic", "runtime-wins")).toBe("token-personal"); + expect(await storage.getApiKey("anthropic", "runtime-wins")).toBe(token(PERSONAL)); }); }); diff --git a/packages/coding-agent/changelog.d/configured-credential-preference.md b/packages/coding-agent/changelog.d/configured-credential-preference.md index 3ed7c805fcf..3b9cad1bb9d 100644 --- a/packages/coding-agent/changelog.d/configured-credential-preference.md +++ b/packages/coding-agent/changelog.d/configured-credential-preference.md @@ -1,3 +1,4 @@ ### Added -- Added global `auth.credentialPreferences` (provider → `email:`/`account:` selector) and `auth.credentialPreferenceMaxUsedPercent` (greater than 0 and at most 100, default 100) config. A preferred account, such as a lower-cost plan, is used first and the provider falls back to normal ranking once that account reaches the usage threshold or hits a quota/rate-limit failure, without the session-blocking behavior of `auth.credentialPins`. Both keys are global-only and ignored in project settings. +- Added global `auth.credentialPreferences`: an ordered list of preferred OAuth accounts per provider, each optionally capped by a usage percent (`- low-cost@example.com` then `- shared@example.com: 75`). The first listed account that is usable and under its cap is used; once every listed account is rate-limited or at its cap, the remaining accounts are ranked normally. Unlike `auth.credentialPins`, a preference never blocks the provider. The key is global-only and ignored in project settings. Startup fails closed when two entries name the same stored account, including through different selectors such as an email and `account:`. +- Added `gjc accounts prefer [=]...` to write that list without editing YAML. It checks every account against the stored OAuth rows, stores portable email/account selectors in a form startup reads back (`email:
` when the bare text would not read back as the same address), and writes nothing when any account is missing, ambiguous, duplicated, or has an invalid cap. `gjc accounts prefer --clear` removes the list. diff --git a/packages/coding-agent/src/cli/accounts-cli.ts b/packages/coding-agent/src/cli/accounts-cli.ts index 090e1106b70..9796641b32f 100644 --- a/packages/coding-agent/src/cli/accounts-cli.ts +++ b/packages/coding-agent/src/cli/accounts-cli.ts @@ -26,15 +26,21 @@ import { clearPersistentPinForRemovedRows, } from "../session/account-inventory"; import { CREDENTIAL_STORE_UNREADABLE_MESSAGE } from "../session/credential-store-errors"; -import { resolveStartupAuthConfig, type StartupAuthConfigSnapshot } from "../session/startup-auth-config"; +import { + type CredentialPreferenceEntryValue, + isValidCredentialPreferenceCap, + parsePreferenceAccount, + resolveStartupAuthConfig, + type StartupAuthConfigSnapshot, +} from "../session/startup-auth-config"; -export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "logout"] as const; +export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "prefer", "logout"] as const; export type AccountsAction = (typeof ACCOUNTS_ACTIONS)[number]; export interface AccountsCommandArgs { action: AccountsAction; provider?: string; - selector?: string; + selectors?: readonly string[]; flags: { json?: boolean; persistent?: boolean; @@ -288,6 +294,147 @@ async function runPin( }); } +/** + * Split `=` into the account and its usage cap. The cap follows the whole account, so an `=` before + * the last `@` belongs to an email local part (`user=2@example.com`), and a suffix that does not start with a digit is + * part of the account (account ids may end in `=`); a digit-led suffix must be a valid cap. + */ +function parsePreferArgument(raw: string): { account: string; cap: number | undefined } { + const separator = raw.lastIndexOf("="); + const rawCap = separator < 0 || separator < raw.lastIndexOf("@") ? "" : raw.slice(separator + 1).trim(); + if (!/^\d/.test(rawCap)) return { account: raw.trim(), cap: undefined }; + const cap = /^\d+(\.\d+)?$/.test(rawCap) ? Number(rawCap) : Number.NaN; + if (!isValidCredentialPreferenceCap(cap)) { + throw new AccountsCommandError( + `Usage cap in ${JSON.stringify(raw)} must be a percent above 0 and at most 100, e.g. me@example.com=75; no preference was written.`, + ); + } + return { account: raw.slice(0, separator).trim(), cap }; +} + +/** + * Portable config text for a resolved OAuth row: its email, else its account id, whichever no other OAuth row of the + * provider shares. Numeric row ids are never persisted as preferences; a row with neither unique value is refused. + * Each candidate text is written only when {@link parsePreferenceAccount} reads it back as the same selector, so a + * successful write neither fails the next startup nor retargets the row: the email is written bare when that + * round-trips, else as `email:
` (an address such as `account:team@example.com` would otherwise parse as an + * account id, and `email:team@example.com` would lose its prefix). + */ +function portablePreferenceAccount( + row: CredentialInventoryRecord, + providerRows: readonly CredentialInventoryRecord[], +): string | undefined { + const oauthRows = providerRows.filter(other => other.credentialKind === "oauth"); + const email = row.email?.trim().toLowerCase(); + if (email && oauthRows.filter(other => other.email?.trim().toLowerCase() === email).length === 1) { + const selector: AuthCredentialSelector = { kind: "email", value: row.email!.trim() }; + const written = [selector.value, `email:${selector.value}`].find(text => readsBackAs(text, selector)); + if (written) return written; + } + const accountId = row.accountId?.trim(); + if (accountId && oauthRows.filter(other => other.accountId?.trim() === accountId).length === 1) { + const written = `account:${accountId}`; + if (readsBackAs(written, { kind: "account", value: accountId })) return written; + } + return undefined; +} + +/** Whether startup parses `text` as exactly `selector`, kind and value. */ +function readsBackAs(text: string, selector: AuthCredentialSelector): boolean { + const parsed = parsePreferenceAccount(text); + return parsed?.kind === selector.kind && parsed.value === selector.value; +} + +async function writePersistentPreferences( + provider: string, + entries: readonly CredentialPreferenceEntryValue[] | undefined, +): Promise { + const settings = await Settings.init(); + await settings.commitAtomicBatchWithCurrent(current => { + const currentAuth = current.auth === undefined ? {} : asRecord(current.auth); + const rawPreferences = currentAuth?.credentialPreferences; + const preferences = rawPreferences === undefined ? {} : asRecord(rawPreferences); + if (!currentAuth || !preferences) { + throw new AccountsCommandError("Global auth.credentialPreferences is malformed; no preference was written."); + } + const next: Record = { ...preferences }; + if (entries) next[provider] = entries; + else delete next[provider]; + return [{ path: "auth.credentialPreferences", op: "set", value: next }]; + }); +} + +async function runPrefer( + providerArg: string | undefined, + accountArgs: readonly string[] | undefined, + flags: AccountsCommandArgs["flags"], +): Promise { + const provider = canonicalProvider(providerArg); + const accounts = accountArgs ?? []; + if (flags.clear && accounts.length > 0) { + throw new AccountsCommandError("Use either preferred accounts or --clear, not both."); + } + if (!flags.clear && accounts.length === 0) { + throw new AccountsCommandError( + "Prefer requires one or more accounts in order (e.g. me@example.com shared@example.com=75), or --clear.", + ); + } + if (flags.clear) { + await writePersistentPreferences(provider, undefined); + if (flags.json) writeJson({ ok: true, provider, cleared: true }); + else writeText([`Cleared preferred accounts for ${provider}; selection uses normal ranking.`]); + return; + } + + const requested = accounts.map(parsePreferArgument); + await withAuthStorageAndModels(async (storage, modelRegistry, startupAuth) => { + if ( + !modelRegistry.getConfiguredProviderIds().includes(provider) && + modelRegistry.getAll().every(model => model.provider !== provider) + ) { + throw new AccountsCommandError(`Provider ${provider} is not configured; no preference was written.`); + } + const providerRows = storage.listCredentialInventory(provider); + const seenIds = new Set(); + const resolved = requested.map(({ account, cap }) => { + let target: OAuthPinTarget; + try { + target = storage.resolveOAuthPinTarget(provider, selectorFromInput(account)); + } catch (error) { + throw toAccountsCommandError(error) ?? error; + } + if (seenIds.has(target.credentialId)) { + throw new AccountsCommandError(`${account} is listed more than once; no preference was written.`); + } + seenIds.add(target.credentialId); + const row = providerRows.find(candidate => candidate.id === target.credentialId); + const portable = row ? portablePreferenceAccount(row, providerRows) : undefined; + if (!portable) { + throw new AccountsCommandError( + `${account} has no unique email or account id to store as a preference; no preference was written.`, + ); + } + return { account: portable, cap: cap === 100 ? undefined : cap }; + }); + const entries: CredentialPreferenceEntryValue[] = resolved.map(({ account, cap }) => + cap === undefined ? account : { [account]: cap }, + ); + await writePersistentPreferences(provider, entries); + if (flags.json) { + writeJson({ ok: true, provider, preferences: entries }); + return; + } + writeText([ + `Preferred accounts for ${provider}, tried in order:`, + ...resolved.map( + ({ account, cap }, index) => + ` ${index + 1}. ${account}${cap === undefined ? " (until rate-limited)" : ` (until ${cap}% used)`}`, + ), + `Then: remaining accounts by ${startupAuth.credentialRankingMode} ranking. Takes effect for new sessions.`, + ]); + }); +} + function accountMatches(row: CredentialInventoryRecord, rawAccount: string): boolean { const value = rawAccount.trim(); if (value.length === 0) return false; @@ -398,7 +545,13 @@ export async function runAccountsCommand(cmd: AccountsCommandArgs): Promise 1) { + throw new AccountsCommandError("Pin takes one selector; use `gjc accounts prefer` for an ordered list."); + } + await runPin(cmd.provider, cmd.selectors?.[0], cmd.flags); + return; + case "prefer": + await runPrefer(cmd.provider, cmd.selectors, cmd.flags); return; case "logout": await runLogout(cmd.provider, cmd.flags); diff --git a/packages/coding-agent/src/cli/fast-help.ts b/packages/coding-agent/src/cli/fast-help.ts index 14abab7610d..d6abf4ea44e 100644 --- a/packages/coding-agent/src/cli/fast-help.ts +++ b/packages/coding-agent/src/cli/fast-help.ts @@ -8,7 +8,7 @@ export function getExtraHelpText(): string { ${APP_NAME} session - List, inspect, create, remove, or attach sessions ${APP_NAME} state - Inspect or manage persisted GJC state ${APP_NAME} auth-broker - Manage the auth-broker (credential vault) - ${APP_NAME} accounts - List, check, pin, and remove stored OAuth accounts + ${APP_NAME} accounts - List, check, pin, prefer, and remove stored OAuth accounts ${APP_NAME} models presets - Inspect, refresh, pin, rollback, or disable the signed model preset registry ${APP_NAME} auth-gateway - Run an auth-gateway forward proxy ${APP_NAME} harness - Run harness control-plane commands diff --git a/packages/coding-agent/src/commands/accounts.ts b/packages/coding-agent/src/commands/accounts.ts index 39573f14fef..367f83d7c5c 100644 --- a/packages/coding-agent/src/commands/accounts.ts +++ b/packages/coding-agent/src/commands/accounts.ts @@ -8,7 +8,7 @@ import { } from "../cli/accounts-cli"; export default class Accounts extends Command { - static description = "List, check, pin, and remove stored OAuth accounts"; + static description = "List, check, pin, prefer, and remove stored OAuth accounts"; static strict = true; static args = { @@ -22,15 +22,17 @@ export default class Accounts extends Command { required: false, }), selector: Args.string({ - description: "Account selector (bare email, id:, email:, or account:)", + description: + "Account selector (bare email, id:, email:, or account:); prefer takes several in order, each optionally =", required: false, + multiple: true, }), }; static flags = { json: Flags.boolean({ description: "Output safe machine-readable JSON" }), persistent: Flags.boolean({ description: "Required for persistent pin changes" }), - clear: Flags.boolean({ description: "Clear the persistent pin" }), + clear: Flags.boolean({ description: "Clear the persistent pin or preferred accounts" }), account: Flags.string({ description: "Logout one account by row id, email, or org:" }), all: Flags.boolean({ description: "Logout every OAuth account for the provider" }), }; @@ -44,6 +46,8 @@ export default class Accounts extends Command { "gjc accounts pin anthropic id:42 --persistent", "gjc accounts pin anthropic 'org:Acme Team' --persistent", "gjc accounts pin anthropic --clear --persistent", + "gjc accounts prefer anthropic cheap@example.com shared@example.com=75", + "gjc accounts prefer anthropic --clear", "gjc accounts logout anthropic --account me@example.com", "gjc accounts logout anthropic --all", ]; @@ -58,7 +62,7 @@ export default class Accounts extends Command { const cmd: AccountsCommandArgs = { action, provider: args.provider, - selector: args.selector, + selectors: Array.isArray(args.selector) ? args.selector : args.selector ? [args.selector] : undefined, flags: { json: flags.json, persistent: flags.persistent, diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index ca100cd1c3c..99c6382679d 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1,5 +1,6 @@ import type { Effort } from "@gajae-code/ai/model-thinking"; import { PET_MODE_IDS, PET_SKIN_IDS, PET_SKINS } from "@gajae-code/tui/components/gajae-pet"; +import { type CredentialPreferenceEntryValue, parseCredentialPreferenceList } from "../session/startup-auth-config"; import { TASK_SIMPLE_MODES } from "../task/simple-mode"; import { getThinkingLevelMetadata } from "../thinking-metadata"; import { DEFAULT_EDIT_MODE_SETTING, EDIT_MODE_SETTINGS, EDIT_MODES, type EditMode } from "../utils/edit-mode"; @@ -215,7 +216,10 @@ type RecordValueDef = | { type: "model-selector-value" } | { type: "string-enum"; values: readonly string[] } | { type: "credential-selector" } - | { type: "portable-credential-selector" }; + | { type: "credential-preference-list" }; + +/** A provider's `auth.credentialPreferences` value: one account or a non-empty ordered list of entries. */ +export type CredentialPreferenceSettingValue = string | CredentialPreferenceEntryValue[]; interface ConstrainedRecordValueDef { type: "autorouting-selector-value"; @@ -453,17 +457,12 @@ export const SETTINGS_SCHEMA = { valueSchema: { type: "credential-selector" } as const, }, "auth.credentialPinStoreIdentity": { type: "string", default: undefined }, - // Soft per-provider OAuth account preference. Global-only, like pins; read at startup. + // Ordered soft per-provider OAuth account preferences with optional per-entry usage caps. Global-only, like pins; + // read at startup. "auth.credentialPreferences": { type: "record", - default: {} as Record, - valueSchema: { type: "portable-credential-selector" } as const, - }, - // Usage percent at which a preferred account yields to the normal ranking; 100 yields only once it is blocked. - "auth.credentialPreferenceMaxUsedPercent": { - type: "number", - default: 100, - validate: (value: number) => Number.isFinite(value) && value > 0 && value <= 100, + default: {} as Record, + valueSchema: { type: "credential-preference-list" } as const, }, "session.directoryMigration": { type: "enum", @@ -4401,8 +4400,8 @@ function validRecordValue(valueSchema: RecordValueDef, value: unknown): boolean /^account:\S+$/.test(value)) ); } - if (valueSchema.type === "portable-credential-selector") { - return typeof value === "string" && (/^email:[^@\s]+@[^@\s]+$/.test(value) || /^account:\S+$/.test(value)); + if (valueSchema.type === "credential-preference-list") { + return parseCredentialPreferenceList(value) !== undefined; } return typeof value === "string" && valueSchema.values.includes(value); } diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 6befb61f318..9a71b211c77 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -316,7 +316,6 @@ const GLOBAL_ONLY_AUTH_SETTING_KEYS = new Set([ "credentialPins", "credentialPinStoreIdentity", "credentialPreferences", - "credentialPreferenceMaxUsedPercent", ]); const LOCAL_NOTIFICATION_SETTING_PATHS = new Set( [...LOCAL_NOTIFICATION_SETTING_KEYS].map(key => `notifications.${key}`), diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 34ccbbdcb2e..7bfeb18c433 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -189,6 +189,7 @@ import { parsePersistedCredentialSelector, resolveStartupAuthConfig, type StartupAuthConfigSnapshot, + startupAuthConfigError, } from "../session/startup-auth-config"; import { isOwnedCompletionEnvelopeAllowed, @@ -795,7 +796,7 @@ export async function discoverAuthStorage( credentialRankingMode, credentialPreferences, }); - return await reloadDiscoveredAuthStorage(storage); + return rejectDuplicateCredentialPreferences(await reloadDiscoveredAuthStorage(storage), agentDir); } const dbPath = getAgentDbPath(agentDir); const storage = await AuthStorage.create(dbPath, { @@ -804,7 +805,7 @@ export async function discoverAuthStorage( credentialRankingMode, credentialPreferences, }); - return await reloadDiscoveredAuthStorage(storage); + return rejectDuplicateCredentialPreferences(await reloadDiscoveredAuthStorage(storage), agentDir); } async function reloadDiscoveredAuthStorage(storage: AuthStorage): Promise { @@ -821,6 +822,26 @@ async function reloadDiscoveredAuthStorage(storage: AuthStorage): Promise sanitizeProviderForLog(provider)), + }); + const error = startupAuthConfigError(agentDir, "invalid-credential-preferences"); + try { + storage.close(); + } catch (cleanupError) { + throw attachStartupCleanupDiagnostic(error, cleanupError); + } + throw error; +} + /** Ranking is resolved as part of the typed startup-auth snapshot. */ /** diff --git a/packages/coding-agent/src/session/startup-auth-config.ts b/packages/coding-agent/src/session/startup-auth-config.ts index 48608682338..20d2ac2bb73 100644 --- a/packages/coding-agent/src/session/startup-auth-config.ts +++ b/packages/coding-agent/src/session/startup-auth-config.ts @@ -31,8 +31,8 @@ export interface StartupAuthConfigSnapshot { credentialPinStoreIdentity?: string; credentialRankingMode: CredentialRankingMode; credentialPins: Readonly>; - /** Soft per-provider preferences; only portable `email:`/`account:` selectors are accepted. */ - credentialPreferences: Readonly>; + /** Ordered soft per-provider preferences; only portable email/`account:` selectors are accepted. */ + credentialPreferences: Readonly>; } export type StartupAuthConfigErrorKind = @@ -72,7 +72,6 @@ const LEGACY_LITERAL_AUTH_KEYS = new Set([ "auth.credentialRankingMode", "auth.credentialPins", "auth.credentialPreferences", - "auth.credentialPreferenceMaxUsedPercent", ]); function configPathForAgentDir(agentDir: string): string { @@ -87,7 +86,7 @@ function shortenConfigPath(configPath: string): string { return absolutePath.startsWith(homePrefix) ? `~${absolutePath.slice(homePath.length)}` : absolutePath; } -function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError { +export function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError { return new StartupAuthConfigError(kind, configPathForAgentDir(agentDir)); } @@ -109,20 +108,69 @@ export function isValidPersistedCredentialSelector(value: string): boolean { return parsePersistedCredentialSelector(value) !== undefined; } +/** JSON-schema pattern for a preference account; shared with {@link parsePreferenceAccount}. */ +export const PREFERENCE_ACCOUNT_PATTERN = "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$"; + /** - * Validate the selector grammar used by `auth.credentialPreferences`. Numeric row ids are excluded: they are bound - * to one credential store, and a preference has no store-identity binding to detect a retargeted row. + * Parse one `auth.credentialPreferences` account: a bare email, `email:
`, or `account:`. Numeric row ids + * are excluded: they are bound to one credential store, and a preference has no store-identity binding to detect a + * retargeted row. */ -export function parsePortableCredentialSelector(value: string): AuthCredentialSelector | undefined { - const selector = parsePersistedCredentialSelector(value); - return selector?.kind === "id" ? undefined : selector; +export function parsePreferenceAccount(value: string): AuthCredentialSelector | undefined { + const trimmed = value.trim(); + const selector = parsePersistedCredentialSelector(trimmed); + if (selector) return selector.kind === "id" ? undefined : selector; + return /^[^@\s:]+@[^@\s]+$/.test(trimmed) ? { kind: "email", value: trimmed } : undefined; } -/** `auth.credentialPreferenceMaxUsedPercent` accepts (0, 100]; 100 rotates away only once the account is blocked. */ -export function isValidCredentialPreferenceMaxUsedPercent(value: unknown): value is number { +/** A preference cap accepts (0, 100]; 100 moves past the account only once it is blocked. */ +export function isValidCredentialPreferenceCap(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100; } +/** Canonical config text for one preference entry: the account, or a one-key `{ account: cap }` mapping. */ +export type CredentialPreferenceEntryValue = string | Readonly>; + +function parseCredentialPreferenceEntry(value: unknown): ConfiguredCredentialPreference | undefined { + if (typeof value === "string") { + const selector = parsePreferenceAccount(value); + return selector ? { selector } : undefined; + } + const entry = asRecord(value); + const fields = entry ? Object.entries(entry) : []; + if (fields.length !== 1) return undefined; + const [account, cap] = fields[0]!; + const selector = parsePreferenceAccount(account); + if (!selector || !isValidCredentialPreferenceCap(cap)) return undefined; + return cap === 100 ? { selector } : { selector, maxUsedFraction: cap / 100 }; +} + +function preferenceSelectorKey(selector: AuthCredentialSelector): string { + return `${selector.kind}:${selector.kind === "email" ? selector.value.toLowerCase() : selector.value}`; +} + +/** + * Parse one provider's `auth.credentialPreferences` value: a single account, or a non-empty ordered list whose + * entries are an account (`me@example.com`) or an account with a usage cap in percent (`me@example.com: 75`). + * Returns undefined for any invalid value, including an account listed twice, so a typo fails closed instead of + * silently changing the order. + */ +export function parseCredentialPreferenceList(value: unknown): ConfiguredCredentialPreference[] | undefined { + const rawEntries = Array.isArray(value) ? value : [value]; + if (rawEntries.length === 0) return undefined; + const entries: ConfiguredCredentialPreference[] = []; + const seen = new Set(); + for (const rawEntry of rawEntries) { + const entry = parseCredentialPreferenceEntry(rawEntry); + if (!entry) return undefined; + const key = preferenceSelectorKey(entry.selector); + if (seen.has(key)) return undefined; + seen.add(key); + entries.push(entry); + } + return entries; +} + function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -169,8 +217,9 @@ function throwLegacyLiteralKeyError(keys: string[]): never { " credentialPins:\n" + " : id:\n" + " credentialPreferences:\n" + - " : email:
\n" + - " credentialPreferenceMaxUsedPercent: <0-100, exclusive of 0>\n" + + " :\n" + + " - \n" + + " - : \n" + "Do not copy secret values into command output; rewrite the file by hand.", ); } @@ -201,27 +250,21 @@ function readCredentialPins( function readCredentialPreferences( auth: Record | undefined, configPath: string, -): Readonly> { - const rawMaxUsedPercent = auth?.credentialPreferenceMaxUsedPercent; - if (rawMaxUsedPercent !== undefined && !isValidCredentialPreferenceMaxUsedPercent(rawMaxUsedPercent)) { - throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - } +): Readonly> { const rawPreferences = auth?.credentialPreferences; if (rawPreferences === undefined) return {}; const preferences = asRecord(rawPreferences); if (!preferences) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - const maxUsedFraction = - rawMaxUsedPercent === undefined || rawMaxUsedPercent === 100 ? undefined : rawMaxUsedPercent / 100; - const result: Record = {}; - for (const [provider, rawSelector] of Object.entries(preferences)) { + const result: Record = {}; + for (const [provider, rawList] of Object.entries(preferences)) { const normalizedProvider = provider.trim(); if (normalizedProvider.length === 0 || /[\u0000-\u001f\u007f]/u.test(normalizedProvider)) { throw new StartupAuthConfigError("invalid-credential-preferences", configPath); } - const selector = typeof rawSelector === "string" ? parsePortableCredentialSelector(rawSelector) : undefined; - if (!selector) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); - result[normalizedProvider] = maxUsedFraction === undefined ? { selector } : { selector, maxUsedFraction }; + const entries = parseCredentialPreferenceList(rawList); + if (!entries) throw new StartupAuthConfigError("invalid-credential-preferences", configPath); + result[normalizedProvider] = entries; } return result; } @@ -230,7 +273,7 @@ interface GlobalStartupAuthYaml { auth: Record | undefined; credentialPinStoreIdentity: string | undefined; credentialPins: Readonly>; - credentialPreferences: Readonly>; + credentialPreferences: Readonly>; } const EMPTY_GLOBAL_STARTUP_AUTH: GlobalStartupAuthYaml = { diff --git a/packages/coding-agent/test/accounts-logout-org.test.ts b/packages/coding-agent/test/accounts-logout-org.test.ts index 4be7ffdaff6..c4b5f8e99b1 100644 --- a/packages/coding-agent/test/accounts-logout-org.test.ts +++ b/packages/coding-agent/test/accounts-logout-org.test.ts @@ -103,7 +103,7 @@ describe("gjc accounts with org: selectors", () => { const result = await accountsJson({ action: "pin", provider: "anthropic", - selector: "org:Acme", + selectors: ["org:Acme"], flags: { persistent: true, json: true }, }); diff --git a/packages/coding-agent/test/accounts-prefer.test.ts b/packages/coding-agent/test/accounts-prefer.test.ts new file mode 100644 index 00000000000..ba7e9dc0ced --- /dev/null +++ b/packages/coding-agent/test/accounts-prefer.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { SqliteAuthCredentialStore } from "@gajae-code/ai"; +import { getAgentDbPath, resetAgentDirFromEnvironment, setAgentDir } from "@gajae-code/utils"; +import { YAML } from "bun"; +import { runAccountsCommand } from "../src/cli/accounts-cli"; +import { resetSettingsForTest } from "../src/config/settings"; +import { discoverAuthStorage } from "../src/sdk/session"; +import { resolveStartupAuthConfig } from "../src/session/startup-auth-config"; + +// Each command run opens the credential store and refreshes the offline model registry, which exceeds the default +// timeout when the suite runs alongside other files. +const CLI_TIMEOUT_MS = 30_000; + +type PreferJson = { ok: boolean; preferences?: unknown[]; cleared?: boolean; error?: { message: string } }; + +function oauth(email: string, accountId: string, organizationId?: string) { + return { + type: "oauth" as const, + access: `access-${email}-${organizationId ?? "personal"}`, + refresh: `refresh-${email}-${organizationId ?? "personal"}`, + expires: Date.now() + 3_600_000, + email, + accountId, + ...(organizationId ? { organizationId, organizationName: organizationId } : {}), + }; +} + +async function preferJson(selectors: string[] | undefined, clear = false): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: Uint8Array | string): boolean => { + chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stdout.write; + const priorExitCode = process.exitCode; + try { + await runAccountsCommand({ action: "prefer", provider: "anthropic", selectors, flags: { json: true, clear } }); + } finally { + process.stdout.write = original as typeof process.stdout.write; + process.exitCode = priorExitCode ?? 0; + } + return JSON.parse(chunks.join("")); +} + +describe("gjc accounts prefer", () => { + let agentDir = ""; + let originalAgentDir: string | undefined; + let ids: number[] = []; + + beforeEach(async () => { + originalAgentDir = process.env.GJC_CODING_AGENT_DIR; + agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-accounts-prefer-")); + setAgentDir(agentDir); + resetSettingsForTest(); + const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); + try { + for (const credential of [ + oauth("cheap@example.com", "acct-cheap"), + oauth("shared@example.com", "acct-shared"), + // One login that belongs to two organizations: neither its email nor its account id is unique. + oauth("team@example.com", "acct-team", "org-a"), + oauth("team@example.com", "acct-team", "org-b"), + ]) { + store.upsertAuthCredentialForProvider("anthropic", credential); + } + ids = store.listAuthCredentials("anthropic").map(entry => entry.id); + expect(ids).toHaveLength(4); + } finally { + store.close(); + } + }); + + afterEach(async () => { + if (originalAgentDir === undefined) delete process.env.GJC_CODING_AGENT_DIR; + else process.env.GJC_CODING_AGENT_DIR = originalAgentDir; + resetAgentDirFromEnvironment(); + resetSettingsForTest(); + await fs.rm(agentDir, { recursive: true, force: true }); + }); + + async function writtenPreferences(): Promise { + const file = Bun.file(path.join(agentDir, "config.yml")); + if (!(await file.exists())) return undefined; + const parsed = YAML.parse(await file.text()) as { auth?: { credentialPreferences?: unknown } } | null; + return parsed?.auth?.credentialPreferences; + } + + it( + "writes the ordered list in the short config form that startup reads back", + async () => { + const result = await preferJson(["cheap@example.com", `id:${ids[1]}=75`]); + + expect(result).toEqual({ + ok: true, + provider: "anthropic", + preferences: ["cheap@example.com", { "shared@example.com": 75 }], + } as never); + expect(await writtenPreferences()).toEqual({ + anthropic: ["cheap@example.com", { "shared@example.com": 75 }], + }); + expect((await resolveStartupAuthConfig(agentDir)).credentialPreferences).toEqual({ + anthropic: [ + { selector: { kind: "email", value: "cheap@example.com" } }, + { selector: { kind: "email", value: "shared@example.com" }, maxUsedFraction: 0.75 }, + ], + }); + }, + CLI_TIMEOUT_MS, + ); + + it.each([ + ["a colon in its local part, in the email: form", "odd:name@example.com", "email:odd:name@example.com", "75"], + ["an account: prefix, in the email: form", "account:team@example.com", "email:account:team@example.com", "75"], + ["an email: prefix, in the email: form", "email:team@example.com", "email:email:team@example.com", "75"], + ["an equals sign in its local part, bare", "user=2@example.com", "user=2@example.com", undefined], + ])( + "stores an email with %s that startup reads back as the same row", + async (_label, email, written, cap) => { + const store = await SqliteAuthCredentialStore.open(getAgentDbPath()); + let rowId: number; + try { + store.upsertAuthCredentialForProvider("anthropic", oauth(email, `acct-${ids.length}`)); + rowId = store.listAuthCredentials("anthropic").find(entry => !ids.includes(entry.id))!.id; + } finally { + store.close(); + } + const argument = cap === undefined ? email : `id:${rowId}=${cap}`; + + const result = await preferJson([argument]); + + const entry = cap === undefined ? written : { [written]: Number(cap) }; + expect(result.preferences).toEqual([entry]); + expect(await writtenPreferences()).toEqual({ anthropic: [entry] }); + const [preference] = (await resolveStartupAuthConfig(agentDir)).credentialPreferences.anthropic ?? []; + expect(preference?.selector).toEqual({ kind: "email", value: email }); + const storage = await discoverAuthStorage(agentDir); + try { + expect(storage.resolveOAuthPinTarget("anthropic", preference!.selector).credentialId).toBe(rowId); + } finally { + storage.close(); + } + }, + CLI_TIMEOUT_MS, + ); + + it( + "stores a 100 percent cap as an uncapped account", + async () => { + await preferJson(["cheap@example.com=100"]); + + expect(await writtenPreferences()).toEqual({ anthropic: ["cheap@example.com"] }); + }, + CLI_TIMEOUT_MS, + ); + + it.each([ + ["an account that is not stored", ["missing@example.com"], "No active OAuth credential"], + ["a zero cap", ["cheap@example.com=0"], "above 0 and at most 100"], + ["a cap over 100", ["cheap@example.com=101"], "above 0 and at most 100"], + ["a malformed cap", ["cheap@example.com=7o"], "above 0 and at most 100"], + ["the same account twice", ["cheap@example.com", "id:" /* filled below */], "listed more than once"], + ["an account with no unique email or account id", ["org:org-a"], "no unique email or account id"], + ])( + "refuses %s and writes nothing", + async (_label, selectors, message) => { + const args = selectors.map(value => (value === "id:" ? `id:${ids[0]}` : value)); + const result = await preferJson(args); + + expect(result.ok).toBe(false); + expect(result.error?.message).toContain(message); + expect(await writtenPreferences()).toBeUndefined(); + }, + CLI_TIMEOUT_MS, + ); + + it( + "clears only the named provider", + async () => { + await preferJson(["cheap@example.com"]); + const result = await preferJson(undefined, true); + + expect(result).toEqual({ ok: true, provider: "anthropic", cleared: true } as never); + expect(await writtenPreferences()).toEqual({}); + }, + CLI_TIMEOUT_MS, + ); + + it( + "requires accounts or --clear, not both", + async () => { + expect((await preferJson(undefined)).error?.message).toContain("one or more accounts"); + expect((await preferJson(["cheap@example.com"], true)).error?.message).toContain("not both"); + }, + CLI_TIMEOUT_MS, + ); +}); diff --git a/packages/coding-agent/test/startup-auth-config.test.ts b/packages/coding-agent/test/startup-auth-config.test.ts index 5d0354c6b91..8c69a07f9d2 100644 --- a/packages/coding-agent/test/startup-auth-config.test.ts +++ b/packages/coding-agent/test/startup-auth-config.test.ts @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { SqliteAuthCredentialStore } from "@gajae-code/ai"; import { getAgentDbPath } from "@gajae-code/utils"; import { Settings } from "../src/config/settings"; import { validateSettingPatch } from "../src/config/settings-schema"; +import { discoverAuthStorage } from "../src/sdk/session"; import { isValidPersistedCredentialSelector, resolveStartupAuthConfig, @@ -169,50 +171,94 @@ describe("startup auth config", () => { }); }); - it("reads soft credential preferences with the shared usage threshold", async () => { + it("reads ordered credential preferences with per-account usage caps", async () => { clearAuthEnv(); const agentDir = await makeAgentDir( [ "auth:", " credentialPreferences:", - " anthropic: email:cheap@example.com", + " anthropic:", + " - cheap@example.com", + " - shared@example.com: 75", + " - email:prefixed@example.com: 40.5", + " - account:acct-9: 100", " openai-codex: account:acct-1", - " credentialPreferenceMaxUsedPercent: 90", + " kimi-code: solo@example.com", ].join("\n"), ); - await expect(resolveStartupAuthConfig(agentDir)).resolves.toMatchObject({ - credentialPreferences: { - anthropic: { selector: { kind: "email", value: "cheap@example.com" }, maxUsedFraction: 0.9 }, - "openai-codex": { selector: { kind: "account", value: "acct-1" }, maxUsedFraction: 0.9 }, - }, + const resolved = await resolveStartupAuthConfig(agentDir); + expect(resolved.credentialPreferences).toEqual({ + anthropic: [ + { selector: { kind: "email", value: "cheap@example.com" } }, + { selector: { kind: "email", value: "shared@example.com" }, maxUsedFraction: 0.75 }, + { selector: { kind: "email", value: "prefixed@example.com" }, maxUsedFraction: 0.405 }, + // A 100 percent cap is the same as no cap: only blocking moves past the account. + { selector: { kind: "account", value: "acct-9" } }, + ], + "openai-codex": [{ selector: { kind: "account", value: "acct-1" } }], + "kimi-code": [{ selector: { kind: "email", value: "solo@example.com" } }], }); + }); - const noThreshold = await makeAgentDir( - "auth:\n credentialPreferences:\n anthropic: email:cheap@example.com\n", + it.each([ + ["a numeric row id", " anthropic: id:42\n"], + ["a non-mapping section", null], + ["a value that is not an account", " anthropic: cheap\n"], + ["an empty list", " anthropic: []\n"], + ["a numeric id inside a list", " anthropic:\n - cheap@example.com\n - id:42\n"], + ["the same account twice", " anthropic:\n - cheap@example.com\n - Cheap@Example.com: 50\n"], + ["two accounts in one entry", " anthropic:\n - cheap@example.com: 50\n other@example.com: 50\n"], + ["a zero cap", " anthropic:\n - cheap@example.com: 0\n"], + ["a cap over 100", " anthropic:\n - cheap@example.com: 101\n"], + ["a string cap", ' anthropic:\n - cheap@example.com: "75%"\n'], + ["a nested list", " anthropic:\n - - cheap@example.com\n"], + ])("fails closed for credential preferences with %s", async (_label, body) => { + clearAuthEnv(); + const agentDir = await makeAgentDir( + body === null + ? "auth:\n credentialPreferences: preference-section-secret\n" + : `auth:\n credentialPreferences:\n${body}`, + ); + await expectStartupAuthConfigError( + resolveStartupAuthConfig(agentDir), + "invalid-credential-preferences", + "preference-section-secret", ); - const resolved = await resolveStartupAuthConfig(noThreshold); - expect(resolved.credentialPreferences).toEqual({ - anthropic: { selector: { kind: "email", value: "cheap@example.com" } }, - }); }); - it("fails closed for numeric, malformed, or out-of-range credential preferences", async () => { + it.each([ + ["fails closed when two entries name one stored account", "acct-cheap", true], + ["opens the store when the entries name different stored accounts", "acct-other", false], + ])("%s through different selectors", async (_label, cheapAccountId, rejected) => { clearAuthEnv(); - for (const config of [ - "auth:\n credentialPreferences:\n anthropic: id:42\n", - "auth:\n credentialPreferences: preference-section-secret\n", - "auth:\n credentialPreferences:\n anthropic: cheap@example.com\n", - "auth:\n credentialPreferenceMaxUsedPercent: 0\n", - "auth:\n credentialPreferenceMaxUsedPercent: 101\n", - 'auth:\n credentialPreferenceMaxUsedPercent: "90"\n', - ]) { - const agentDir = await makeAgentDir(config); - await expectStartupAuthConfigError( - resolveStartupAuthConfig(agentDir), - "invalid-credential-preferences", - "preference-section-secret", - ); + const agentDir = await makeAgentDir( + "auth:\n credentialPreferences:\n anthropic:\n - cheap@example.com: 50\n - account:acct-cheap\n", + ); + const store = await SqliteAuthCredentialStore.open(getAgentDbPath(agentDir)); + try { + store.upsertAuthCredentialForProvider("anthropic", { + type: "oauth", + access: "access-cheap", + refresh: "refresh-cheap", + expires: Date.now() + 3_600_000, + email: "cheap@example.com", + accountId: cheapAccountId, + }); + } finally { + store.close(); + } + + const discovery = discoverAuthStorage(agentDir); + if (rejected) { + await expectStartupAuthConfigError(discovery, "invalid-credential-preferences", "cheap@example.com"); + return; + } + const storage = await discovery; + try { + expect(storage.findDuplicateCredentialPreferences()).toEqual([]); + } finally { + storage.close(); } }); @@ -317,33 +363,43 @@ describe("startup auth config", () => { ]); }); - it("validates preference records as portable selectors with a bounded threshold", () => { - expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: "email:cheap@example.com" } })).toEqual( + it("validates preference records with the startup parser grammar", () => { + for (const value of [ + "email:cheap@example.com", + "cheap@example.com", + ["cheap@example.com", { "account:acct-1": 75 }], + ]) { + expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: value } })).toEqual([]); + } + for (const value of [ + "id:27", [], - ); - expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: "id:27" } })).toEqual([ - { path: "auth.credentialPreferences.anthropic", detail: "Expected portable-credential-selector." }, - ]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 90 })).toEqual([]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 0 })).not.toEqual([]); - expect(validateSettingPatch({ "auth.credentialPreferenceMaxUsedPercent": 101 })).not.toEqual([]); + [{ "cheap@example.com": 0 }], + [{ "cheap@example.com": 50, "other@example.com": 50 }], + ["cheap@example.com", "cheap@example.com"], + ]) { + expect(validateSettingPatch({ "auth.credentialPreferences": { anthropic: value } })).toEqual([ + { path: "auth.credentialPreferences.anthropic", detail: "Expected credential-preference-list." }, + ]); + } }); it("ignores project-scoped credential preferences", async () => { clearAuthEnv(); const agentDir = await makeAgentDir( - "auth:\n credentialPreferences:\n anthropic: email:global@example.com\n credentialPreferenceMaxUsedPercent: 80\n", + "auth:\n credentialPreferences:\n anthropic:\n - global@example.com: 80\n", ); const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-startup-auth-project-")); tempDirs.push(projectDir); await fs.mkdir(path.join(projectDir, ".gjc"), { recursive: true }); await fs.writeFile( path.join(projectDir, ".gjc", "config.yml"), - "auth:\n credentialPreferences:\n anthropic: email:project@example.com\n credentialPreferenceMaxUsedPercent: 10\n", + "auth:\n credentialPreferences:\n anthropic:\n - project@example.com: 10\n", ); const settings = await Settings.loadForScope({ cwd: projectDir, agentDir }); - expect(settings.get("auth.credentialPreferences")).toEqual({ anthropic: "email:global@example.com" }); - expect(settings.get("auth.credentialPreferenceMaxUsedPercent")).toBe(80); + expect(settings.get("auth.credentialPreferences")).toEqual({ + anthropic: [{ "global@example.com": 80 }], + }); }); }); diff --git a/schemas/config.schema.json b/schemas/config.schema.json index d236ecc9f6e..0613cde49dc 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -46,16 +46,40 @@ "credentialPreferences": { "type": "object", "additionalProperties": { - "type": "string", - "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+)$" + "anyOf": [ + { + "type": "string", + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "anyOf": [ + { + "type": "string", + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "pattern": "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$" + }, + "additionalProperties": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + } + } + ] + } + } + ] }, "default": {} - }, - "credentialPreferenceMaxUsedPercent": { - "type": "number", - "default": 100, - "exclusiveMinimum": 0, - "maximum": 100 } }, "additionalProperties": false diff --git a/scripts/generate-json-schemas.test.ts b/scripts/generate-json-schemas.test.ts index b7a390b1dce..4ca76c869ea 100644 --- a/scripts/generate-json-schemas.test.ts +++ b/scripts/generate-json-schemas.test.ts @@ -3,6 +3,11 @@ import * as path from "node:path"; import { JSON_SCHEMA_OUTPUTS, stableJson } from "./generate-json-schemas"; import { ProfileModelSelectorPattern } from "../packages/coding-agent/src/config/models-config-schema"; import { SETTINGS_SCHEMA } from "../packages/coding-agent/src/config/settings-schema"; +import { + PREFERENCE_ACCOUNT_PATTERN, + parseCredentialPreferenceList, + parsePreferenceAccount, +} from "../packages/coding-agent/src/session/startup-auth-config"; function acceptsJsonSchemaFixture(schema: unknown, value: unknown): boolean { if (schema === true) return true; @@ -127,6 +132,44 @@ describe("generated JSON Schemas", () => { })).toBe(false); }); + it("emits the credential preference grammar the startup parser accepts", () => { + const schema = configSchema() as any; + const value = schema.properties.auth.properties.credentialPreferences.additionalProperties; + const [single, list] = value.anyOf; + const [account, capped] = list.items.anyOf; + expect(single).toEqual({ type: "string", pattern: PREFERENCE_ACCOUNT_PATTERN }); + expect(account).toEqual(single); + expect(list.minItems).toBe(1); + // The runtime rejects an account listed twice; the schema rejects the exact repeats it can express. + expect(list.uniqueItems).toBe(true); + expect(parseCredentialPreferenceList(["me@example.com", "me@example.com"])).toBeUndefined(); + expect(parseCredentialPreferenceList([{ "me@example.com": 75 }, { "me@example.com": 75 }])).toBeUndefined(); + expect(capped).toMatchObject({ + minProperties: 1, + maxProperties: 1, + propertyNames: { pattern: PREFERENCE_ACCOUNT_PATTERN }, + additionalProperties: { type: "number", exclusiveMinimum: 0, maximum: 100 }, + }); + + const pattern = new RegExp(PREFERENCE_ACCOUNT_PATTERN); + for (const candidate of [ + "me@example.com", + "email:me@example.com", + "account:acct-1", + "id:42", + "me", + "me@@example.com", + "project:p-1", + "me @example.com", + ]) { + expect(pattern.test(candidate)).toBe(parsePreferenceAccount(candidate) !== undefined); + } + for (const cap of [0, 0.5, 75, 100, 101]) { + const inSchema = cap > 0 && cap <= 100; + expect(parseCredentialPreferenceList([{ "me@example.com": cap }]) !== undefined).toBe(inSchema); + } + }); + it("emits web search fallback item enum and provider webSearch enum", () => { const configSchema = JSON_SCHEMA_OUTPUTS.find(output => output.path === "schemas/config.schema.json")?.schema as any; const fallbackItems = configSchema.properties.web_search.properties.fallback.items; diff --git a/scripts/generate-json-schemas.ts b/scripts/generate-json-schemas.ts index d0a22aaf31f..12688708529 100644 --- a/scripts/generate-json-schemas.ts +++ b/scripts/generate-json-schemas.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { zodToWireSchema } from "../packages/ai/src/utils/schema/wire"; import { AUTOROUTING_SELECTOR_MAX_LENGTH } from "../packages/coding-agent/src/config/autorouting-contract"; import { SETTINGS_SCHEMA } from "../packages/coding-agent/src/config/settings-schema"; +import { PREFERENCE_ACCOUNT_PATTERN } from "../packages/coding-agent/src/session/startup-auth-config"; import { ModelsConfigSchema } from "../packages/coding-agent/src/config/models-config-schema"; type JsonSchema = boolean | JsonSchemaObject; @@ -33,7 +34,6 @@ type SettingDefinition = SettingsSchema[keyof SettingsSchema]; const DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; const PERSISTED_CREDENTIAL_SELECTOR_PATTERN = "^(id:[1-9][0-9]*|email:[^@\\s]+@[^@\\s]+|account:\\S+)$"; -const PORTABLE_CREDENTIAL_SELECTOR_PATTERN = "^(email:[^@\\s]+@[^@\\s]+|account:\\S+)$"; export const JSON_SCHEMA_OUTPUTS = [ { path: "schemas/config.schema.json", @@ -135,10 +135,6 @@ function settingDefinitionToJsonSchema(settingPath: string, definition: SettingD schema.exclusiveMinimum = 0; schema.maximum = 1; } - if (settingPath === "auth.credentialPreferenceMaxUsedPercent") { - schema.exclusiveMinimum = 0; - schema.maximum = 100; - } if (settingPath === "sdk.promptDeadlineMs" || settingPath === "sdk.promptMaxRuntimeMs") { schema.type = "integer"; schema.minimum = 60_000; @@ -219,7 +215,7 @@ function recordValueSchema( valueSchema?: | { readonly type: "model-selector-value" } | { readonly type: "credential-selector" } - | { readonly type: "portable-credential-selector" } + | { readonly type: "credential-preference-list" } | { readonly type: "string-enum"; readonly values: readonly string[] }, ): JsonSchema { if (valueSchema?.type === "string-enum") return { type: "string", enum: valueSchema.values }; @@ -229,10 +225,17 @@ function recordValueSchema( pattern: PERSISTED_CREDENTIAL_SELECTOR_PATTERN, }; } - if (valueSchema?.type === "portable-credential-selector") { + if (valueSchema?.type === "credential-preference-list") { + const account = { type: "string", pattern: PREFERENCE_ACCOUNT_PATTERN }; + const cappedAccount = { + type: "object", + minProperties: 1, + maxProperties: 1, + propertyNames: { pattern: PREFERENCE_ACCOUNT_PATTERN }, + additionalProperties: { type: "number", exclusiveMinimum: 0, maximum: 100 }, + }; return { - type: "string", - pattern: PORTABLE_CREDENTIAL_SELECTOR_PATTERN, + anyOf: [account, { type: "array", minItems: 1, uniqueItems: true, items: { anyOf: [account, cappedAccount] } }], }; } if (valueSchema?.type !== "model-selector-value") return true;