diff --git a/apps/desktop/src/entities/release/changelog.ts b/apps/desktop/src/entities/release/changelog.ts index 4f8e825c..254eb7a7 100644 --- a/apps/desktop/src/entities/release/changelog.ts +++ b/apps/desktop/src/entities/release/changelog.ts @@ -51,6 +51,11 @@ export const changelogReleases: readonly ChangelogRelease[] = [ title: "Open any file from chat", description: "File links in answers open in Files at the linked line even when the file has not changed, and local links render as file chips. Paths written as inline code, such as src/foo.ts:12, become links when the file exists in the checkout.", }, + { + kind: "improved", + title: "One ChatGPT subscription login", + description: "Pi 0.99 signs in with ChatGPT through the OpenAI provider and marks ChatGPT / Codex as legacy, so the Subscription tab now offers a single OpenAI entry. If you already signed in with ChatGPT / Codex, its card stays so you can keep using it or sign out.", + }, { kind: "improved", title: "Continuous corners", diff --git a/apps/desktop/src/pages/settings.test.tsx b/apps/desktop/src/pages/settings.test.tsx index 7924a3a7..37f77911 100644 --- a/apps/desktop/src/pages/settings.test.tsx +++ b/apps/desktop/src/pages/settings.test.tsx @@ -765,6 +765,51 @@ describe("Settings — changelog", () => { }); }); +describe("Settings — subscription providers", () => { + it("shows one ChatGPT login and keeps the legacy codex card only when signed in", async () => { + const authStatus = (codexSignedIn: boolean): typeof providerAuthStatus => ({ + ...providerAuthStatus, + providers: [ + { + id: "openai", + label: "OpenAI", + supportsApiKey: true, + supportsOAuth: true, + mode: "none", + configured: false, + }, + { + id: "openai-codex", + label: "ChatGPT / Codex", + supportsApiKey: true, + supportsOAuth: true, + mode: codexSignedIn ? "oauth" : "none", + configured: codexSignedIn, + }, + ...providerAuthStatus.providers, + ], + }); + + const first = renderSettings("/usage?settings=providers", disabledUpdateStatus, { + authStatus: authStatus(false), + }); + expect( + await screen.findByTestId("provider-subscription-openai"), + ).toBeVisible(); + expect( + screen.queryByTestId("provider-subscription-openai-codex"), + ).not.toBeInTheDocument(); + first.unmount(); + + renderSettings("/usage?settings=providers", disabledUpdateStatus, { + authStatus: authStatus(true), + }); + expect( + await screen.findByTestId("provider-subscription-openai-codex"), + ).toBeVisible(); + }); +}); + describe("Settings — provider connection test", () => { it("offers Check only for configured providers and shows a single status line", async () => { const user = userEvent.setup(); diff --git a/apps/desktop/src/pages/settings.tsx b/apps/desktop/src/pages/settings.tsx index e3bcd787..e5a394dd 100644 --- a/apps/desktop/src/pages/settings.tsx +++ b/apps/desktop/src/pages/settings.tsx @@ -47,13 +47,14 @@ import { useUpdateStatus } from "@/entities/update/use-update-status"; import { isModelVisible } from "@/entities/model/model-selector/model-selector-logic"; import { invoke, revealProjectInFinder } from "@/shared/runtime"; import type { UpdateStatus } from "@/shared/update-protocol"; -import type { - ProviderAuthId, - ProviderAuthStatusItem, - ProviderAuthStatusReport, - ProviderConnectionTestResult, - ProviderFailureKind, - RuntimeModelCapability, +import { + showsSubscriptionCard, + type ProviderAuthId, + type ProviderAuthStatusItem, + type ProviderAuthStatusReport, + type ProviderConnectionTestResult, + type ProviderFailureKind, + type RuntimeModelCapability, } from "@pace/core"; type AuthTab = "subscription" | "api_key"; @@ -942,7 +943,7 @@ function SettingsContent({ const providers = statusQuery.data?.providers ?? []; const subscriptionProviders = useMemo( - () => providers.filter((provider) => provider.supportsOAuth), + () => providers.filter((provider) => showsSubscriptionCard(provider)), [providers], ); const visibleApiKeyProviders = useMemo(() => { diff --git a/apps/desktop/src/shared/runtime.ts b/apps/desktop/src/shared/runtime.ts index 1d5d567a..a433ef64 100644 --- a/apps/desktop/src/shared/runtime.ts +++ b/apps/desktop/src/shared/runtime.ts @@ -363,7 +363,8 @@ export function invokeBrowserFallback(command: string, args?: InvokeArgs): Pr id: "openai", label: "OpenAI", supportsApiKey: true, - supportsOAuth: false, + // Pi 0.99 moved ChatGPT subscription OAuth onto `openai`. + supportsOAuth: true, mode: "api_key", configured: true, keyHint: "…dev1", diff --git a/docs/release/0.1.0.md b/docs/release/0.1.0.md index 98307d92..333d1947 100644 --- a/docs/release/0.1.0.md +++ b/docs/release/0.1.0.md @@ -15,6 +15,7 @@ ## 改进 +- 只保留一个 ChatGPT 订阅登录:Pi 0.99 改由 OpenAI provider 提供 ChatGPT 订阅登录,并把原来的 ChatGPT / Codex 标为旧版。Settings → Providers → Subscription 现在只显示 OpenAI 一个入口;已经用 ChatGPT / Codex 登录过的用户仍会看到该卡片,可以继续使用或登出。 - 连续曲率圆角:界面圆角统一改为 squircle 形状,并调整圆角刻度,使整体圆润程度与之前一致;圆形按钮保持正圆。 - 动态图标更克制:所有动态图标的动画时长统一为 800ms,只在鼠标真正移入时播放,切换页面后不再自动重播,按下时不再缩放。 diff --git a/e2e/smoke/s3-provider-settings.spec.ts b/e2e/smoke/s3-provider-settings.spec.ts index b876457a..5a5467ed 100644 --- a/e2e/smoke/s3-provider-settings.spec.ts +++ b/e2e/smoke/s3-provider-settings.spec.ts @@ -71,16 +71,18 @@ test.describe("S3: Provider Settings (DF-002)", () => { testApp.window.getByTestId("provider-api-key-openai-codex"), ).toHaveCount(0); - // Subscription tab: ChatGPT/Codex + Anthropic + Grok (xAI) + // Subscription tab: OpenAI (ChatGPT subscription since Pi 0.99) + + // Anthropic + Grok (xAI). The legacy openai-codex card only appears + // for an existing codex OAuth sign-in; nothing is seeded here. await settingsTab(testApp.window, "Subscription").click(); - for (const provider of ["openai-codex", "anthropic", "xai"]) { + for (const provider of ["openai", "anthropic", "xai"]) { const card = testApp.window.getByTestId(`provider-subscription-${provider}`); await expect(card).toBeVisible(); await expect(card.getByTestId(`provider-icon-${provider}`)).toBeVisible(); } - // OpenAI API key and DeepSeek have no subscription card + // The legacy codex provider and DeepSeek have no subscription card await expect( - testApp.window.getByTestId("provider-subscription-openai"), + testApp.window.getByTestId("provider-subscription-openai-codex"), ).toHaveCount(0); await expect( testApp.window.getByTestId("provider-subscription-deepseek"), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08eafbe9..445b4783 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,9 +36,11 @@ export { export { FEATURED_PROVIDER_ORDER, + LEGACY_SUBSCRIPTION_PROVIDERS, PROVIDER_DISPLAY_OVERRIDES, classifyProviderFailure, describeProviderFailure, + showsSubscriptionCard, sortProvidersForDisplay, type ProviderAuthId, type ProviderAuthMode, diff --git a/packages/core/src/provider-auth.test.ts b/packages/core/src/provider-auth.test.ts index af03c627..39d9d967 100644 --- a/packages/core/src/provider-auth.test.ts +++ b/packages/core/src/provider-auth.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyProviderFailure, describeProviderFailure, + showsSubscriptionCard, sortProvidersForDisplay, } from "./provider-auth"; @@ -27,6 +28,42 @@ describe("sortProvidersForDisplay", () => { }); }); +describe("showsSubscriptionCard", () => { + const codex = { id: "openai-codex", supportsOAuth: true }; + + it("shows the legacy codex card only for an existing OAuth sign-in", () => { + expect( + showsSubscriptionCard({ ...codex, configured: false, mode: "none" }), + ).toBe(false); + expect( + showsSubscriptionCard({ ...codex, configured: true, mode: "oauth" }), + ).toBe(true); + // Signed in with an API key is not the subscription flow the card offers. + expect( + showsSubscriptionCard({ ...codex, configured: true, mode: "api_key" }), + ).toBe(false); + }); + + it("shows current OAuth providers regardless of sign-in state", () => { + expect( + showsSubscriptionCard({ + id: "openai", + supportsOAuth: true, + configured: false, + mode: "none", + }), + ).toBe(true); + expect( + showsSubscriptionCard({ + id: "deepseek", + supportsOAuth: false, + configured: false, + mode: "none", + }), + ).toBe(false); + }); +}); + describe("classifyProviderFailure", () => { it("classifies HTTP 401 as auth, including when the body mentions a plan", () => { expect(classifyProviderFailure(Object.assign(new Error("subscription plan"), { status: 401 }))).toBe( diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index 18436a64..13d20784 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -32,11 +32,33 @@ export const PROVIDER_DISPLAY_OVERRIDES: Readonly> = { xai: "Grok (xAI)", }; -export const FEATURED_PROVIDER_ORDER: readonly string[] = [ +// Pi 0.99 moved ChatGPT subscription login onto `openai` ("Sign in with +// ChatGPT") and marks `openai-codex` as legacy. The codex card only stays +// for users already signed in through it; everyone else gets the `openai` +// entry so the Subscription tab shows one ChatGPT login, not two. +export const LEGACY_SUBSCRIPTION_PROVIDERS: ReadonlySet = new Set([ "openai-codex", +]); + +/** Whether the Subscription tab offers this provider's OAuth login. */ +export function showsSubscriptionCard( + item: Pick< + ProviderAuthStatusItem, + "id" | "supportsOAuth" | "configured" | "mode" + >, +): boolean { + return ( + item.supportsOAuth && + (!LEGACY_SUBSCRIPTION_PROVIDERS.has(item.id) || + (item.configured && item.mode === "oauth")) + ); +} + +export const FEATURED_PROVIDER_ORDER: readonly string[] = [ + "openai", "anthropic", "radius", - "openai", + "openai-codex", "deepseek", "xai", "google",