fix(auth): bidirectional mode/type compat + sync OAuth to all agents (#12692)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 2dee8e1174
Co-authored-by: mudrii <220262+mudrii@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
mudrii
2026-02-20 18:31:09 +08:00
committed by GitHub
parent 083298ab9d
commit 7ecfc1d93c
14 changed files with 568 additions and 46 deletions

View File

@@ -114,6 +114,205 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
});
});
it("adopts newer OAuth token from main agent even when secondary token is still valid", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();
const secondaryExpiry = now + 30 * 60 * 1000;
const mainExpiry = now + 2 * 60 * 60 * 1000;
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "secondary-access-token",
refresh: "secondary-refresh-token",
expires: secondaryExpiry,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "main-newer-access-token",
refresh: "main-newer-refresh-token",
expires: mainExpiry,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
expect(result?.apiKey).toBe("main-newer-access-token");
const updatedSecondaryStore = JSON.parse(
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
access: "main-newer-access-token",
expires: mainExpiry,
});
});
it("adopts main token when secondary expires is NaN/malformed", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();
const mainExpiry = now + 2 * 60 * 60 * 1000;
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "secondary-stale",
refresh: "secondary-refresh",
expires: NaN,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "main-fresh-token",
refresh: "main-refresh",
expires: mainExpiry,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
expect(result?.apiKey).toBe("main-fresh-token");
});
it("accepts mode=token + type=oauth for legacy compatibility", async () => {
const profileId = "anthropic:default";
const store: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "oauth-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
};
const result = await resolveApiKeyForProfile({
cfg: {
auth: {
profiles: {
[profileId]: {
provider: "anthropic",
mode: "token",
},
},
},
},
store,
profileId,
});
expect(result?.apiKey).toBe("oauth-token");
});
it("accepts mode=oauth + type=token (regression)", async () => {
const profileId = "anthropic:default";
const store: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "token",
provider: "anthropic",
token: "static-token",
expires: Date.now() + 60_000,
},
},
};
const result = await resolveApiKeyForProfile({
cfg: {
auth: {
profiles: {
[profileId]: {
provider: "anthropic",
mode: "oauth",
},
},
},
},
store,
profileId,
});
expect(result?.apiKey).toBe("static-token");
});
it("rejects true mode/type mismatches", async () => {
const profileId = "anthropic:default";
const store: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "oauth-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
};
const result = await resolveApiKeyForProfile({
cfg: {
auth: {
profiles: {
[profileId]: {
provider: "anthropic",
mode: "api_key",
},
},
},
},
store,
profileId,
});
expect(result).toBeNull();
});
it("throws error when both secondary and main agent credentials are expired", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();

View File

@@ -60,7 +60,7 @@ describe("resolveApiKeyForProfile config compatibility", () => {
expect(result).toBeNull();
});
it("rejects oauth credentials when config mode is token", async () => {
it("accepts oauth credentials when config mode is token (bidirectional compat)", async () => {
const profileId = "anthropic:oauth";
const store: AuthProfileStore = {
version: 1,
@@ -80,7 +80,12 @@ describe("resolveApiKeyForProfile config compatibility", () => {
store,
profileId,
});
expect(result).toBeNull();
// token ↔ oauth are bidirectionally compatible bearer-token auth paths.
expect(result).toEqual({
apiKey: "access-123",
provider: "anthropic",
email: undefined,
});
});
it("rejects credentials when provider does not match config", async () => {

View File

@@ -23,6 +23,20 @@ const isOAuthProvider = (provider: string): provider is OAuthProvider =>
const resolveOAuthProvider = (provider: string): OAuthProvider | null =>
isOAuthProvider(provider) ? provider : null;
/** Bearer-token auth modes that are interchangeable (oauth tokens and raw tokens). */
const BEARER_AUTH_MODES = new Set(["oauth", "token"]);
const isCompatibleModeType = (mode: string | undefined, type: string | undefined): boolean => {
if (!mode || !type) {
return false;
}
if (mode === type) {
return true;
}
// Both token and oauth represent bearer-token auth paths — allow bidirectional compat.
return BEARER_AUTH_MODES.has(mode) && BEARER_AUTH_MODES.has(type);
};
function isProfileConfigCompatible(params: {
cfg?: OpenClawConfig;
profileId: string;
@@ -34,16 +48,8 @@ function isProfileConfigCompatible(params: {
if (profileConfig && profileConfig.provider !== params.provider) {
return false;
}
if (profileConfig && profileConfig.mode !== params.mode) {
if (
!(
params.allowOAuthTokenCompatibility &&
profileConfig.mode === "oauth" &&
params.mode === "token"
)
) {
return false;
}
if (profileConfig && !isCompatibleModeType(profileConfig.mode, params.mode)) {
return false;
}
return true;
}
@@ -91,6 +97,43 @@ type ResolveApiKeyForProfileParams = {
agentDir?: string;
};
function adoptNewerMainOAuthCredential(params: {
store: AuthProfileStore;
profileId: string;
agentDir?: string;
cred: OAuthCredentials & { type: "oauth"; provider: string; email?: string };
}): (OAuthCredentials & { type: "oauth"; provider: string; email?: string }) | null {
if (!params.agentDir) {
return null;
}
try {
const mainStore = ensureAuthProfileStore(undefined);
const mainCred = mainStore.profiles[params.profileId];
if (
mainCred?.type === "oauth" &&
mainCred.provider === params.cred.provider &&
Number.isFinite(mainCred.expires) &&
(!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires)
) {
params.store.profiles[params.profileId] = { ...mainCred };
saveAuthProfileStore(params.store, params.agentDir);
log.info("adopted newer OAuth credentials from main agent", {
profileId: params.profileId,
agentDir: params.agentDir,
expires: new Date(mainCred.expires).toISOString(),
});
return mainCred;
}
} catch (err) {
// Best-effort: don't crash if main agent store is missing or unreadable.
log.debug("adoptNewerMainOAuthCredential failed", {
profileId: params.profileId,
error: err instanceof Error ? err.message : String(err),
});
}
return null;
}
async function refreshOAuthTokenWithLock(params: {
profileId: string;
agentDir?: string;
@@ -229,11 +272,20 @@ export async function resolveApiKeyForProfile(
}
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
}
if (Date.now() < cred.expires) {
const oauthCred =
adoptNewerMainOAuthCredential({
store,
profileId,
agentDir: params.agentDir,
cred,
}) ?? cred;
if (Date.now() < oauthCred.expires) {
return buildOAuthProfileResult({
provider: cred.provider,
credentials: cred,
email: cred.email,
provider: oauthCred.provider,
credentials: oauthCred,
email: oauthCred.email,
});
}

View File

@@ -38,6 +38,7 @@ export type AuthProfileFailureReason =
| "rate_limit"
| "billing"
| "timeout"
| "model_not_found"
| "unknown";
/** Per-profile usage statistics for round-robin and cooldown tracking */

View File

@@ -51,6 +51,8 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine
return 408;
case "format":
return 400;
case "model_not_found":
return 404;
default:
return undefined;
}

View File

@@ -262,6 +262,49 @@ describe("runWithModelFallback", () => {
]);
});
it("falls back on unknown model errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("Unknown model: anthropic/claude-opus-4-6"))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "anthropic",
model: "claude-opus-4-6",
run,
});
// Override model failed with model_not_found → falls back to configured primary.
// (Same candidate-resolution path as other override-model failures.)
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("openai");
expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini");
});
it("falls back on model not found errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("Model not found: openai/gpt-6"))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-6",
run,
});
// Override model failed with model_not_found → falls back to configured primary.
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("openai");
expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini");
});
it("skips providers when all profiles are in cooldown", async () => {
const provider = `cooldown-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;

View File

@@ -16,6 +16,7 @@ export {
getApiErrorPayloadFingerprint,
isAuthAssistantError,
isAuthErrorMessage,
isModelNotFoundErrorMessage,
isBillingAssistantError,
parseApiErrorInfo,
sanitizeUserFacingText,

View File

@@ -765,6 +765,37 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
return isAuthErrorMessage(msg.errorMessage ?? "");
}
export function isModelNotFoundErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
// Direct pattern matches from OpenClaw internals and common providers.
if (
lower.includes("unknown model") ||
lower.includes("model not found") ||
lower.includes("model_not_found") ||
lower.includes("not_found_error") ||
(lower.includes("does not exist") && lower.includes("model")) ||
(lower.includes("invalid model") && !lower.includes("invalid model reference"))
) {
return true;
}
// Google Gemini: "models/X is not found for api version"
if (/models\/[^\s]+ is not found/i.test(raw)) {
return true;
}
// JSON error payloads: {"status": "NOT_FOUND"} or {"code": 404} combined with not-found text.
if (/\b404\b/.test(raw) && /not[-_ ]?found/i.test(raw)) {
return true;
}
return false;
}
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) {
return null;
@@ -772,6 +803,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageSizeError(raw)) {
return null;
}
if (isModelNotFoundErrorMessage(raw)) {
return "model_not_found";
}
if (isTransientHttpError(raw)) {
// Treat transient 5xx provider failures as retryable transport issues.
return "timeout";

View File

@@ -1,3 +1,10 @@
export type EmbeddedContextFile = { path: string; content: string };
export type FailoverReason = "auth" | "format" | "rate_limit" | "billing" | "timeout" | "unknown";
export type FailoverReason =
| "auth"
| "format"
| "rate_limit"
| "billing"
| "timeout"
| "model_not_found"
| "unknown";

View File

@@ -274,7 +274,11 @@ export async function runEmbeddedPiAgent(
params.config,
);
if (!model) {
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
throw new FailoverError(error ?? `Unknown model: ${provider}/${modelId}`, {
reason: "model_not_found",
provider,
model: modelId,
});
}
const ctxInfo = resolveContextWindowInfo({