diff --git a/apps/vscode/src/sdk/provider-usability.test.ts b/apps/vscode/src/sdk/provider-usability.test.ts index 5e1c8944c9..f520175eb4 100644 --- a/apps/vscode/src/sdk/provider-usability.test.ts +++ b/apps/vscode/src/sdk/provider-usability.test.ts @@ -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); + }); +}); diff --git a/apps/vscode/src/sdk/provider-usability.ts b/apps/vscode/src/sdk/provider-usability.ts index 9b638bc670..faac035b8e 100644 --- a/apps/vscode/src/sdk/provider-usability.ts +++ b/apps/vscode/src/sdk/provider-usability.ts @@ -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(["ollama", "lmstudio", "vscode-lm"]) +const KEYLESS_LOCAL_PROVIDERS = new Set([ + "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(["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; } } diff --git a/apps/vscode/webview-ui/src/components/settings/providers/OpenAiCodexProvider.tsx b/apps/vscode/webview-ui/src/components/settings/providers/OpenAiCodexProvider.tsx index 23447ecfc3..89dab39b87 100644 --- a/apps/vscode/webview-ui/src/components/settings/providers/OpenAiCodexProvider.tsx +++ b/apps/vscode/webview-ui/src/components/settings/providers/OpenAiCodexProvider.tsx @@ -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 (
{openAiCodexIsAuthenticated ? ( -
- Signed in to OpenAI Codex +
+ + Signed in to OpenAI Codex + Sign Out @@ -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.

- Sign in to OpenAI Codex + + Sign in to OpenAI Codex +
)}
@@ -85,16 +141,16 @@ export const OpenAiCodexProvider = ({ showModelOptions, isPopup, currentMode }: - handleModeFieldChange( - { plan: "planModeApiModelId", act: "actModeApiModelId" }, - e.target.value, - currentMode, + onChange={(event: Event) => + handleModelChange( + (event.target as HTMLSelectElement | null)?.value ?? "", ) } selectedModelId={selectedModelId} /> - {showReasoningEffort && } + {showReasoningEffort && ( + + )} )}
- ) -} + ); +};