import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, aroundEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginMetadataSnapshotFixture } from "../../plugins/plugin-metadata.test-support.js"; import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { SecretSurfaceUnavailableError } from "../../secrets/runtime-degraded-state.js"; import type { AuthProfileStore } from "../auth-profiles.js"; import { createApiKeyCredential } from "../auth-profiles/credential-fixtures.test-support.js"; import { OAuthRefreshFailureError } from "../auth-profiles/oauth-refresh-failure.js"; import { resolvePreparedRuntimeAuthAttempts, resolvePreparedRuntimeModelAuth, scopeAuthProfileStoreToPreparedPlan, } from "./resolve-auth.js"; vi.mock("../model-auth-env-vars.js", async (importOriginal) => ({ ...(await importOriginal()), resolveProviderEnvAuthLookupMaps: () => ({ aliasMap: {}, envCandidateMap: { openai: ["OPENAI_API_KEY", "OPENAI_OAUTH_TOKEN"] }, authEvidenceMap: {}, setupProviderFallbackRefs: [], }), })); vi.mock("../../llm/oauth.js", async (importOriginal) => ({ ...(await importOriginal()), getOAuthApiKey: vi.fn(async () => { throw new Error("invalid_grant"); }), })); const platformModel = { id: "gpt-5.5", name: "gpt-5.5", provider: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", input: ["text"], reasoning: true, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_000, } as Model; const subscriptionModel = { ...platformModel, api: "openai-chatgpt-responses", baseUrl: "https://chatgpt.com/backend-api/codex", } as Model; function authStore(profiles: AuthProfileStore["profiles"]): AuthProfileStore { return { version: 1, profiles }; } describe("resolvePreparedRuntimeModelAuth", () => { aroundEach((runTest) => withPluginRuntimeGenerationScope( { metadataSnapshot: createPluginMetadataSnapshotFixture({ plugins: [{ id: "openai", providers: ["openai"] }], }), }, runTest, ), ); beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_MISSING_PREPARED_AUTH", ""); vi.stubEnv("OPENCLAW_TEST_MISSING_BOUND_AUTH", ""); }); afterEach(() => { vi.unstubAllEnvs(); }); it("removes profile and selection state after a Platform key is resolved", () => { const store = { ...authStore({ "openai:subscription": { type: "token", provider: "openai", token: "subscription-token", expires: Date.now() + 60_000, }, "openai:platform": createApiKeyCredential("openai", "platform-key"), }), order: { openai: ["openai:subscription", "openai:platform"] }, lastGood: { openai: "openai:subscription" }, usageStats: { "openai:subscription": { lastUsed: 1 } }, runtimePersistedProfileIds: ["openai:subscription"], runtimeExternalProfileIds: ["openai:subscription"], runtimeExternalProfileIdsAuthoritative: true, } satisfies AuthProfileStore; expect( scopeAuthProfileStoreToPreparedPlan(store, { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:platform", forwardedAuthProfileCandidateIds: ["openai:platform"], selectedAuthMode: "api-key", modelRoute: { provider: "openai", modelId: "gpt-5.6", api: "openai-responses", baseUrl: "https://api.openai.com/v1", authRequirement: "api-key", requestTransportOverrides: "none", }, }), ).toMatchObject({ profiles: {}, order: { openai: [] }, lastGood: {}, usageStats: {}, runtimePersistedProfileIds: [], runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: true, }); }); it("keeps a failed explicit SecretRef terminal across prepared profile candidates", async () => { const store = authStore({ "openai:missing": { type: "api_key", provider: "openai", keyRef: { source: "env", provider: "default", id: "OPENCLAW_TEST_MISSING_PREPARED_AUTH", }, }, "openai:backup": createApiKeyCredential("openai", "backup-key"), }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:missing", forwardedAuthProfileSource: "auto", forwardedAuthProfileCandidateIds: ["openai:missing", "openai:backup"], selectedAuthMode: "api_key", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-responses", baseUrl: "https://api.openai.com/v1", authRequirement: "api-key", requestTransportOverrides: "none", }, }, model: platformModel, cfg: {}, store, secretSentinels: true, }), ).rejects.toMatchObject({ code: "SECRET_SURFACE_UNAVAILABLE", ownerKind: "account", }); }); it( "does not borrow an unprepared API-key profile for direct subscription auth", { timeout: 1_000 }, async () => { vi.stubEnv("OPENAI_API_KEY", ""); const store = authStore({ "openai:platform": createApiKeyCredential("openai", "platform-key"), }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "token", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-chatgpt-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authRequirement: "subscription", requestTransportOverrides: "none", }, }, model: subscriptionModel, cfg: {}, store, secretSentinels: true, }), ).rejects.toThrow('No API key found for provider "openai"'); }, ); it("resolves an ambient Platform key without borrowing the OAuth-only full store", async () => { vi.stubEnv("OPENAI_API_KEY", "ambient-platform-key"); const store = authStore({ "openai:chatgpt": { type: "oauth", provider: "openai", access: "subscription-token", refresh: "refresh-token", expires: Date.now() + 60_000, }, }); const resolved = await resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "api-key", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-responses", baseUrl: "https://api.openai.com/v1", authRequirement: "api-key", requestTransportOverrides: "none", }, }, model: platformModel, cfg: {}, store, secretSentinels: true, }); expect(resolved).toMatchObject({ auth: { apiKey: "ambient-platform-key", mode: "api-key", }, plan: { forwardedAuthProfileId: undefined, selectedAuthMode: "api-key", }, }); expect(resolved.auth.source).toContain("OPENAI_API_KEY"); }); it("keeps authored unpinned provider auth ahead of an opposite-route store", async () => { const store = authStore({ "openai:chatgpt": { type: "token", provider: "openai", token: "subscription-token", }, }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "api-key", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-responses", baseUrl: "https://api.openai.com/v1", authRequirement: "api-key", requestTransportOverrides: "none", }, }, model: platformModel, cfg: { models: { providers: { openai: { apiKey: "configured-platform-key", baseUrl: "https://api.openai.com/v1", models: [], }, }, }, }, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { mode: "api-key" } }); }); it("materializes authored OpenAI oauth without borrowing the API-only full store", async () => { vi.stubEnv("OPENAI_API_KEY", ""); const store = authStore({ "openai:platform": createApiKeyCredential("openai", "platform-key"), }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "oauth", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-chatgpt-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authRequirement: "subscription", requestTransportOverrides: "none", }, }, model: subscriptionModel, cfg: { models: { providers: { openai: { auth: "oauth", apiKey: "configured-subscription-credential", baseUrl: "https://chatgpt.com/backend-api/codex", models: [], }, }, }, }, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { apiKey: "configured-subscription-credential", source: "models.json", mode: "oauth", }, plan: { selectedAuthMode: "oauth", modelRoute: { authRequirement: "subscription" }, }, }); }); it("skips a prepared candidate whose stored credential class changed", async () => { const store = authStore({ "openai:changed": createApiKeyCredential("openai", "platform-key"), "openai:backup": { type: "token", provider: "openai", token: "subscription-token", expires: Date.now() + 60_000, }, }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:changed", forwardedAuthProfileSource: "auto", forwardedAuthProfileCandidateIds: ["openai:changed", "openai:backup"], selectedAuthMode: "token", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-chatgpt-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authRequirement: "subscription", requestTransportOverrides: "none", }, }, model: subscriptionModel, cfg: {}, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { profileId: "openai:backup", mode: "token" }, plan: { forwardedAuthProfileId: "openai:backup", forwardedAuthProfileCandidateIds: ["openai:backup"], selectedAuthMode: "token", }, }); }); it("falls through from a stale OAuth automatic candidate to a prepared backup profile", async () => { const store = authStore({ "openai:expired": { type: "oauth", provider: "openai", access: "expired-access", refresh: "expired-refresh", expires: Date.now() - 60_000, }, "openai:backup": createApiKeyCredential("openai", "backup-key"), }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:expired", forwardedAuthProfileSource: "auto", forwardedAuthProfileCandidateIds: ["openai:expired", "openai:backup"], selectedAuthMode: "api_key", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-responses", baseUrl: "https://api.openai.com/v1", authRequirement: "api-key", requestTransportOverrides: "none", }, }, model: platformModel, cfg: {}, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { profileId: "openai:backup", mode: "api-key" }, plan: { forwardedAuthProfileId: "openai:backup", forwardedAuthProfileCandidateIds: ["openai:backup"], selectedAuthMode: "api-key", }, }); }); it("skips an automatic candidate that cooled down after plan preparation", async () => { const store = authStore({ "openai:first": createApiKeyCredential("openai", "first-key"), "openai:backup": createApiKeyCredential("openai", "backup-key"), }); const plan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:first", forwardedAuthProfileSource: "auto" as const, forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], selectedAuthMode: "api_key", modelRoute: { provider: "openai", modelId: "gpt-5.5", api: "openai-responses" as const, baseUrl: "https://api.openai.com/v1", authRequirement: "api-key" as const, requestTransportOverrides: "none" as const, }, }; store.usageStats = { "openai:first": { cooldownUntil: Date.now() + 60_000, cooldownReason: "rate_limit", cooldownModel: "gpt-5.5", }, }; await expect( resolvePreparedRuntimeModelAuth({ plan, model: platformModel, cfg: {}, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { profileId: "openai:backup" }, plan: { forwardedAuthProfileId: "openai:backup", forwardedAuthProfileCandidateIds: ["openai:backup"], }, }); }); it("fails closed when every prepared automatic candidate is in cooldown", async () => { const store = authStore({ "openai:first": { type: "api_key", provider: "openai", key: "first-key" }, "openai:backup": { type: "api_key", provider: "openai", key: "backup-key" }, }); store.usageStats = Object.fromEntries( ["openai:first", "openai:backup"].map((profileId) => [ profileId, { cooldownUntil: Date.now() + 60_000, cooldownReason: "rate_limit" as const, cooldownModel: "gpt-5.5", }, ]), ); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:first", forwardedAuthProfileSource: "auto", forwardedAuthProfileCandidateIds: ["openai:first", "openai:backup"], }, model: platformModel, cfg: {}, store, secretSentinels: true, }), ).rejects.toThrow("temporarily unavailable"); }); it("does not unlock direct fallback when a profile cools during materialization", async () => { const store = authStore({ "openai:first": { type: "api_key", provider: "openai", key: "first-key" }, }); const profilePlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:first", forwardedAuthProfileSource: "auto" as const, forwardedAuthProfileCandidateIds: ["openai:first"], }; const directPlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", }; const resolveAuth = vi.fn(async () => ({ plan: directPlan, auth: "unused" })); const materializeModel = vi.fn(async () => { store.usageStats = { "openai:first": { cooldownUntil: Date.now() + 60_000 }, }; return platformModel; }); await expect( resolvePreparedRuntimeAuthAttempts({ attempts: [ { kind: "profile", plan: profilePlan, profileId: "openai:first" }, { kind: "direct", plan: directPlan, allowAuthProfileFallback: false, requiresPriorProfileAttempt: true, }, ], store, modelId: "gpt-5.5", model: platformModel, materializeModel, resolveAuth, errorMessage: "prepared auth failed", }), ).rejects.toThrow("temporarily unavailable"); expect(materializeModel).toHaveBeenCalledOnce(); expect(resolveAuth).not.toHaveBeenCalled(); }); it("does not unlock another prepared attempt after an explicit profile ref fails", async () => { const profilePlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:cold", forwardedAuthProfileSource: "auto" as const, forwardedAuthProfileCandidateIds: ["openai:cold"], }; const directPlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", }; const unavailable = new SecretSurfaceUnavailableError({ ownerKind: "account", ownerId: "openai:cold", state: "unavailable", paths: ["auth-profiles.openai:cold.key"], refKeys: ["env:default:MISSING_OPENAI_KEY"], reason: "secret reference was not found", }); const resolveAuth = vi.fn(async ({ attempt }: { attempt: { kind: string } }) => { if (attempt.kind === "profile") { throw unavailable; } return { plan: directPlan, auth: "must-not-be-used" }; }); const materializeModel = vi.fn(async () => platformModel); await expect( resolvePreparedRuntimeAuthAttempts({ attempts: [ { kind: "profile", plan: profilePlan, profileId: "openai:cold" }, { kind: "direct", plan: directPlan, allowAuthProfileFallback: false, requiresPriorProfileAttempt: true, }, ], store: authStore({ "openai:cold": { type: "api_key", provider: "openai", key: "unused" }, }), modelId: "gpt-5.5", model: platformModel, materializeModel, resolveAuth, errorMessage: "prepared auth failed", }), ).rejects.toBe(unavailable); expect(resolveAuth).toHaveBeenCalledOnce(); expect(materializeModel).toHaveBeenCalledOnce(); }); it("does not unlock direct fallback after a prepared OAuth refresh failure", async () => { const profilePlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:oauth", forwardedAuthProfileSource: "auto" as const, forwardedAuthProfileCandidateIds: ["openai:oauth"], }; const directPlan = { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "api-key", }; const refreshFailure = new OAuthRefreshFailureError({ provider: "openai", profileId: "openai:oauth", message: "OAuth token refresh failed for openai: expired refresh credential", }); const resolveAuth = vi.fn(async ({ attempt }: { attempt: { kind: string } }) => { if (attempt.kind === "profile") { throw refreshFailure; } return { plan: directPlan, auth: "must-not-be-used" }; }); const materializeModel = vi.fn(async () => platformModel); await expect( resolvePreparedRuntimeAuthAttempts({ attempts: [ { kind: "profile", plan: profilePlan, profileId: "openai:oauth" }, { kind: "direct", plan: directPlan, allowAuthProfileFallback: false, requiresPriorProfileAttempt: true, }, ], store: authStore({ "openai:oauth": { type: "oauth", provider: "openai", access: "expired-access", refresh: "expired-refresh", expires: Date.now() - 60_000, }, }), modelId: "gpt-5.5", model: platformModel, materializeModel, resolveAuth, errorMessage: "prepared auth failed", }), ).rejects.toBe(refreshFailure); expect(resolveAuth).toHaveBeenCalledOnce(); expect(materializeModel).toHaveBeenCalledOnce(); }); it("forces unscoped model rematerialization for direct fallback after profile failure", async () => { const store = authStore({ "github-copilot:first": { type: "token", provider: "github-copilot", token: "profile-token", }, }); const profilePlan = { providerForAuth: "github-copilot", authProfileProviderForAuth: "github-copilot", forwardedAuthProfileId: "github-copilot:first", forwardedAuthProfileSource: "auto" as const, forwardedAuthProfileCandidateIds: ["github-copilot:first"], }; const directPlan = { providerForAuth: "github-copilot", authProfileProviderForAuth: "github-copilot", selectedAuthMode: "token", }; const profileModel = { ...platformModel, contextWindow: 1_050_000 }; const directModel = { ...platformModel, contextWindow: 400_000 }; const materializeModel = vi.fn(async ({ forceResolve }: { forceResolve?: boolean }) => forceResolve ? directModel : profileModel, ); const resolveAuth = vi.fn( async ({ attempt }: { attempt: { kind: "direct" | "implicit" | "profile" } }) => { if (attempt.kind === "profile") { throw new Error("profile credential failed"); } return { plan: directPlan, auth: "direct" }; }, ); const result = await resolvePreparedRuntimeAuthAttempts({ attempts: [ { kind: "profile", plan: profilePlan, profileId: "github-copilot:first" }, { kind: "direct", plan: directPlan, allowAuthProfileFallback: false, requiresPriorProfileAttempt: true, }, ], store, modelId: "gpt-5.6-sol", model: platformModel, materializeModel, resolveAuth, errorMessage: "prepared auth failed", }); expect(result).toMatchObject({ auth: "direct", model: { contextWindow: 400_000 } }); expect(materializeModel.mock.calls.map(([input]) => input.forceResolve)).toEqual([false, true]); }); it("forces the first direct model when provider metadata is credential-scoped", async () => { const directPlan = { providerForAuth: "github-copilot", authProfileProviderForAuth: "github-copilot", selectedAuthMode: "api-key", }; const directModel = { ...platformModel, contextWindow: 1_050_000 }; const materializeModel = vi.fn(async () => directModel); const result = await resolvePreparedRuntimeAuthAttempts({ attempts: [ { kind: "direct", plan: directPlan, allowAuthProfileFallback: false, requiresPriorProfileAttempt: false, }, ], store: authStore({}), modelId: "gpt-5.6-sol", model: platformModel, materializeModel, resolveAuth: async () => ({ plan: directPlan, auth: "direct" }), forceCredentialScopedDirectModelResolve: true, errorMessage: "prepared auth failed", }); expect(result.model).toBe(directModel); expect(materializeModel).toHaveBeenCalledWith({ plan: directPlan, model: platformModel, forceResolve: true, }); }); it("keeps a single bound prepared profile terminal", async () => { const store = authStore({ "openai:bound": { type: "api_key", provider: "openai", keyRef: { source: "env", provider: "default", id: "OPENCLAW_TEST_MISSING_BOUND_AUTH", }, }, "openai:unbound": createApiKeyCredential("openai", "must-not-be-borrowed"), }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:bound", forwardedAuthProfileSource: "auto", forwardedAuthProfileCandidateIds: ["openai:bound"], }, model: platformModel, cfg: { auth: { order: { openai: ["openai:bound", "openai:unbound"] } }, }, store, secretSentinels: true, }), ).rejects.toThrow(); }); it("keeps a user-locked profile terminal when environment auth is also present", async () => { vi.stubEnv("OPENAI_API_KEY", "ambient-key"); const store = authStore({ "openai:locked": { type: "api_key", provider: "openai", key: "codex-app-server", }, }); await expect( resolvePreparedRuntimeModelAuth({ plan: { providerForAuth: "openai", authProfileProviderForAuth: "openai", forwardedAuthProfileId: "openai:locked", forwardedAuthProfileSource: "user", forwardedAuthProfileCandidateIds: ["openai:locked"], }, model: platformModel, cfg: {}, store, secretSentinels: true, }), ).resolves.toMatchObject({ auth: { profileId: "openai:locked" }, plan: { forwardedAuthProfileId: "openai:locked", forwardedAuthProfileSource: "user", forwardedAuthProfileCandidateIds: ["openai:locked"], }, }); }); });