Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Login no longer double-appends a provider-owned OAuth account pool as a fake `login-N` slot copied from the flat compatibility sentinel.

### Removed

## [2026.9.2] - 2026-09-02
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ function nextLoginSlotName(credential: PooledCredential): string {
* user's stored bytes change until a second credential actually exists.
*/
export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential {
if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) {
return flat;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
return flat;
}
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
## Preserve provider-owned credential pools during login (2026-09-02)

### What changed

- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` now accepts an OAuth credential whose provider already returned a populated `accounts` pool as the complete post-login credential instead of appending its flat compatibility fields as another generated slot.
- `packages/ai/test/credential-pool-resolve-slot.test.ts`: covers a provider-owned named account pool and still appends unnamed flat credentials as `login-N`.

### Why

- The shared login layer automatically appends ordinary flat credentials. The Claude SDK OAuth provider already returns its current pool plus the newly named account, so applying the generic append a second time stored the provider's managed top-level sentinel as a fake `login-2` account.

### Why an extension could not handle it

- The double append happens after the provider login returns, inside the shared credential-pool write path. Providers cannot prevent the runtime from reinterpreting their completed pool as a flat credential.

### Expected merge conflict zones

- LOW: `auth/pool/slots.ts` at the start of `appendLoginSlot`.

## Cursor conversation cache eviction cannot break a live request (2026-08-31)

### What changed
Expand Down
31 changes: 30 additions & 1 deletion packages/ai/test/credential-pool-resolve-slot.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { appendLoginSlot, listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import { resolveProviderAuth } from "../src/auth/resolve.ts";
import type { OAuthCredential } from "../src/auth/types.ts";
import { createProvider, type Provider } from "../src/models.ts";
Expand Down Expand Up @@ -63,6 +63,35 @@ function oauthProvider(refreshed: (credential: OAuthCredential) => OAuthCredenti
}

describe("slot-scoped auth resolution", () => {
test("login preserves a provider-owned pooled credential instead of double-appending its flat sentinel", () => {
const current = pooledOAuthEntry();
const providerOwned: PooledCredential = {
...current,
accounts: [
...(current.accounts ?? []),
{ name: "work", access: "named-access", refresh: "r-named", expires: FUTURE, source: "login" },
],
};

expect(appendLoginSlot(current, providerOwned)).toEqual(providerOwned);
});

test("unnamed flat oauth still appends as login-N", () => {
const current = pooledOAuthEntry();
const next = appendLoginSlot(current, {
type: "oauth",
access: "new-access",
refresh: "r-new",
expires: FUTURE,
}) as PooledCredential;

expect(listSlots(next).map((slot) => slot.name)).toEqual(["default", "alt", "login-2"]);
expect(listSlots(next).find((slot) => slot.name === "login-2")).toMatchObject({
access: "new-access",
refresh: "r-new",
});
});

test("slotName resolves the named api_key slot instead of the flat projection", async () => {
const store = new InMemoryCredentialStore();
await store.modify("slottest", async () => pooledApiKeyEntry());
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

### Fixed

- Claude SDK OAuth readiness now treats a rotation-selected concrete OAuth slot as configured, so a second login no longer fails every request with `Provider is not configured: claude-sdk-oauth`.
- `Provider is not configured:` is no longer a hard-error model fallback, so an auth miss on Claude SDK OAuth does not eject the turn onto another provider.

### New Features

### Breaking Changes
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7378,6 +7378,7 @@ export class AgentSession {
private _isHardErrorFallbackEligible(message: AssistantMessage): boolean {
return (
!message.errorMessage?.startsWith(TURN_RETRY_SUPPRESSION_PREFIX) &&
!message.errorMessage?.startsWith("Provider is not configured:") &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This guard couples fallback behavior to the exact English wording of an error message that is generated in two other files (Provider is not configured: ${model.provider} at packages/ai/src/models.ts:680 and packages/coding-agent/src/core/model-runtime.ts:711), with no shared constant. If either generator is reworded or localized, the startsWith check silently stops matching and the auth-miss hard-error hop this fix is meant to prevent quietly returns. Export a shared prefix constant (e.g. in packages/ai where ModelsError is defined) and reference it in both throw sites and this guard, as is done with TURN_RETRY_SUPPRESSION_PREFIX.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/agent-session.ts, line 7381:

<comment>This guard couples fallback behavior to the exact English wording of an error message that is generated in two other files (`Provider is not configured: ${model.provider}` at packages/ai/src/models.ts:680 and packages/coding-agent/src/core/model-runtime.ts:711), with no shared constant. If either generator is reworded or localized, the startsWith check silently stops matching and the auth-miss hard-error hop this fix is meant to prevent quietly returns. Export a shared prefix constant (e.g. in packages/ai where ModelsError is defined) and reference it in both throw sites and this guard, as is done with TURN_RETRY_SUPPRESSION_PREFIX.</comment>

<file context>
@@ -7378,6 +7378,7 @@ export class AgentSession {
 	private _isHardErrorFallbackEligible(message: AssistantMessage): boolean {
 		return (
 			!message.errorMessage?.startsWith(TURN_RETRY_SUPPRESSION_PREFIX) &&
+			!message.errorMessage?.startsWith("Provider is not configured:") &&
 			message.stopReason === "error" &&
 			!isContextOverflow(message, this.model?.contextWindow ?? 0) &&
</file context>

message.stopReason === "error" &&
!isContextOverflow(message, this.model?.contextWindow ?? 0) &&
!this._isCursorPayloadOverflow(message) &&
Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# changes

## 2026-09-02 - Do not hard-fallback a provider-not-configured auth miss

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: `_isHardErrorFallbackEligible` no longer treats `Provider is not configured:` as a model hard-error that ejects onto another provider.
- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: a configured fallback chain stays unused when the current model fails with that auth miss.

### Why

- Auth wiring failures were classified as hard-error and immediately switched `claude-sdk-oauth/claude-opus-5` onto a different provider (for example `opengateway/anthropic/claude-opus-5`) instead of staying on Claude SDK OAuth or its sibling accounts.

### Why an extension could not handle it

- Hard-error fallback eligibility is decided in `AgentSession` before extension failover runs.

### Expected merge conflict zones

- LOW: `_isHardErrorFallbackEligible` in `agent-session.ts`.

## 2026-08-31 - Session activity contract for host occupancy decisions

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# claude-sdk-oauth

## 2026-09-02 - Preserve selected OAuth slots during provider preflight

### What changed

- `oauth-login.ts`: readiness now recognizes a concrete OAuth credential selected by the shared credential-rotation layer, while still excluding the provider's synthetic managed sentinel.
- `test/claude-sdk-oauth-login.test.ts`: projected non-sentinel slots pass `check`; projected sentinels do not.

### Why

- With two or more Claude logins, shared credential rotation passes one selected OAuth slot to provider auth resolution. The Claude readiness predicate counted only the parent credential's `accounts` array, so the selected slot appeared empty and the request failed with `Provider is not configured: claude-sdk-oauth` even though both accounts were valid.

### Why an extension could not handle it

- Readiness is this provider's `oauth.check` predicate. An external extension cannot change how a selected slot is counted once rotation has already projected it.

### Expected merge conflict zones

- LOW in `oauth-login.ts` `configuredFor` account counting.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
environment?: Record<string, string>,
): Promise<boolean> => {
const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : [];
const selectedStoredAccount =
stored?.type === "oauth" &&
stored.access !== SENTINEL_OAUTH_FIELDS.access &&
stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh;
const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
const accountCount = storedAccounts.length + environmentTokenCount;
const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;
const settings = deps.readSettings?.();
const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient");
if (lane === "ambient") {
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/test/claude-sdk-oauth-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,24 @@ describe("claude-sdk-oauth oauth login config", () => {
const credential = await config.login({});
expect(await config.refreshToken(credential)).toBe(credential);
});

it("treats a projected non-sentinel OAuth slot as configured", async () => {
const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) });
const ctx = { env: async () => undefined, fileExists: async () => false };
const check = await config.check({
ctx,
credential: { type: "oauth", access: "slot-access", refresh: "slot-refresh", expires: Date.now() + 60_000 },
});
expect(check).toEqual({ source: "Claude SDK OAuth", type: "oauth" });
});

it("does not treat a projected managed sentinel as configured", async () => {
const config = createOAuthConfig({ readCurrent: async () => undefined, loginFlow: fakeFlow(fresh) });
const ctx = { env: async () => undefined, fileExists: async () => false };
const check = await config.check({
ctx,
credential: { type: "oauth", ...SENTINEL_OAUTH_FIELDS },
});
expect(check).toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,22 @@ describe("retry fallback hard errors", () => {
expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]);
});

it("does not switch providers on a provider-not-configured auth miss", async () => {
const harness = await createHarness({
models: [{ id: "faux-1" }, { id: "faux-2" }],
settings: { retry: { enabled: true, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } } },
});
harnesses.push(harness);
const authMiss = "Provider is not configured: claude-sdk-oauth";
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: authMiss })]);

await harness.session.prompt("hello");

expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual(["faux-1"]);
expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]);
expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: authMiss });
});

it("does not treat an aborted response as a hard-error fallback", async () => {
const harness = await createHarness({
models: [{ id: "faux-1" }, { id: "faux-2" }],
Expand Down