mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:58:25 +00:00
fix(auth/session): preserve override reset behavior and repair oauth profile-id drift (openclaw#18820) thanks @Glucksberg
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,74 @@ describe("resolveAuthProfileOrder", () => {
|
||||
});
|
||||
expect(order).toEqual(["minimax:prod"]);
|
||||
});
|
||||
it("falls back to stored provider profiles when config profile ids drift", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual(["openai-codex:user@example.com"]);
|
||||
});
|
||||
it("does not bypass explicit ids when the configured profile exists but is invalid", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "token",
|
||||
provider: "openai-codex",
|
||||
token: "expired-token",
|
||||
expires: Date.now() - 1_000,
|
||||
},
|
||||
"openai-codex:user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai-codex",
|
||||
});
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
it("drops explicit order entries that belong to another provider", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function resolveAuthProfileOrder(params: {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = baseOrder.filter((profileId) => {
|
||||
const isValidProfile = (profileId: string): boolean => {
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred) {
|
||||
return false;
|
||||
@@ -78,7 +78,18 @@ export function resolveAuthProfileOrder(params: {
|
||||
return Boolean(cred.access?.trim() || cred.refresh?.trim());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
let filtered = baseOrder.filter(isValidProfile);
|
||||
|
||||
// Repair config/store profile-id drift from older onboarding flows:
|
||||
// if configured profile ids no longer exist in auth-profiles.json, scan the
|
||||
// provider's stored credentials and use any valid entries.
|
||||
const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]);
|
||||
if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) {
|
||||
const storeProfiles = listProfilesForProvider(store, providerKey);
|
||||
filtered = storeProfiles.filter(isValidProfile);
|
||||
}
|
||||
|
||||
const deduped = dedupeProfileIds(filtered);
|
||||
|
||||
// If user specified explicit order (store override or config), respect it
|
||||
|
||||
@@ -139,6 +139,75 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back directly to configured primary when an override model fails", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = vi.fn().mockImplementation(async (provider, model) => {
|
||||
if (provider === "anthropic" && model === "claude-opus-4-5") {
|
||||
throw Object.assign(new Error("unauthorized"), { status: 401 });
|
||||
}
|
||||
if (provider === "openai" && model === "gpt-4.1-mini") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
|
||||
});
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
expect(run.mock.calls).toEqual([
|
||||
["anthropic", "claude-opus-4-5"],
|
||||
["openai", "gpt-4.1-mini"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats normalized default refs as primary and keeps configured fallback chain", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 }))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: " OpenAI ",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([
|
||||
["openai", "gpt-4.1-mini"],
|
||||
["anthropic", "claude-haiku-3-5"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back on transient HTTP 5xx errors", async () => {
|
||||
await expectFallsBackToHaiku({
|
||||
provider: "openai",
|
||||
@@ -167,12 +236,30 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back on credential validation errors", async () => {
|
||||
await expectFallsBackToHaiku({
|
||||
it("falls back to configured primary for override credential validation errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi.fn().mockImplementation(async (provider, model) => {
|
||||
if (provider === "anthropic" && model === "claude-opus-4") {
|
||||
throw new Error('No credentials found for profile "anthropic:default".');
|
||||
}
|
||||
if (provider === "openai" && model === "gpt-4.1-mini") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
|
||||
});
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4",
|
||||
firstError: new Error('No credentials found for profile "anthropic:default".'),
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run.mock.calls).toEqual([
|
||||
["anthropic", "claude-opus-4"],
|
||||
["openai", "gpt-4.1-mini"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips providers when all profiles are in cooldown", async () => {
|
||||
|
||||
@@ -96,6 +96,10 @@ type ModelFallbackRunResult<T> = {
|
||||
attempts: FallbackAttempt[];
|
||||
};
|
||||
|
||||
function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean {
|
||||
return a.provider === b.provider && a.model === b.model;
|
||||
}
|
||||
|
||||
function throwFallbackFailureSummary(params: {
|
||||
attempts: FallbackAttempt[];
|
||||
candidates: ModelCandidate[];
|
||||
@@ -193,6 +197,7 @@ function resolveFallbackCandidates(params: {
|
||||
const providerRaw = String(params.provider ?? "").trim() || defaultProvider;
|
||||
const modelRaw = String(params.model ?? "").trim() || defaultModel;
|
||||
const normalizedPrimary = normalizeModelRef(providerRaw, modelRaw);
|
||||
const configuredPrimary = normalizeModelRef(defaultProvider, defaultModel);
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg ?? {},
|
||||
defaultProvider,
|
||||
@@ -209,6 +214,11 @@ function resolveFallbackCandidates(params: {
|
||||
if (params.fallbacksOverride !== undefined) {
|
||||
return params.fallbacksOverride;
|
||||
}
|
||||
// Skip configured fallback chain when the user runs a non-default override.
|
||||
// In that case, retry should return directly to configured primary.
|
||||
if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) {
|
||||
return []; // Override model failed → go straight to configured default
|
||||
}
|
||||
const model = params.cfg?.agents?.defaults?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
|
||||
Reference in New Issue
Block a user