persist openai-codex provider model settings

- also don't show a sign in button if openai-codex is the only provider
This commit is contained in:
Max Paulus 🥪
2026-06-24 14:11:27 +09:00
committed by Dominic Cooney
parent 0e9a5c0fc1
commit 3757a8fd0d
3 changed files with 272 additions and 137 deletions
+102 -68
View File
@@ -1,13 +1,16 @@
import type { ApiConfiguration } from "@shared/api"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { hasUsableProvider, hasUsableProviderForActiveMode } from "./provider-usability"
import type { ApiConfiguration } from "@shared/api";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
hasUsableProvider,
hasUsableProviderForActiveMode,
} from "./provider-usability";
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getProviderSettings: vi.fn(() => undefined as unknown),
getLastUsedProviderSettings: vi.fn(() => undefined as unknown),
saveProviderSettings: vi.fn(),
}
};
return {
providerSettingsManager,
getProviderSettingsManager: vi.fn(() => providerSettingsManager),
@@ -15,18 +18,18 @@ const mocks = vi.hoisted(() => {
getApiConfiguration: vi.fn((): ApiConfiguration => ({})),
getGlobalSettingsKey: vi.fn((_key: string): unknown => undefined),
},
}
})
};
});
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => mocks.stateManager,
},
}))
}));
vi.mock("./provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
}));
vi.mock("@shared/services/Logger", () => ({
Logger: {
@@ -35,44 +38,44 @@ vi.mock("@shared/services/Logger", () => ({
error: vi.fn(),
debug: vi.fn(),
},
}))
}));
beforeEach(() => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
})
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined);
});
afterEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
describe("hasUsableProvider", () => {
it("returns false when no provider is set for the active mode", () => {
expect(hasUsableProvider({}, "act")).toBe(false)
expect(hasUsableProvider({}, "plan")).toBe(false)
})
expect(hasUsableProvider({}, "act")).toBe(false);
expect(hasUsableProvider({}, "plan")).toBe(false);
});
it("returns true for a BYOK provider with an API key (act mode)", () => {
const config: ApiConfiguration = {
actModeApiProvider: "openrouter",
openRouterApiKey: "sk-or-123",
}
expect(hasUsableProvider(config, "act")).toBe(true)
})
};
expect(hasUsableProvider(config, "act")).toBe(true);
});
it("returns false for a BYOK provider with no API key", () => {
const config: ApiConfiguration = {
actModeApiProvider: "openrouter",
}
expect(hasUsableProvider(config, "act")).toBe(false)
})
};
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("returns false for a BYOK provider whose key is only whitespace", () => {
const config: ApiConfiguration = {
actModeApiProvider: "anthropic",
apiKey: " ",
}
expect(hasUsableProvider(config, "act")).toBe(false)
})
};
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("resolves the provider for the active mode independently (plan vs act)", () => {
const config: ApiConfiguration = {
@@ -80,92 +83,123 @@ describe("hasUsableProvider", () => {
apiKey: "sk-ant-123",
planModeApiProvider: "openrouter",
// no openRouterApiKey -> plan is not usable
}
expect(hasUsableProvider(config, "act")).toBe(true)
expect(hasUsableProvider(config, "plan")).toBe(false)
})
};
expect(hasUsableProvider(config, "act")).toBe(true);
expect(hasUsableProvider(config, "plan")).toBe(false);
});
it("treats the cline provider as usable when a token is present in providers.json", () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "cline",
auth: { accessToken: "workos:abc123" },
})
const config: ApiConfiguration = { actModeApiProvider: "cline" }
expect(hasUsableProvider(config, "act")).toBe(true)
})
});
const config: ApiConfiguration = { actModeApiProvider: "cline" };
expect(hasUsableProvider(config, "act")).toBe(true);
});
it("treats the cline provider as NOT usable when no token and no model is present", () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
const config: ApiConfiguration = { actModeApiProvider: "cline" }
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(
undefined,
);
const config: ApiConfiguration = { actModeApiProvider: "cline" };
// cline is keyless (oauth), but with no token AND no model -> not usable
expect(hasUsableProvider(config, "act")).toBe(false)
})
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("treats the cline provider as NOT usable when a model is configured but there is no token", () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(
undefined,
);
const config: ApiConfiguration = {
actModeApiProvider: "cline",
actModeClineModelId: "anthropic/claude-sonnet-4.6",
}
};
// This is the core case of scenario #3: an unauthenticated Cline user
// (the default provider, which always has a model preselected) must be
// gated. A configured model alone must NOT make an OAuth provider usable.
expect(hasUsableProvider(config, "act")).toBe(false)
})
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("treats OpenAI Codex as usable when ChatGPT subscription OAuth credentials are present", () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "openai-codex",
auth: { accessToken: "codex-access", refreshToken: "codex-refresh" },
});
const config: ApiConfiguration = {
actModeApiProvider: "openai-codex",
actModeApiModelId: "gpt-5.4",
};
expect(hasUsableProvider(config, "act")).toBe(true);
});
it("treats OpenAI Codex as NOT usable when a model is configured but no OAuth credentials are present", () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(
undefined,
);
const config: ApiConfiguration = {
actModeApiProvider: "openai-codex",
actModeApiModelId: "gpt-5.4",
};
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("treats ollama (keyless local) as usable when a model is configured, without a key", () => {
const config: ApiConfiguration = {
actModeApiProvider: "ollama",
actModeOllamaModelId: "llama3",
}
expect(hasUsableProvider(config, "act")).toBe(true)
})
};
expect(hasUsableProvider(config, "act")).toBe(true);
});
it("treats ollama (keyless local) as NOT usable when no model is configured", () => {
const config: ApiConfiguration = {
actModeApiProvider: "ollama",
}
expect(hasUsableProvider(config, "act")).toBe(false)
})
};
expect(hasUsableProvider(config, "act")).toBe(false);
});
it("treats lmstudio (keyless local) as usable when a model is configured", () => {
const config: ApiConfiguration = {
actModeApiProvider: "lmstudio",
actModeLmStudioModelId: "local-model",
}
expect(hasUsableProvider(config, "act")).toBe(true)
})
})
};
expect(hasUsableProvider(config, "act")).toBe(true);
});
});
describe("hasUsableProviderForActiveMode", () => {
it("reads ApiConfiguration + mode from StateManager and returns true for a usable BYOK config", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
openRouterApiKey: "sk-or-xyz",
})
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => (key === "mode" ? "act" : undefined))
expect(hasUsableProviderForActiveMode()).toBe(true)
})
});
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) =>
key === "mode" ? "act" : undefined,
);
expect(hasUsableProviderForActiveMode()).toBe(true);
});
it("returns false when StateManager has no provider configured", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({})
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) => (key === "mode" ? "act" : undefined))
expect(hasUsableProviderForActiveMode()).toBe(false)
})
mocks.stateManager.getApiConfiguration.mockReturnValue({});
mocks.stateManager.getGlobalSettingsKey.mockImplementation((key: string) =>
key === "mode" ? "act" : undefined,
);
expect(hasUsableProviderForActiveMode()).toBe(false);
});
it("defaults mode to act when StateManager returns no mode", () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
apiKey: "sk-ant",
})
mocks.stateManager.getGlobalSettingsKey.mockReturnValue(undefined)
expect(hasUsableProviderForActiveMode()).toBe(true)
})
});
mocks.stateManager.getGlobalSettingsKey.mockReturnValue(undefined);
expect(hasUsableProviderForActiveMode()).toBe(true);
});
it("fails open (returns true) when StateManager throws", () => {
mocks.stateManager.getApiConfiguration.mockImplementation(() => {
throw new Error("boom")
})
expect(hasUsableProviderForActiveMode()).toBe(true)
})
})
throw new Error("boom");
});
expect(hasUsableProviderForActiveMode()).toBe(true);
});
});
+75 -30
View File
@@ -3,19 +3,20 @@
// No legacy state-key heuristics.
//
// "Usable" mirrors what buildSessionConfig can actually turn into a working
// session: if we can resolve a credential (BYOK key or Cline OAuth token) for
// the active-mode provider, the user can chat. Keyless providers (Cline/OAuth
// providers, SDK "local-auth" providers, and the local-inference providers
// session: if we can resolve a credential (BYOK key or OAuth auth token) for
// the active-mode provider, the user can chat. Keyless providers (SDK
// "local-auth" providers and the local-inference providers
// ollama/lmstudio/vscode-lm) are usable once a model is configured, even
// without an API key.
import { getProviderConfigFields } from "@cline/core"
import type { ApiConfiguration } from "@shared/api"
import { Logger } from "@shared/services/Logger"
import type { Mode } from "@shared/storage/types"
import { StateManager } from "@/core/storage/StateManager"
import { resolveApiKey, resolveModelId } from "./cline-session-factory"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { getProviderConfigFields } from "@cline/core";
import type { ApiConfiguration } from "@shared/api";
import { Logger } from "@shared/services/Logger";
import type { Mode } from "@shared/storage/types";
import { StateManager } from "@/core/storage/StateManager";
import { resolveApiKey, resolveModelId } from "./cline-session-factory";
import { toSdkProviderId } from "./model-catalog/sdk-provider-id";
import { getProviderSettingsManager } from "./provider-migration";
/**
* Local-inference providers that the legacy ApiConfiguration path treats as
@@ -26,7 +27,11 @@ import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
* listed here explicitly. `vscode-lm` is a VSCode-host provider that is not in
* the SDK builtin catalog at all.
*/
const KEYLESS_LOCAL_PROVIDERS = new Set<string>(["ollama", "lmstudio", "vscode-lm"])
const KEYLESS_LOCAL_PROVIDERS = new Set<string>([
"ollama",
"lmstudio",
"vscode-lm",
]);
/**
* Whether a provider can be usable WITHOUT an API key, on the strength of a
@@ -49,15 +54,38 @@ const KEYLESS_LOCAL_PROVIDERS = new Set<string>(["ollama", "lmstudio", "vscode-l
* Keeps the keyless set catalog-driven instead of a hardcoded list of every
* provider, matching how the rest of the SDK adapter classifies providers.
*/
function getProviderAuthMethod(providerId: string): string | undefined {
try {
return getProviderConfigFields(toSdkProviderId(providerId)).authMethod;
} catch {
return undefined;
}
}
function isKeylessViaModelProvider(providerId: string): boolean {
if (KEYLESS_LOCAL_PROVIDERS.has(providerId)) {
return true
return true;
}
return getProviderAuthMethod(providerId) === "local";
}
function hasProviderSettingsAuthCredential(providerId: string): boolean {
try {
const fields = getProviderConfigFields(toSdkProviderId(providerId))
return fields.authMethod === "local"
const settings =
getProviderSettingsManager().getProviderSettings(providerId);
const auth =
settings && typeof settings === "object"
? (settings as { auth?: { accessToken?: unknown } }).auth
: undefined;
return (
typeof auth?.accessToken === "string" &&
auth.accessToken.trim().length > 0
);
} catch {
return false
Logger.warn(
`[ProviderUsability] Failed to read provider settings auth credentials for provider ${providerId}`,
);
return false;
}
}
@@ -67,29 +95,43 @@ function isKeylessViaModelProvider(providerId: string): boolean {
* Decoupled from the StateManager singleton so it can be unit-tested with a
* plain ApiConfiguration + mode.
*/
export function hasUsableProvider(apiConfig: ApiConfiguration, mode: Mode): boolean {
const providerId = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
export function hasUsableProvider(
apiConfig: ApiConfiguration,
mode: Mode,
): boolean {
const providerId =
mode === "plan"
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider;
// No provider selected for the active mode -> cannot chat.
if (!providerId) {
return false
return false;
}
// A resolvable credential (BYOK key, Bedrock key, or Cline OAuth token)
// A resolvable credential (BYOK key, Bedrock key, or OAuth token)
// means we can build a session and chat.
const apiKey = resolveApiKey(providerId, apiConfig)
const apiKey = resolveApiKey(providerId, apiConfig);
if (typeof apiKey === "string" && apiKey.trim().length > 0) {
return true
return true;
}
// OAuth-backed providers such as OpenAI Codex store subscription credentials
// in providers.json as `settings.auth.accessToken`; they do not necessarily
// project that token into ApiConfiguration, so resolve the auth envelope
// directly through the shared provider settings store.
if (hasProviderSettingsAuthCredential(providerId)) {
return true;
}
// Keyless local/local-auth providers are usable once a model is
// configured for the mode (no API key required).
if (isKeylessViaModelProvider(providerId)) {
const modelId = resolveModelId(providerId, mode, apiConfig)
return typeof modelId === "string" && modelId.trim().length > 0
const modelId = resolveModelId(providerId, mode, apiConfig);
return typeof modelId === "string" && modelId.trim().length > 0;
}
return false
return false;
}
/**
@@ -99,13 +141,16 @@ export function hasUsableProvider(apiConfig: ApiConfiguration, mode: Mode): bool
*/
export function hasUsableProviderForActiveMode(): boolean {
try {
const stateManager = StateManager.get()
const apiConfig = stateManager.getApiConfiguration()
const mode: Mode = stateManager.getGlobalSettingsKey("mode") ?? "act"
return hasUsableProvider(apiConfig, mode)
const stateManager = StateManager.get();
const apiConfig = stateManager.getApiConfiguration();
const mode: Mode = stateManager.getGlobalSettingsKey("mode") ?? "act";
return hasUsableProvider(apiConfig, mode);
} catch (error) {
Logger.warn("[ProviderUsability] Failed to compute usable provider:", error)
Logger.warn(
"[ProviderUsability] Failed to compute usable provider:",
error,
);
// Fail open: do not gate the UI on an internal read error.
return true
return true;
}
}
@@ -1,20 +1,22 @@
import { Mode } from "@shared/storage/types"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useStaticProviderSelection } from "@/hooks/useStaticProviderSelection"
import { AccountServiceClient } from "@/services/grpc-client"
import { ModelInfoView } from "../common/ModelInfoView"
import { ModelSelector } from "../common/ModelSelector"
import ReasoningEffortSelector from "../ReasoningEffortSelector"
import { supportsReasoningEffortForModelId } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { openAiModelInfoSafeDefaults } from "@shared/api";
import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion";
import type { Mode } from "@shared/storage/types";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { useExtensionState } from "@/context/ExtensionStateContext";
import { useProviderConfig } from "@/hooks/useProviderConfig";
import { useStaticProviderSelection } from "@/hooks/useStaticProviderSelection";
import { AccountServiceClient } from "@/services/grpc-client";
import { ModelInfoView } from "../common/ModelInfoView";
import { ModelSelector } from "../common/ModelSelector";
import ReasoningEffortSelector from "../ReasoningEffortSelector";
import { supportsReasoningEffortForModelId } from "../utils/providerUtils";
const OPENAI_CODEX_PROVIDER_ID = "openai-codex"
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
interface OpenAiCodexProviderProps {
showModelOptions: boolean
isPopup?: boolean
currentMode: Mode
showModelOptions: boolean;
isPopup?: boolean;
currentMode: Mode;
}
/**
@@ -28,39 +30,89 @@ interface OpenAiCodexProviderProps {
* provider, and the `hideUsageCost` flag is not hard-coded — it is
* derived from the SDK's `ProviderInfo.metadata.usageCostDisplay`.
*/
export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }: OpenAiCodexProviderProps) => {
const { apiConfiguration, openAiCodexIsAuthenticated } = useExtensionState()
const { handleModeFieldChange } = useApiConfigurationHandlers()
const { models, selectedModelId, selectedModelInfo, hideUsageCost } = useStaticProviderSelection(
export const OpenAiCodexProvider = ({
showModelOptions,
isPopup,
currentMode,
}: OpenAiCodexProviderProps) => {
const { apiConfiguration, openAiCodexIsAuthenticated } = useExtensionState();
const { config, commitSelection } = useProviderConfig(
OPENAI_CODEX_PROVIDER_ID,
);
const {
models,
defaultModelId,
selectedModelId: legacySelectedModelId,
selectedModelInfo: legacySelectedModelInfo,
hideUsageCost,
} = useStaticProviderSelection(
OPENAI_CODEX_PROVIDER_ID,
apiConfiguration,
currentMode,
)
);
const committedSelection =
currentMode === "plan" ? config?.planSelection : config?.actSelection;
const selectedModelId = committedSelection?.modelId ?? legacySelectedModelId;
const selectedModelInfo = committedSelection?.modelInfo
? fromProtobufModelInfo(committedSelection.modelInfo)
: legacySelectedModelInfo;
const showReasoningEffort = supportsReasoningEffortForModelId(selectedModelId, true)
const showReasoningEffort = supportsReasoningEffortForModelId(
selectedModelId,
true,
);
const handleModelChange = (modelId: string) => {
if (!modelId) {
return;
}
const fallbackModelId = defaultModelId || Object.keys(models)[0] || modelId;
const modelInfo =
models[modelId] ??
models[fallbackModelId] ??
selectedModelInfo ??
openAiModelInfoSafeDefaults;
void commitSelection(currentMode, {
providerId: OPENAI_CODEX_PROVIDER_ID,
modelId,
modelInfo,
}).catch((err) =>
console.error("Failed to commit OpenAI Codex model selection:", err),
);
};
const handleSignIn = async () => {
try {
await AccountServiceClient.openAiCodexSignIn({})
await AccountServiceClient.openAiCodexSignIn({});
} catch (error) {
console.error("Failed to sign in to OpenAI Codex:", error)
console.error("Failed to sign in to OpenAI Codex:", error);
}
}
};
const handleSignOut = async () => {
try {
await AccountServiceClient.openAiCodexSignOut({})
await AccountServiceClient.openAiCodexSignOut({});
} catch (error) {
console.error("Failed to sign out of OpenAI Codex:", error)
console.error("Failed to sign out of OpenAI Codex:", error);
}
}
};
return (
<div>
<div style={{ marginBottom: "15px" }}>
{openAiCodexIsAuthenticated ? (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>Signed in to OpenAI Codex</span>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
Signed in to OpenAI Codex
</span>
<VSCodeButton appearance="secondary" onClick={handleSignOut}>
Sign Out
</VSCodeButton>
@@ -72,10 +124,14 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }:
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
marginBottom: "10px",
}}>
Sign in with your ChatGPT Plus or Pro subscription to use GPT-5 models without an API key.
}}
>
Sign in with your ChatGPT Plus or Pro subscription to use GPT-5
models without an API key.
</p>
<VSCodeButton onClick={handleSignIn}>Sign in to OpenAI Codex</VSCodeButton>
<VSCodeButton onClick={handleSignIn}>
Sign in to OpenAI Codex
</VSCodeButton>
</div>
)}
</div>
@@ -85,16 +141,16 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }:
<ModelSelector
label="Model"
models={models}
onChange={(e: any) =>
handleModeFieldChange(
{ plan: "planModeApiModelId", act: "actModeApiModelId" },
e.target.value,
currentMode,
onChange={(event: Event) =>
handleModelChange(
(event.target as HTMLSelectElement | null)?.value ?? "",
)
}
selectedModelId={selectedModelId}
/>
{showReasoningEffort && <ReasoningEffortSelector currentMode={currentMode} />}
{showReasoningEffort && (
<ReasoningEffortSelector currentMode={currentMode} />
)}
<ModelInfoView
hideUsageCost={hideUsageCost}
@@ -105,5 +161,5 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }:
</>
)}
</div>
)
}
);
};