diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 57bfaea81..509288591 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -14,6 +14,10 @@ import { } from "#/api/conversation-metadata-store"; import { ACP_VERTEX_SAFE_MODEL } from "#/constants/acp-providers"; import { DEFAULT_SETTINGS } from "#/services/settings"; +import { + LLM_AUTH_TYPE_SUBSCRIPTION, + OPENAI_SUBSCRIPTION_VENDOR, +} from "#/constants/llm-subscription"; const { mockGetAgentServerWorkingDir, @@ -160,6 +164,48 @@ describe("buildStartConversationRequest", () => { expect(payload.initial_message.content[0]?.text).toBe("hello"); }); + it("uses subscription auth metadata without API credentials", () => { + const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { + model: "gpt-5.2-codex", + api_key: "stale-api-key", + base_url: "https://api.openai.com/v1", + auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, + subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, + }, + }, + }, + }) as { agent_settings: { llm: Record } }; + + expect(payload.agent_settings.llm).toEqual({ + model: "gpt-5.2-codex", + auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, + subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, + }); + }); + + it("passes the stored model through unchanged for subscription auth", () => { + const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { + model: "openai/gpt-4o", + auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, + subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, + }, + }, + }, + }) as { agent_settings: { llm: Record } }; + + expect(payload.agent_settings.llm.model).toBe("openai/gpt-4o"); + }); + it("forwards the switch-LLM setting to SDK agent settings", () => { const payload = buildStartConversationRequest({ settings: { diff --git a/__tests__/api/llm-subscription-service.test.ts b/__tests__/api/llm-subscription-service.test.ts new file mode 100644 index 000000000..eecc827c4 --- /dev/null +++ b/__tests__/api/llm-subscription-service.test.ts @@ -0,0 +1,95 @@ +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; +import { + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + OPENAI_SUBSCRIPTION_STATUS_PATH, +} from "#/constants/llm-subscription"; +import { server } from "#/mocks/node"; +import { resetTestHandlersMockSettings } from "#/mocks/settings-handlers"; + +describe("LLMSubscriptionService", () => { + beforeEach(() => { + resetTestHandlersMockSettings(); + }); + + it("fetches OpenAI subscription models from the agent-server endpoint", async () => { + await expect(LLMSubscriptionService.getOpenAIModels()).resolves.toEqual([ + "gpt-5.2", + "gpt-5.3-codex", + ]); + }); + + it("normalizes OpenAI subscription status from MSW handlers", async () => { + await expect(LLMSubscriptionService.getOpenAIStatus()).resolves.toEqual({ + vendor: "openai", + connected: false, + accountEmail: null, + expiresAt: null, + }); + }); + + it("normalizes device login challenge responses", async () => { + await expect( + LLMSubscriptionService.startOpenAIDeviceLogin(), + ).resolves.toEqual({ + deviceCode: "mock-device-code", + userCode: "MOCK-CODE", + verificationUri: "https://auth.openai.com/activate", + verificationUriComplete: + "https://auth.openai.com/activate?user_code=MOCK-CODE", + expiresAt: 900, + intervalSeconds: 1, + }); + }); + + it("posts the device code when polling login", async () => { + await expect( + LLMSubscriptionService.pollOpenAIDeviceLogin("mock-device-code"), + ).resolves.toMatchObject({ connected: true }); + + await expect( + LLMSubscriptionService.getOpenAIStatus(), + ).resolves.toMatchObject({ + connected: true, + accountEmail: "mock-chatgpt@example.com", + }); + }); + + it("calls the logout endpoint", async () => { + await LLMSubscriptionService.pollOpenAIDeviceLogin("mock-device-code"); + + await expect(LLMSubscriptionService.logoutOpenAI()).resolves.toMatchObject({ + connected: false, + }); + await expect( + LLMSubscriptionService.getOpenAIStatus(), + ).resolves.toMatchObject({ connected: false }); + }); + + it("rejects incomplete device challenges with blank required fields", async () => { + server.use( + http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_START_PATH}`, () => + HttpResponse.json({ + device_code: " ", + user_code: "MOCK-CODE", + verification_uri: "https://auth.openai.com/activate", + }), + ), + ); + + await expect( + LLMSubscriptionService.startOpenAIDeviceLogin(), + ).rejects.toThrow("Subscription device login response is incomplete"); + }); + + it("surfaces agent-server errors", async () => { + server.use( + http.get(`*${OPENAI_SUBSCRIPTION_STATUS_PATH}`, () => + HttpResponse.json({ detail: "unauthorized" }, { status: 401 }), + ), + ); + + await expect(LLMSubscriptionService.getOpenAIStatus()).rejects.toThrow(); + }); +}); diff --git a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx index 9e11fd263..ff6852d94 100644 --- a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx +++ b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; import { AxiosError } from "axios"; -import { screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { @@ -12,6 +12,83 @@ import * as useActivateLlmProfileHook from "#/hooks/mutation/use-activate-llm-pr import * as useSaveLlmProfileHook from "#/hooks/mutation/use-save-llm-profile"; import ProfilesService from "#/api/profiles-service/profiles-service.api"; +vi.mock("#/routes/llm-settings", async () => { + const React = await vi.importActual("react"); + return { + LlmSettingsScreen: ({ + initialValueOverrides, + onSaveControlChange, + }: { + initialValueOverrides?: Record; + onSaveControlChange?: (control: { + save: () => void; + isSaving: boolean; + isDirty: boolean; + view: "basic" | "all"; + values: Record; + getDirtyPayload: () => { llm: Record }; + }) => void; + }) => { + const initialValueOverridesRef = React.useRef(initialValueOverrides); + const [view, setView] = React.useState<"basic" | "all">("basic"); + const [temperature, setTemperature] = React.useState("0.2"); + React.useEffect(() => { + const values = { + "llm.model": "openai/gpt-4o", + "llm.api_key": "test-api-key", + "llm.base_url": "", + ...(initialValueOverridesRef.current ?? {}), + }; + onSaveControlChange?.({ + save: vi.fn(), + isSaving: false, + isDirty: true, + view, + values, + getDirtyPayload: () => { + if (view === "all") { + return { llm: { temperature: Number(temperature) } }; + } + return { + llm: { + model: values["llm.model"], + api_key: values["llm.api_key"], + base_url: values["llm.base_url"], + }, + }; + }, + }); + }, [onSaveControlChange, temperature, view]); + + return ( +
+ + + {view === "all" ? ( + setTemperature(event.currentTarget.value)} + /> + ) : null} +
+ ); + }, + }; +}); + vi.mock("#/hooks/query/use-llm-profiles"); vi.mock("#/hooks/mutation/use-activate-llm-profile"); vi.mock("#/hooks/mutation/use-save-llm-profile"); @@ -199,52 +276,23 @@ describe("LlmSettingsLocalView", () => { ).toBeInTheDocument(); }); - /** - * Integration test verifying the actual save flow: - * 1. Renders the component - * 2. Navigates to create view - * 3. Fills in profile name - * 4. Clicks save - * 5. Verifies the save mutation was called with correct payload - * 6. Verifies the view switches back to list mode - */ - it("calls save mutation with correct payload and returns to list", async () => { - const user = userEvent.setup(); + it("keeps the create view stable when save controls are incomplete", () => { mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); renderWithProviders(); - // Navigate to create view - await user.click(screen.getByTestId("add-llm-profile")); + fireEvent.click(screen.getByTestId("add-llm-profile")); - // Should be in create view expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - // Fill in profile name const nameInput = screen.getByTestId("profile-name-input"); - await user.clear(nameInput); - await user.type(nameInput, "my-new-profile"); + fireEvent.change(nameInput, { target: { value: "my-new-profile" } }); + expect(nameInput).toHaveValue("my-new-profile"); - // The save button should be enabled after name is entered - // (model is handled by the embedded LlmSettingsScreen which we mock) const saveButton = screen.getByTestId("save-profile-btn"); + fireEvent.click(saveButton); - // Click save - the actual form submission requires the embedded - // LlmSettingsScreen to provide form values via onSaveControlChange. - // Since we mock that component's behavior, we verify the mutation hook - // was set up correctly and the UI state transitions work. - await user.click(saveButton); - - // After successful save, should return to list view - // Note: The actual save flow depends on the embedded LlmSettingsScreen - // providing a saveControl with form values. This test verifies the - // component correctly wires the mutation hook and handles UI transitions. - await waitFor(() => { - // Either we're back at list view or the save button interaction completed - const profileList = screen.queryByText("gpt-4-profile"); - const createView = screen.queryByTestId("profile-name-input"); - expect(profileList || createView).toBeTruthy(); - }); + expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); }); describe("create mode form initialization", () => { diff --git a/__tests__/hooks/query/use-agent-settings-schema.test.tsx b/__tests__/hooks/query/use-agent-settings-schema.test.tsx index 858f7c7d3..ad78f31b2 100644 --- a/__tests__/hooks/query/use-agent-settings-schema.test.tsx +++ b/__tests__/hooks/query/use-agent-settings-schema.test.tsx @@ -12,6 +12,7 @@ import { ActiveBackendProvider } from "#/contexts/active-backend-context"; import { useActiveBackendContext } from "#/contexts/active-backend-context"; import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema"; import type { SettingsSchema } from "#/types/settings"; +import { withLlmSubscriptionSchemaFields } from "#/utils/llm-subscription-schema"; const agentSchema: SettingsSchema = { model_name: "AgentSettings", @@ -86,7 +87,9 @@ describe("useAgentSettingsSchema", () => { }); await waitFor(() => { - expect(result.current.schemaQuery.data).toEqual(agentSchema); + expect(result.current.schemaQuery.data).toEqual( + withLlmSubscriptionSchemaFields(agentSchema), + ); }); expect(getSettingsSchemaSpy).toHaveBeenCalledTimes(1); }); diff --git a/__tests__/package-library.test.ts b/__tests__/package-library.test.ts index fd9b4640d..6296fbc8f 100644 --- a/__tests__/package-library.test.ts +++ b/__tests__/package-library.test.ts @@ -53,11 +53,18 @@ describe("package library metadata", () => { // Git dependencies break `npm install -g` because npm clones the repo and // runs the prepare script without devDependencies. All packages should be - // referenced from a registry; only @openhands/extensions is allowed as a git - // dep until it is published to npm. - it("does not use git dependencies (except @openhands/extensions)", () => { - const GIT_DEP_PATTERN = /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/; - const ALLOWED_GIT_DEPS = new Set(["@openhands/extensions"]); + // referenced from a registry. @openhands/extensions is allowed until it is + // published to npm; @openhands/typescript-client is temporarily allowed while + // this stacked PR waits for the subscription client branch to merge/release. + // TODO(#917): remove @openhands/typescript-client exemption once + // OpenHands/typescript-client#178 merges and publishes to npm. + it("does not use git dependencies except approved stack pins", () => { + const GIT_DEP_PATTERN = + /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/; + const ALLOWED_GIT_DEPS = new Set([ + "@openhands/extensions", + "@openhands/typescript-client", + ]); const allDeps = { ...packageJson.dependencies, diff --git a/__tests__/routes/llm-settings.test.tsx b/__tests__/routes/llm-settings.test.tsx index 58b0ab315..a60d9c486 100644 --- a/__tests__/routes/llm-settings.test.tsx +++ b/__tests__/routes/llm-settings.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Import the named export LlmSettingsScreen directly for testing the form component. @@ -11,6 +11,7 @@ import { Settings } from "#/types/settings"; import * as activeBackendContext from "#/contexts/active-backend-context"; import type { Backend } from "#/api/backend-registry/types"; import * as useLlmProfilesHook from "#/hooks/query/use-llm-profiles"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; vi.mock("#/hooks/query/use-llm-profiles"); @@ -162,6 +163,136 @@ describe("LlmSettingsScreen", () => { expect(screen.getByTestId("llm-api-key-input")).toHaveValue(""); expect(screen.queryByTestId("set-indicator")).not.toBeInTheDocument(); }); + + it("renders ChatGPT subscription settings without API key fields", async () => { + vi.spyOn(LLMSubscriptionService, "getOpenAIStatus").mockResolvedValue({ + vendor: "openai", + connected: false, + accountEmail: null, + expiresAt: null, + }); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + buildSettings({ + llm_model: "gpt-5.2-codex", + agent_settings: { + ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, + llm: { + model: "gpt-5.2-codex", + auth_type: "subscription", + subscription_vendor: "openai", + }, + }, + }), + ); + + renderLlmSettingsScreen(); + + await screen.findByTestId("llm-subscription-settings"); + + expect( + screen.getByTestId("openai-subscription-auth-card"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("llm-api-key-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument(); + }); + + it("disables subscription model controls while models are loading", async () => { + vi.spyOn(LLMSubscriptionService, "getOpenAIStatus").mockResolvedValue({ + vendor: "openai", + connected: true, + accountEmail: "graham@example.com", + expiresAt: null, + }); + vi.spyOn(LLMSubscriptionService, "getOpenAIModels").mockReturnValue( + new Promise(() => {}), + ); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + buildSettings({ + llm_model: "gpt-5.2-codex", + agent_settings: { + ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, + llm: { + model: "gpt-5.2-codex", + auth_type: "subscription", + subscription_vendor: "openai", + }, + }, + }), + ); + + renderLlmSettingsScreen(); + + await screen.findByTestId("llm-subscription-settings"); + + expect(screen.getByTestId("llm-auth-type-input")).toBeDisabled(); + expect(screen.getByTestId("llm-subscription-model-input")).toBeDisabled(); + }); + + it("auto-polls the ChatGPT subscription device login after opening verification", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + vi.spyOn(LLMSubscriptionService, "getOpenAIStatus").mockResolvedValue({ + vendor: "openai", + connected: false, + accountEmail: null, + expiresAt: null, + }); + vi.spyOn( + LLMSubscriptionService, + "startOpenAIDeviceLogin", + ).mockResolvedValue({ + deviceCode: "device-code", + userCode: "USER-CODE", + verificationUri: "https://auth.openai.com/activate", + verificationUriComplete: + "https://auth.openai.com/activate?user_code=USER-CODE", + expiresAt: null, + intervalSeconds: 1, + }); + vi.spyOn(LLMSubscriptionService, "getOpenAIModels").mockResolvedValue([ + "gpt-5.2-codex", + ]); + const pollLogin = vi + .spyOn(LLMSubscriptionService, "pollOpenAIDeviceLogin") + .mockResolvedValue({ + vendor: "openai", + connected: true, + accountEmail: "graham@example.com", + expiresAt: null, + }); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + buildSettings({ + llm_model: "gpt-5.2-codex", + agent_settings: { + ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, + llm: { + model: "gpt-5.2-codex", + auth_type: "subscription", + subscription_vendor: "openai", + }, + }, + }), + ); + + renderLlmSettingsScreen(); + + await screen.findByTestId("llm-subscription-settings"); + fireEvent.click(screen.getByTestId("subscription-connect")); + await screen.findByText("USER-CODE"); + + expect(openSpy).toHaveBeenCalledWith( + "https://auth.openai.com/activate?user_code=USER-CODE", + "_blank", + "noopener,noreferrer", + ); + + await waitFor( + () => { + expect(pollLogin).toHaveBeenCalled(); + }, + { timeout: 2500 }, + ); + expect(pollLogin.mock.calls[0]?.[0]).toBe("device-code"); + }); }); describe("LlmSettingsRoute - backend mode rendering", () => { diff --git a/package-lock.json b/package-lock.json index 297f8e606..e0983542a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3485,7 +3485,6 @@ "version": "1.24.3", "resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.24.3.tgz", "integrity": "sha512-4w5rGbgXSnRKm6DZGedGRMWa4nIyl8/1+lEzinBIjZbdE0MD6+89EAItkk6FTvKU0jdVpoCAtmLWBP3Y/r8ORA==", - "license": "MIT", "dependencies": { "@openrouter/sdk": "^0.12.35", "ws": "^8.20.0" diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index e8f0f4819..0151d0885 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -22,6 +22,12 @@ import { } from "./conversation-service/agent-server-conversation-service.types"; import SettingsService from "./settings-service/settings-service.api"; import { getStoredConversationMetadata } from "./conversation-metadata-store"; +import LLMSubscriptionService from "./llm-subscription-service"; +import { + LLM_AUTH_TYPE_SUBSCRIPTION, + OPENAI_SUBSCRIPTION_VENDOR, + isSubscriptionLlmConfig, +} from "#/constants/llm-subscription"; export interface DirectConversationInfo { id: string; @@ -737,6 +743,16 @@ function buildConfiguredOpenHandsAgentSettings( delete llm.base_url; } + if (isSubscriptionLlmConfig(llm)) { + llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; + llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + delete llm.api_key; + delete llm.base_url; + } else { + delete llm.auth_type; + delete llm.subscription_vendor; + } + const mcpConfig = toRecord(agentSettings.mcp_config); if (Object.keys(mcpConfig).length === 0 || !("mcpServers" in mcpConfig)) { delete agentSettings.mcp_config; @@ -972,6 +988,27 @@ export function buildStartConversationRequest( return payload; } +export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR = + "Connect your ChatGPT subscription before starting a conversation with this LLM profile."; + +/** + * Throws if a ChatGPT subscription LLM profile is not connected. + * Called before conversation creation and LLM profile switch only — not on + * subsequent message sends or conversation resume. The agent-server must handle + * mid-conversation token expiry gracefully. + */ +export async function assertSubscriptionAuthReady( + agentSettings: Record, +): Promise { + const llm = toRecord(agentSettings.llm); + if (!isSubscriptionLlmConfig(llm)) return; + + const status = await LLMSubscriptionService.getOpenAIStatus(); + if (!status.connected) { + throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR); + } +} + export async function buildStartConversationRequestWithEncryptedSettings(options: { settings: Settings; query?: string; @@ -991,6 +1028,8 @@ export async function buildStartConversationRequestWithEncryptedSettings(options const { agentSettings, conversationSettings, secretsEncrypted } = settingsResult; + await assertSubscriptionAuthReady(agentSettings); + return buildStartConversationRequest({ ...options, encryptedAgentSettings: agentSettings, diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index 02fa86a18..8505aa7e8 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -34,6 +34,7 @@ import { } from "../cloud/conversation-service.api"; import { DirectConversationInfo, + assertSubscriptionAuthReady, buildStartConversationRequestWithEncryptedSettings, emptyHooksResponse, getDefaultConversationTitle, @@ -710,6 +711,7 @@ class AgentServerConversationService { const model = typeof profile.config.model === "string" ? profile.config.model : ""; if (!model) throw new Error(`Profile '${profileName}' has no model.`); + await assertSubscriptionAuthReady({ llm: profile.config }); await conversationClient.switchLLM(conversationId, { ...profile.config, model, diff --git a/src/api/llm-subscription-service.ts b/src/api/llm-subscription-service.ts new file mode 100644 index 000000000..815e145f5 --- /dev/null +++ b/src/api/llm-subscription-service.ts @@ -0,0 +1,199 @@ +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { + OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + OPENAI_SUBSCRIPTION_LOGOUT_PATH, + OPENAI_SUBSCRIPTION_MODELS_PATH, + OPENAI_SUBSCRIPTION_STATUS_PATH, + OPENAI_SUBSCRIPTION_VENDOR, +} from "#/constants/llm-subscription"; + +type RawSubscriptionStatus = Record; +type RawDeviceStart = Record; + +export interface LLMSubscriptionStatus { + vendor: typeof OPENAI_SUBSCRIPTION_VENDOR; + connected: boolean; + accountEmail: string | null; + expiresAt: string | number | null; +} + +export interface LLMSubscriptionDeviceChallenge { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string | null; + expiresAt: string | number | null; + intervalSeconds: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const readString = ( + value: Record, + keys: string[], +): string | null => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +}; + +const readNumber = ( + value: Record, + keys: string[], +): number | null => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + } + return null; +}; + +const readBoolean = ( + value: Record, + keys: string[], +): boolean => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "boolean") { + return candidate; + } + } + return false; +}; + +async function requestSubscriptionEndpoint( + path: string, + init: RequestInit = {}, +): Promise { + const { host, apiKey } = getAgentServerClientOptions(); + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + if (apiKey) { + headers.set("X-Session-API-Key", apiKey); + } + + const response = await fetch(`${host}${path}`, { ...init, headers }); + if (!response.ok) { + throw new Error(`Subscription request failed with ${response.status}`); + } + return response.json(); +} + +function normalizeModels(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw.filter((item): item is string => typeof item === "string"); + } + if (isRecord(raw) && Array.isArray(raw.models)) { + return raw.models.filter( + (item): item is string => typeof item === "string", + ); + } + return []; +} + +function normalizeStatus(raw: RawSubscriptionStatus): LLMSubscriptionStatus { + return { + vendor: OPENAI_SUBSCRIPTION_VENDOR, + connected: readBoolean(raw, ["connected", "authenticated", "is_connected"]), + accountEmail: readString(raw, ["account_email", "email", "account"]), + expiresAt: + readString(raw, ["expires_at", "expiresAt"]) ?? + readNumber(raw, ["expires_at", "expiresAt"]), + }; +} + +function normalizeDeviceChallenge( + raw: RawDeviceStart, +): LLMSubscriptionDeviceChallenge { + const deviceCode = readString(raw, ["device_code", "deviceCode"]); + const userCode = readString(raw, ["user_code", "userCode"]); + const verificationUri = readString(raw, [ + "verification_uri", + "verificationUri", + "verification_url", + "verificationUrl", + ]); + + if (!deviceCode || !userCode || !verificationUri) { + throw new Error("Subscription device login response is incomplete"); + } + + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete: readString(raw, [ + "verification_uri_complete", + "verificationUriComplete", + "verification_url_complete", + "verificationUrlComplete", + ]), + expiresAt: + readString(raw, ["expires_at", "expiresAt"]) ?? + readNumber(raw, ["expires_at", "expiresAt", "expires_in", "expiresIn"]), + intervalSeconds: readNumber(raw, [ + "interval", + "interval_seconds", + "intervalSeconds", + ]), + }; +} + +class LLMSubscriptionService { + static async getOpenAIModels(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_MODELS_PATH, + ); + return normalizeModels(response); + } + + static async getOpenAIStatus(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_STATUS_PATH, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } + + static async startOpenAIDeviceLogin(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + { method: "POST" }, + ); + return normalizeDeviceChallenge(response as RawDeviceStart); + } + + static async pollOpenAIDeviceLogin( + deviceCode: string, + ): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, + { + method: "POST", + body: JSON.stringify({ device_code: deviceCode }), + }, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } + + static async logoutOpenAI(): Promise { + const response = await requestSubscriptionEndpoint( + OPENAI_SUBSCRIPTION_LOGOUT_PATH, + { method: "POST" }, + ); + return normalizeStatus(response as RawSubscriptionStatus); + } +} + +export default LLMSubscriptionService; diff --git a/src/components/features/settings/brand-button.tsx b/src/components/features/settings/brand-button.tsx index 2fced8705..70abdf6c4 100644 --- a/src/components/features/settings/brand-button.tsx +++ b/src/components/features/settings/brand-button.tsx @@ -51,7 +51,7 @@ export const BrandButton = forwardRef< className={cn( formControlButtonClassName, variant === "primary" && - "bg-primary text-[var(--oh-color-base)] hover:opacity-80", + "bg-primary text-[var(--oh-color-base)] hover:opacity-80 disabled:bg-[var(--oh-interactive-hover)] disabled:text-[var(--oh-muted)] disabled:opacity-100", variant === "secondary" && "border border-[var(--oh-border)] bg-base-secondary text-white hover:bg-surface-raised", variant === "tertiary" && diff --git a/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx b/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx index fb8be1193..caeaa3872 100644 --- a/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx +++ b/src/components/features/settings/llm-profiles/llm-settings-local-view.tsx @@ -17,6 +17,7 @@ import { useSettings } from "#/hooks/query/use-settings"; import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema"; import ProfilesService, { ProfileInfo, + type SaveProfileRequest, } from "#/api/profiles-service/profiles-service.api"; import { displayErrorToast, @@ -28,6 +29,14 @@ import { isProfileNameValid, } from "#/utils/derive-profile-name"; import { SdkSectionSaveControl } from "../sdk-settings/sdk-section-page"; +import { + LLM_AUTH_TYPE_API_KEY, + LLM_AUTH_TYPE_KEY, + LLM_AUTH_TYPE_SUBSCRIPTION, + LLM_SUBSCRIPTION_VENDOR_KEY, + OPENAI_SUBSCRIPTION_VENDOR, + resolveLlmAuthType, +} from "#/constants/llm-subscription"; import { normalizeFieldValue, SettingsFormValues, @@ -178,6 +187,12 @@ export function LlmSettingsLocalView() { initialValues["llm.model"] = (config.model as string) ?? ""; initialValues["llm.api_key"] = (config.api_key as string) ?? ""; initialValues["llm.base_url"] = (config.base_url as string) ?? ""; + initialValues[LLM_AUTH_TYPE_KEY] = resolveLlmAuthType( + config.auth_type, + ); + initialValues[LLM_SUBSCRIPTION_VENDOR_KEY] = + (config.subscription_vendor as string) ?? + OPENAI_SUBSCRIPTION_VENDOR; } setEditingProfile({ profile, initialValues, baseConfig: config }); @@ -248,27 +263,38 @@ export function LlmSettingsLocalView() { ? { ...editingProfile.baseConfig } : {}; const llmConfig: Record = { ...baseConfig, ...dirtyLlm }; + const authType = resolveLlmAuthType(llmConfig.auth_type); - // The Basic tab has no base_url field. Provider defaults are handled by - // the backend, so drop stale custom values for every provider. - if (saveControl.view === "basic") { + if (authType === LLM_AUTH_TYPE_SUBSCRIPTION) { + llmConfig.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; + llmConfig.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + delete llmConfig.api_key; delete llmConfig.base_url; - } + } else { + llmConfig.auth_type = LLM_AUTH_TYPE_API_KEY; + llmConfig.subscription_vendor = null; + + // The Basic tab has no base_url field. Provider defaults are handled by + // the backend, so drop stale custom values for every provider. + if (saveControl.view === "basic") { + delete llmConfig.base_url; + } - // API key handling: an empty value means "no change" (the UX doesn't - // support clearing a key). In edit mode preserve the existing encrypted - // key from the profile; in create mode omit api_key entirely. A newly - // typed key arrives in `dirtyLlm` and wins. - if ( - typeof llmConfig.api_key !== "string" || - llmConfig.api_key.trim() === "" - ) { - const existingKey = - typeof baseConfig.api_key === "string" ? baseConfig.api_key : ""; - if (existingKey) { - llmConfig.api_key = existingKey; - } else { - delete llmConfig.api_key; + // API key handling: an empty value means "no change" (the UX doesn't + // support clearing a key). In edit mode preserve the existing encrypted + // key from the profile; in create mode omit api_key entirely. A newly + // typed key arrives in `dirtyLlm` and wins. + if ( + typeof llmConfig.api_key !== "string" || + llmConfig.api_key.trim() === "" + ) { + const existingKey = + typeof baseConfig.api_key === "string" ? baseConfig.api_key : ""; + if (existingKey) { + llmConfig.api_key = existingKey; + } else { + delete llmConfig.api_key; + } } } @@ -298,11 +324,7 @@ export function LlmSettingsLocalView() { await saveProfile.mutateAsync({ name: trimmedName, request: { - llm: llmConfig as { - model: string; - api_key?: string; - base_url?: string; - }, + llm: llmConfig as SaveProfileRequest["llm"], include_secrets: true, }, }); @@ -401,7 +423,13 @@ export function LlmSettingsLocalView() { ? // Edit mode: use the existing profile values editingProfile.initialValues : // Create mode: start with empty fields for a fresh profile - { "llm.model": "", "llm.api_key": "", "llm.base_url": "" } + { + "llm.model": "", + "llm.api_key": "", + "llm.base_url": "", + [LLM_AUTH_TYPE_KEY]: LLM_AUTH_TYPE_API_KEY, + [LLM_SUBSCRIPTION_VENDOR_KEY]: OPENAI_SUBSCRIPTION_VENDOR, + } } onSaveControlChange={handleSaveControlChange} /> diff --git a/src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx b/src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx new file mode 100644 index 000000000..7de007642 --- /dev/null +++ b/src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx @@ -0,0 +1,280 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ExternalLink } from "lucide-react"; +import type { LLMSubscriptionDeviceChallenge } from "#/api/llm-subscription-service"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; +import { + useLogoutOpenAISubscription, + usePollOpenAISubscriptionLogin, + useStartOpenAISubscriptionLogin, +} from "#/hooks/mutation/use-llm-subscription-auth"; +import { useOpenAISubscriptionStatus } from "#/hooks/query/use-llm-subscription-status"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; + +const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5; +const MIN_DEVICE_POLL_INTERVAL_SECONDS = 1; + +interface OpenAISubscriptionAuthCardProps { + isDisabled?: boolean; +} + +function openVerificationUrl(challenge: LLMSubscriptionDeviceChallenge) { + const url = challenge.verificationUriComplete ?? challenge.verificationUri; + window.open(url, "_blank", "noopener,noreferrer"); +} + +export function OpenAISubscriptionAuthCard({ + isDisabled = false, +}: OpenAISubscriptionAuthCardProps) { + const { t } = useTranslation("openhands"); + const status = useOpenAISubscriptionStatus(); + const startLogin = useStartOpenAISubscriptionLogin(); + const pollLogin = usePollOpenAISubscriptionLogin(); + const logout = useLogoutOpenAISubscription(); + const [challenge, setChallenge] = + React.useState(null); + const [copied, setCopied] = React.useState(false); + const [isPendingLogin, setIsPendingLogin] = React.useState(false); + const pollTimeoutRef = React.useRef(null); + + const clearPollTimeout = React.useCallback(() => { + if (pollTimeoutRef.current !== null) { + window.clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + const handleCopyCode = () => { + if (!challenge) return; + navigator.clipboard.writeText(challenge.userCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const isBusy = + startLogin.isPending || pollLogin.isPending || logout.isPending; + const connected = Boolean(status.data?.connected); + + const pollDeviceLogin = React.useCallback( + async (deviceCode: string) => { + try { + const nextStatus = await pollLogin.mutateAsync(deviceCode); + if (nextStatus.connected) { + setChallenge(null); + setIsPendingLogin(false); + displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECTED_TOAST)); + return true; + } + setIsPendingLogin(true); + return false; + } catch { + displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR)); + return true; + } + }, + [pollLogin, t], + ); + + React.useEffect(() => { + if (!challenge || connected || isDisabled) { + return undefined; + } + + let cancelled = false; + const intervalSeconds = Math.max( + challenge.intervalSeconds ?? DEFAULT_DEVICE_POLL_INTERVAL_SECONDS, + MIN_DEVICE_POLL_INTERVAL_SECONDS, + ); + + const schedulePoll = () => { + clearPollTimeout(); + pollTimeoutRef.current = window.setTimeout(async () => { + if (cancelled) { + return; + } + + const shouldStop = await pollDeviceLogin(challenge.deviceCode); + if (!cancelled && !shouldStop) { + schedulePoll(); + } + }, intervalSeconds * 1000); + }; + + schedulePoll(); + + return () => { + cancelled = true; + clearPollTimeout(); + }; + }, [challenge, clearPollTimeout, connected, isDisabled, pollDeviceLogin]); + + const handleStartLogin = async () => { + try { + const nextChallenge = await startLogin.mutateAsync(); + setChallenge(nextChallenge); + setIsPendingLogin(true); + openVerificationUrl(nextChallenge); + } catch { + displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR)); + } + }; + + const handlePollLogin = async () => { + if (!challenge) return; + clearPollTimeout(); + void pollDeviceLogin(challenge.deviceCode); + }; + + const handleLogout = async () => { + try { + await logout.mutateAsync(); + clearPollTimeout(); + setChallenge(null); + setIsPendingLogin(false); + displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECTED_TOAST)); + } catch { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + const handleCancelLogin = () => { + clearPollTimeout(); + setChallenge(null); + setIsPendingLogin(false); + }; + + return ( +
+
+ + {t(I18nKey.SETTINGS$SUBSCRIPTION_CARD_TITLE)} + + + {t(I18nKey.SETTINGS$SUBSCRIPTION_CARD_DESCRIPTION)} + +
+ +
+ {status.isLoading ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_CHECKING)} + + ) : status.isError ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_UNAVAILABLE)} + + ) : connected ? ( + <> + + {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_CONNECTED)} + + {status.data?.accountEmail ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_ACCOUNT, { + account: status.data.accountEmail, + })} + + ) : null} + + ) : ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_STATUS_DISCONNECTED)} + + )} +
+ + {challenge ? ( +
+ {t(I18nKey.SETTINGS$SUBSCRIPTION_DEVICE_INSTRUCTIONS)} +
+ {challenge.userCode} + +
+ + {t(I18nKey.SETTINGS$SUBSCRIPTION_OPEN_LOGIN)} + + + {isPendingLogin ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_PENDING_TOAST)} + + ) : null} +
+ ) : null} + +
+ {connected ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECT)} + + ) : ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT)} + + )} + + {challenge ? ( + <> + + {t(I18nKey.SETTINGS$SUBSCRIPTION_FINISH_SIGN_IN)} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ) : null} +
+
+ ); +} diff --git a/src/constants/llm-subscription.ts b/src/constants/llm-subscription.ts new file mode 100644 index 000000000..7d4ac2a3b --- /dev/null +++ b/src/constants/llm-subscription.ts @@ -0,0 +1,75 @@ +import type { SettingsChoice, SettingsFieldSchema } from "#/types/settings"; + +export const LLM_AUTH_TYPE_KEY = "llm.auth_type"; +export const LLM_SUBSCRIPTION_VENDOR_KEY = "llm.subscription_vendor"; +export const LLM_AUTH_TYPE_API_KEY = "api_key"; +export const LLM_AUTH_TYPE_SUBSCRIPTION = "subscription"; +export const OPENAI_SUBSCRIPTION_VENDOR = "openai"; + +export const OPENAI_SUBSCRIPTION_MODELS_PATH = + "/api/llm/subscription/openai/models"; +export const OPENAI_SUBSCRIPTION_STATUS_PATH = + "/api/llm/subscription/openai/status"; +export const OPENAI_SUBSCRIPTION_DEVICE_START_PATH = + "/api/llm/subscription/openai/device/start"; +export const OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH = + "/api/llm/subscription/openai/device/poll"; +export const OPENAI_SUBSCRIPTION_LOGOUT_PATH = + "/api/llm/subscription/openai/logout"; + +export type LlmAuthType = + | typeof LLM_AUTH_TYPE_API_KEY + | typeof LLM_AUTH_TYPE_SUBSCRIPTION; + +export const LLM_AUTH_TYPE_CHOICES: SettingsChoice[] = [ + { label: "API key", value: LLM_AUTH_TYPE_API_KEY }, + { label: "ChatGPT subscription", value: LLM_AUTH_TYPE_SUBSCRIPTION }, +]; + +const LLM_AUTH_TYPE_FIELD: SettingsFieldSchema = { + key: LLM_AUTH_TYPE_KEY, + label: "Authentication", + description: + "Choose whether this profile uses API credentials or a ChatGPT subscription.", + section: "llm", + section_label: "LLM", + value_type: "string", + default: LLM_AUTH_TYPE_API_KEY, + choices: LLM_AUTH_TYPE_CHOICES, + depends_on: [], + prominence: "critical", + secret: false, + required: true, +}; + +const LLM_SUBSCRIPTION_VENDOR_FIELD: SettingsFieldSchema = { + key: LLM_SUBSCRIPTION_VENDOR_KEY, + label: "Subscription provider", + description: "Provider used for subscription-backed LLM access.", + section: "llm", + section_label: "LLM", + value_type: "string", + default: OPENAI_SUBSCRIPTION_VENDOR, + choices: [{ label: "OpenAI", value: OPENAI_SUBSCRIPTION_VENDOR }], + depends_on: [], + prominence: "critical", + secret: false, + required: true, +}; + +export const LLM_SUBSCRIPTION_SCHEMA_FIELDS = [ + LLM_AUTH_TYPE_FIELD, + LLM_SUBSCRIPTION_VENDOR_FIELD, +]; + +export function resolveLlmAuthType(value: unknown): LlmAuthType { + return value === LLM_AUTH_TYPE_SUBSCRIPTION + ? LLM_AUTH_TYPE_SUBSCRIPTION + : LLM_AUTH_TYPE_API_KEY; +} + +export function isSubscriptionLlmConfig( + llm: Record | null | undefined, +): boolean { + return resolveLlmAuthType(llm?.auth_type) === LLM_AUTH_TYPE_SUBSCRIPTION; +} diff --git a/src/hooks/mutation/use-llm-subscription-auth.ts b/src/hooks/mutation/use-llm-subscription-auth.ts new file mode 100644 index 000000000..9842fa43e --- /dev/null +++ b/src/hooks/mutation/use-llm-subscription-auth.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; +import { LLM_SUBSCRIPTION_QUERY_KEYS } from "#/hooks/query/query-keys"; + +export function useStartOpenAISubscriptionLogin() { + return useMutation({ + mutationFn: LLMSubscriptionService.startOpenAIDeviceLogin, + }); +} + +export function usePollOpenAISubscriptionLogin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: LLMSubscriptionService.pollOpenAIDeviceLogin, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: LLM_SUBSCRIPTION_QUERY_KEYS.openaiStatus, + }); + }, + }); +} + +export function useLogoutOpenAISubscription() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: LLMSubscriptionService.logoutOpenAI, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: LLM_SUBSCRIPTION_QUERY_KEYS.openaiStatus, + }); + }, + }); +} diff --git a/src/hooks/query/query-keys.ts b/src/hooks/query/query-keys.ts index 2cf1a5ad0..23150e927 100644 --- a/src/hooks/query/query-keys.ts +++ b/src/hooks/query/query-keys.ts @@ -20,6 +20,12 @@ export const LLM_PROFILES_QUERY_KEYS = { all: ["llm-profiles"] as const, } as const; +export const LLM_SUBSCRIPTION_QUERY_KEYS = { + all: ["llm-subscription"] as const, + openaiStatus: ["llm-subscription", "openai", "status"] as const, + openaiModels: ["llm-subscription", "openai", "models"] as const, +} as const; + export const LOCAL_WORKSPACES_QUERY_KEYS = { all: ["local-workspaces"] as const, } as const; diff --git a/src/hooks/query/use-agent-settings-schema.ts b/src/hooks/query/use-agent-settings-schema.ts index 99195ffd4..b7746078e 100644 --- a/src/hooks/query/use-agent-settings-schema.ts +++ b/src/hooks/query/use-agent-settings-schema.ts @@ -1,8 +1,10 @@ +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { isNoBackend } from "#/api/backend-registry/active-store"; import SettingsService from "#/api/settings-service/settings-service.api"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { SettingsSchema } from "#/types/settings"; +import { withLlmSubscriptionSchemaFields } from "#/utils/llm-subscription-schema"; import { useIsAuthed } from "./use-is-authed"; const useSettingsSchema = ( @@ -36,9 +38,22 @@ const useSettingsSchema = ( }, }); + const fallbackData = useMemo( + () => + type === "agent" + ? withLlmSubscriptionSchemaFields(fallbackSchema) + : fallbackSchema, + [fallbackSchema, type], + ); + + const queryData = useMemo( + () => (type === "agent" ? withLlmSubscriptionSchemaFields(data) : data), + [data, type], + ); + if (fallbackSchema) { return { - data: fallbackSchema, + data: fallbackData, error: null, isLoading: false, isFetching: false, @@ -46,7 +61,7 @@ const useSettingsSchema = ( } return { - data, + data: queryData, error, isLoading, isFetching, diff --git a/src/hooks/query/use-llm-subscription-models.ts b/src/hooks/query/use-llm-subscription-models.ts new file mode 100644 index 000000000..e9aa4ef52 --- /dev/null +++ b/src/hooks/query/use-llm-subscription-models.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; +import { LLM_SUBSCRIPTION_QUERY_KEYS } from "#/hooks/query/query-keys"; + +export function useOpenAISubscriptionModels({ + enabled = true, +}: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: LLM_SUBSCRIPTION_QUERY_KEYS.openaiModels, + queryFn: LLMSubscriptionService.getOpenAIModels, + enabled, + retry: false, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + meta: { + disableToast: true, + }, + }); +} diff --git a/src/hooks/query/use-llm-subscription-status.ts b/src/hooks/query/use-llm-subscription-status.ts new file mode 100644 index 000000000..14487bf33 --- /dev/null +++ b/src/hooks/query/use-llm-subscription-status.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; +import { LLM_SUBSCRIPTION_QUERY_KEYS } from "#/hooks/query/query-keys"; + +export function useOpenAISubscriptionStatus({ + enabled = true, +}: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: LLM_SUBSCRIPTION_QUERY_KEYS.openaiStatus, + queryFn: LLMSubscriptionService.getOpenAIStatus, + enabled, + retry: false, + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 5, + meta: { + disableToast: true, + }, + }); +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index bae71cfe8..e8ebc2d9d 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -29137,6 +29137,363 @@ "uk": "Модель обов'язкова", "ca": "El model és obligatori" }, + "SETTINGS$LLM_AUTH_TYPE": { + "en": "Authentication", + "ja": "認証", + "zh-CN": "身份验证", + "zh-TW": "驗證", + "ko-KR": "인증", + "no": "Autentisering", + "it": "Autenticazione", + "pt": "Autenticação", + "es": "Autenticación", + "ar": "المصادقة", + "fr": "Authentification", + "tr": "Kimlik doğrulama", + "de": "Authentifizierung", + "uk": "Автентифікація", + "ca": "Autenticació" + }, + "SETTINGS$LLM_AUTH_TYPE_API_KEY": { + "en": "API key", + "ja": "APIキー", + "zh-CN": "API 密钥", + "zh-TW": "API 金鑰", + "ko-KR": "API 키", + "no": "API-nøkkel", + "it": "Chiave API", + "pt": "Chave de API", + "es": "Clave de API", + "ar": "مفتاح API", + "fr": "Clé API", + "tr": "API anahtarı", + "de": "API-Schlüssel", + "uk": "Ключ API", + "ca": "Clau d’API" + }, + "SETTINGS$LLM_AUTH_TYPE_SUBSCRIPTION": { + "en": "ChatGPT subscription", + "ja": "ChatGPTサブスクリプション", + "zh-CN": "ChatGPT 订阅", + "zh-TW": "ChatGPT 訂閱", + "ko-KR": "ChatGPT 구독", + "no": "ChatGPT-abonnement", + "it": "Abbonamento ChatGPT", + "pt": "Assinatura do ChatGPT", + "es": "Suscripción de ChatGPT", + "ar": "اشتراك ChatGPT", + "fr": "Abonnement ChatGPT", + "tr": "ChatGPT aboneliği", + "de": "ChatGPT-Abonnement", + "uk": "Підписка ChatGPT", + "ca": "Subscripció de ChatGPT" + }, + "SETTINGS$SUBSCRIPTION_MODEL": { + "en": "Subscription model", + "ja": "サブスクリプションモデル", + "zh-CN": "订阅模型", + "zh-TW": "訂閱模型", + "ko-KR": "구독 모델", + "no": "Abonnementsmodell", + "it": "Modello dell’abbonamento", + "pt": "Modelo da assinatura", + "es": "Modelo de suscripción", + "ar": "نموذج الاشتراك", + "fr": "Modèle d’abonnement", + "tr": "Abonelik modeli", + "de": "Abonnementmodell", + "uk": "Модель підписки", + "ca": "Model de subscripció" + }, + "SETTINGS$SUBSCRIPTION_CARD_TITLE": { + "en": "ChatGPT subscription", + "ja": "ChatGPTサブスクリプション", + "zh-CN": "ChatGPT 订阅", + "zh-TW": "ChatGPT 訂閱", + "ko-KR": "ChatGPT 구독", + "no": "ChatGPT-abonnement", + "it": "Abbonamento ChatGPT", + "pt": "Assinatura do ChatGPT", + "es": "Suscripción de ChatGPT", + "ar": "اشتراك ChatGPT", + "fr": "Abonnement ChatGPT", + "tr": "ChatGPT aboneliği", + "de": "ChatGPT-Abonnement", + "uk": "Підписка ChatGPT", + "ca": "Subscripció de ChatGPT" + }, + "SETTINGS$SUBSCRIPTION_CARD_DESCRIPTION": { + "en": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "ja": "接続済みのChatGPT PlusまたはProサブスクリプションを使用して、OpenAI APIキーなしで対応するCodexモデルを実行します。", + "zh-CN": "使用已连接的 ChatGPT Plus 或 Pro 订阅,无需 OpenAI API 密钥即可运行受支持的 Codex 模型。", + "zh-TW": "使用已連線的 ChatGPT Plus 或 Pro 訂閱,無需 OpenAI API 金鑰即可執行支援的 Codex 模型。", + "ko-KR": "연결된 ChatGPT Plus 또는 Pro 구독을 사용해 OpenAI API 키 없이 지원되는 Codex 모델을 실행합니다.", + "no": "Bruk et tilkoblet ChatGPT Plus- eller Pro-abonnement for å kjøre støttede Codex-modeller uten en OpenAI API-nøkkel.", + "it": "Usa un abbonamento ChatGPT Plus o Pro collegato per eseguire i modelli Codex supportati senza una chiave API OpenAI.", + "pt": "Use uma assinatura conectada do ChatGPT Plus ou Pro para executar modelos Codex compatíveis sem uma chave de API da OpenAI.", + "es": "Usa una suscripción conectada de ChatGPT Plus o Pro para ejecutar modelos Codex compatibles sin una clave API de OpenAI.", + "ar": "استخدم اشتراك ChatGPT Plus أو Pro متصلاً لتشغيل نماذج Codex المدعومة بدون مفتاح API من OpenAI.", + "fr": "Utilisez un abonnement ChatGPT Plus ou Pro connecté pour exécuter les modèles Codex pris en charge sans clé API OpenAI.", + "tr": "Desteklenen Codex modellerini OpenAI API anahtarı olmadan çalıştırmak için bağlı bir ChatGPT Plus veya Pro aboneliği kullanın.", + "de": "Verwende ein verbundenes ChatGPT Plus- oder Pro-Abonnement, um unterstützte Codex-Modelle ohne OpenAI-API-Schlüssel auszuführen.", + "uk": "Використовуйте підключену підписку ChatGPT Plus або Pro, щоб запускати підтримувані моделі Codex без ключа API OpenAI.", + "ca": "Fes servir una subscripció de ChatGPT Plus o Pro connectada per executar models Codex compatibles sense una clau API d’OpenAI." + }, + "SETTINGS$SUBSCRIPTION_STATUS_CHECKING": { + "en": "Checking subscription connection…", + "ja": "サブスクリプション接続を確認しています…", + "zh-CN": "正在检查订阅连接…", + "zh-TW": "正在檢查訂閱連線…", + "ko-KR": "구독 연결을 확인하는 중…", + "no": "Sjekker abonnementstilkobling…", + "it": "Verifica della connessione dell’abbonamento…", + "pt": "Verificando conexão da assinatura…", + "es": "Comprobando conexión de la suscripción…", + "ar": "جارٍ التحقق من اتصال الاشتراك…", + "fr": "Vérification de la connexion de l’abonnement…", + "tr": "Abonelik bağlantısı kontrol ediliyor…", + "de": "Abonnementverbindung wird geprüft…", + "uk": "Перевірка підключення підписки…", + "ca": "S’està comprovant la connexió de la subscripció…" + }, + "SETTINGS$SUBSCRIPTION_STATUS_CONNECTED": { + "en": "ChatGPT subscription connected", + "ja": "ChatGPTサブスクリプションは接続済みです", + "zh-CN": "ChatGPT 订阅已连接", + "zh-TW": "ChatGPT 訂閱已連接", + "ko-KR": "ChatGPT 구독이 연결됨", + "no": "ChatGPT-abonnement tilkoblet", + "it": "Abbonamento ChatGPT connesso", + "pt": "Assinatura do ChatGPT conectada", + "es": "Suscripción de ChatGPT conectada", + "ar": "اشتراك ChatGPT متصل", + "fr": "Abonnement ChatGPT connecté", + "tr": "ChatGPT aboneliği bağlı", + "de": "ChatGPT-Abonnement verbunden", + "uk": "Підписку ChatGPT підключено", + "ca": "Subscripció de ChatGPT connectada" + }, + "SETTINGS$SUBSCRIPTION_STATUS_DISCONNECTED": { + "en": "ChatGPT subscription not connected", + "ja": "ChatGPTサブスクリプションは未接続です", + "zh-CN": "ChatGPT 订阅未连接", + "zh-TW": "ChatGPT 訂閱未連接", + "ko-KR": "ChatGPT 구독이 연결되지 않음", + "no": "ChatGPT-abonnement ikke tilkoblet", + "it": "Abbonamento ChatGPT non connesso", + "pt": "Assinatura do ChatGPT não conectada", + "es": "Suscripción de ChatGPT no conectada", + "ar": "اشتراك ChatGPT غير متصل", + "fr": "Abonnement ChatGPT non connecté", + "tr": "ChatGPT aboneliği bağlı değil", + "de": "ChatGPT-Abonnement nicht verbunden", + "uk": "Підписку ChatGPT не підключено", + "ca": "Subscripció de ChatGPT no connectada" + }, + "SETTINGS$SUBSCRIPTION_STATUS_UNAVAILABLE": { + "en": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "ja": "サブスクリプション状態は利用できません。サブスクリプション認証エンドポイントを備えたagent serverのバージョンにアップグレードしてください。", + "zh-CN": "订阅状态不可用。请将 agent server 升级到包含订阅身份验证端点的版本。", + "zh-TW": "訂閱狀態無法使用。請將 agent server 升級到包含訂閱驗證端點的版本。", + "ko-KR": "구독 상태를 사용할 수 없습니다. 구독 인증 엔드포인트가 있는 agent server 버전으로 업그레이드하세요.", + "no": "Abonnementsstatus er utilgjengelig. Oppgrader agent server til en versjon med endepunkter for abonnementsautentisering.", + "it": "Lo stato dell’abbonamento non è disponibile. Aggiorna l’agent server a una versione con endpoint di autenticazione per abbonamenti.", + "pt": "O status da assinatura não está disponível. Atualize o agent server para uma versão com endpoints de autenticação por assinatura.", + "es": "El estado de la suscripción no está disponible. Actualiza el agent server a una versión con endpoints de autenticación de suscripción.", + "ar": "حالة الاشتراك غير متاحة. رقّ agent server إلى إصدار يتضمن نقاط نهاية مصادقة الاشتراك.", + "fr": "L’état de l’abonnement n’est pas disponible. Mettez à niveau l’agent server vers une version avec des points de terminaison d’authentification par abonnement.", + "tr": "Abonelik durumu kullanılamıyor. Agent server’ı abonelik kimlik doğrulama uç noktalarına sahip bir sürüme yükseltin.", + "de": "Der Abonnementstatus ist nicht verfügbar. Aktualisiere den agent server auf eine Version mit Endpunkten für Abonnementauthentifizierung.", + "uk": "Стан підписки недоступний. Оновіть agent server до версії з кінцевими точками автентифікації підписки.", + "ca": "L’estat de la subscripció no està disponible. Actualitza l’agent server a una versió amb punts finals d’autenticació de subscripció." + }, + "SETTINGS$SUBSCRIPTION_ACCOUNT": { + "en": "Account: {{account}}", + "ja": "アカウント: {{account}}", + "zh-CN": "账户:{{account}}", + "zh-TW": "帳戶:{{account}}", + "ko-KR": "계정: {{account}}", + "no": "Konto: {{account}}", + "it": "Account: {{account}}", + "pt": "Conta: {{account}}", + "es": "Cuenta: {{account}}", + "ar": "الحساب: {{account}}", + "fr": "Compte : {{account}}", + "tr": "Hesap: {{account}}", + "de": "Konto: {{account}}", + "uk": "Обліковий запис: {{account}}", + "ca": "Compte: {{account}}" + }, + "SETTINGS$SUBSCRIPTION_CONNECT": { + "en": "Connect ChatGPT", + "ja": "ChatGPTに接続", + "zh-CN": "连接 ChatGPT", + "zh-TW": "連接 ChatGPT", + "ko-KR": "ChatGPT 연결", + "no": "Koble til ChatGPT", + "it": "Connetti ChatGPT", + "pt": "Conectar ChatGPT", + "es": "Conectar ChatGPT", + "ar": "اتصل بـ ChatGPT", + "fr": "Connecter ChatGPT", + "tr": "ChatGPT’ye bağlan", + "de": "ChatGPT verbinden", + "uk": "Підключити ChatGPT", + "ca": "Connecta ChatGPT" + }, + "SETTINGS$SUBSCRIPTION_DISCONNECT": { + "en": "Disconnect", + "ja": "切断", + "zh-CN": "断开连接", + "zh-TW": "中斷連線", + "ko-KR": "연결 해제", + "no": "Koble fra", + "it": "Disconnetti", + "pt": "Desconectar", + "es": "Desconectar", + "ar": "قطع الاتصال", + "fr": "Déconnecter", + "tr": "Bağlantıyı kes", + "de": "Trennen", + "uk": "Відключити", + "ca": "Desconnecta" + }, + "SETTINGS$SUBSCRIPTION_DEVICE_INSTRUCTIONS": { + "en": "Finish signing in with OpenAI in your browser, then return here.", + "ja": "ブラウザーでOpenAIへのサインインを完了してから、ここに戻ってください。", + "zh-CN": "在浏览器中完成 OpenAI 登录,然后返回此处。", + "zh-TW": "在瀏覽器中完成 OpenAI 登入,然後返回這裡。", + "ko-KR": "브라우저에서 OpenAI 로그인을 완료한 뒤 여기로 돌아오세요.", + "no": "Fullfør påloggingen med OpenAI i nettleseren, og kom tilbake hit.", + "it": "Completa l’accesso con OpenAI nel browser, poi torna qui.", + "pt": "Conclua o login com a OpenAI no navegador e volte aqui.", + "es": "Termina de iniciar sesión con OpenAI en tu navegador y vuelve aquí.", + "ar": "أكمل تسجيل الدخول إلى OpenAI في المتصفح، ثم عُد إلى هنا.", + "fr": "Terminez la connexion à OpenAI dans votre navigateur, puis revenez ici.", + "tr": "Tarayıcınızda OpenAI oturum açma işlemini tamamlayın, sonra buraya dönün.", + "de": "Schließe die Anmeldung bei OpenAI im Browser ab und kehre dann hierher zurück.", + "uk": "Завершіть вхід в OpenAI у браузері, а потім поверніться сюди.", + "ca": "Acaba d’iniciar la sessió amb OpenAI al navegador i torna aquí." + }, + "SETTINGS$SUBSCRIPTION_USER_CODE": { + "en": "Code: {{code}}", + "ja": "コード: {{code}}", + "zh-CN": "代码:{{code}}", + "zh-TW": "代碼:{{code}}", + "ko-KR": "코드: {{code}}", + "no": "Kode: {{code}}", + "it": "Codice: {{code}}", + "pt": "Código: {{code}}", + "es": "Código: {{code}}", + "ar": "الرمز: {{code}}", + "fr": "Code : {{code}}", + "tr": "Kod: {{code}}", + "de": "Code: {{code}}", + "uk": "Код: {{code}}", + "ca": "Codi: {{code}}" + }, + "SETTINGS$SUBSCRIPTION_OPEN_LOGIN": { + "en": "Open sign-in page", + "ja": "サインインページを開く", + "zh-CN": "打开登录页面", + "zh-TW": "開啟登入頁面", + "ko-KR": "로그인 페이지 열기", + "no": "Åpne påloggingssiden", + "it": "Apri la pagina di accesso", + "pt": "Abrir página de login", + "es": "Abrir página de inicio de sesión", + "ar": "فتح صفحة تسجيل الدخول", + "fr": "Ouvrir la page de connexion", + "tr": "Oturum açma sayfasını aç", + "de": "Anmeldeseite öffnen", + "uk": "Відкрити сторінку входу", + "ca": "Obre la pàgina d’inici de sessió" + }, + "SETTINGS$SUBSCRIPTION_FINISH_SIGN_IN": { + "en": "I've finished signing in", + "ja": "サインインを完了しました", + "zh-CN": "我已完成登录", + "zh-TW": "我已完成登入", + "ko-KR": "로그인을 완료했습니다", + "no": "Jeg er ferdig med å logge inn", + "it": "Ho completato l’accesso", + "pt": "Concluí o login", + "es": "He terminado de iniciar sesión", + "ar": "أنهيت تسجيل الدخول", + "fr": "J’ai terminé la connexion", + "tr": "Oturum açmayı tamamladım", + "de": "Ich habe die Anmeldung abgeschlossen", + "uk": "Я завершив вхід", + "ca": "He acabat d’iniciar la sessió" + }, + "SETTINGS$SUBSCRIPTION_CONNECT_ERROR": { + "en": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "ja": "ChatGPTサブスクリプションに接続できません。再試行するか、agent-serverのログを確認してください。", + "zh-CN": "无法连接你的 ChatGPT 订阅。请重试或检查 agent-server 日志。", + "zh-TW": "無法連接你的 ChatGPT 訂閱。請重試或檢查 agent-server 記錄。", + "ko-KR": "ChatGPT 구독을 연결할 수 없습니다. 다시 시도하거나 agent-server 로그를 확인하세요.", + "no": "Kunne ikke koble til ChatGPT-abonnementet. Prøv igjen eller sjekk agent-server-loggene.", + "it": "Impossibile connettere l’abbonamento ChatGPT. Riprova o controlla i log di agent-server.", + "pt": "Não foi possível conectar sua assinatura do ChatGPT. Tente novamente ou verifique os logs do agent-server.", + "es": "No se pudo conectar tu suscripción de ChatGPT. Inténtalo de nuevo o revisa los registros de agent-server.", + "ar": "تعذر توصيل اشتراك ChatGPT. حاول مرة أخرى أو تحقق من سجلات agent-server.", + "fr": "Impossible de connecter votre abonnement ChatGPT. Réessayez ou consultez les journaux de l’agent-server.", + "tr": "ChatGPT aboneliğiniz bağlanamadı. Tekrar deneyin veya agent-server günlüklerini kontrol edin.", + "de": "Dein ChatGPT-Abonnement konnte nicht verbunden werden. Versuche es erneut oder prüfe die agent-server-Protokolle.", + "uk": "Не вдалося підключити підписку ChatGPT. Спробуйте ще раз або перевірте журнали agent-server.", + "ca": "No s’ha pogut connectar la subscripció de ChatGPT. Torna-ho a provar o revisa els registres de l’agent-server." + }, + "SETTINGS$SUBSCRIPTION_CONNECTED_TOAST": { + "en": "ChatGPT subscription connected", + "ja": "ChatGPTサブスクリプションに接続しました", + "zh-CN": "ChatGPT 订阅已连接", + "zh-TW": "ChatGPT 訂閱已連接", + "ko-KR": "ChatGPT 구독이 연결되었습니다", + "no": "ChatGPT-abonnement tilkoblet", + "it": "Abbonamento ChatGPT connesso", + "pt": "Assinatura do ChatGPT conectada", + "es": "Suscripción de ChatGPT conectada", + "ar": "تم توصيل اشتراك ChatGPT", + "fr": "Abonnement ChatGPT connecté", + "tr": "ChatGPT aboneliği bağlandı", + "de": "ChatGPT-Abonnement verbunden", + "uk": "Підписку ChatGPT підключено", + "ca": "Subscripció de ChatGPT connectada" + }, + "SETTINGS$SUBSCRIPTION_DISCONNECTED_TOAST": { + "en": "ChatGPT subscription disconnected", + "ja": "ChatGPTサブスクリプションを切断しました", + "zh-CN": "ChatGPT 订阅已断开连接", + "zh-TW": "ChatGPT 訂閱已中斷連線", + "ko-KR": "ChatGPT 구독 연결이 해제되었습니다", + "no": "ChatGPT-abonnement frakoblet", + "it": "Abbonamento ChatGPT disconnesso", + "pt": "Assinatura do ChatGPT desconectada", + "es": "Suscripción de ChatGPT desconectada", + "ar": "تم قطع اتصال اشتراك ChatGPT", + "fr": "Abonnement ChatGPT déconnecté", + "tr": "ChatGPT aboneliği bağlantısı kesildi", + "de": "ChatGPT-Abonnement getrennt", + "uk": "Підписку ChatGPT відключено", + "ca": "Subscripció de ChatGPT desconnectada" + }, + "SETTINGS$SUBSCRIPTION_PENDING_TOAST": { + "en": "Sign-in is not complete yet. Finish the browser flow and try again.", + "ja": "サインインはまだ完了していません。ブラウザーのフローを完了してから再試行してください。", + "zh-CN": "登录尚未完成。请完成浏览器流程后重试。", + "zh-TW": "登入尚未完成。請完成瀏覽器流程後再試一次。", + "ko-KR": "로그인이 아직 완료되지 않았습니다. 브라우저 흐름을 완료한 뒤 다시 시도하세요.", + "no": "Påloggingen er ikke fullført ennå. Fullfør nettleserflyten og prøv igjen.", + "it": "L’accesso non è ancora completo. Completa il flusso nel browser e riprova.", + "pt": "O login ainda não foi concluído. Finalize o fluxo no navegador e tente novamente.", + "es": "El inicio de sesión aún no está completo. Termina el flujo del navegador e inténtalo de nuevo.", + "ar": "لم يكتمل تسجيل الدخول بعد. أنهِ تدفق المتصفح وحاول مرة أخرى.", + "fr": "La connexion n’est pas encore terminée. Finissez le parcours dans le navigateur et réessayez.", + "tr": "Oturum açma henüz tamamlanmadı. Tarayıcı akışını bitirin ve tekrar deneyin.", + "de": "Die Anmeldung ist noch nicht abgeschlossen. Beende den Browserablauf und versuche es erneut.", + "uk": "Вхід ще не завершено. Завершіть процес у браузері й повторіть спробу.", + "ca": "L’inici de sessió encara no s’ha completat. Acaba el flux del navegador i torna-ho a provar." + }, "STATUS$SAVING": { "en": "Saving...", "ja": "保存中...", diff --git a/src/mocks/settings-handlers.ts b/src/mocks/settings-handlers.ts index f04d2e430..ac73c2374 100644 --- a/src/mocks/settings-handlers.ts +++ b/src/mocks/settings-handlers.ts @@ -3,6 +3,14 @@ import { WebClientConfig } from "#/api/option-service/option.types"; import type { SaveProfileRequest } from "#/api/profiles-service/profiles-service.api"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings, SettingsValue } from "#/types/settings"; +import { + OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, + OPENAI_SUBSCRIPTION_DEVICE_START_PATH, + OPENAI_SUBSCRIPTION_LOGOUT_PATH, + OPENAI_SUBSCRIPTION_MODELS_PATH, + OPENAI_SUBSCRIPTION_STATUS_PATH, + OPENAI_SUBSCRIPTION_VENDOR, +} from "#/constants/llm-subscription"; /** Simple recursive merge — objects merge, scalars overwrite. */ function deepMerge( @@ -444,6 +452,8 @@ const MOCK_LLM_PROFILES: { activeProfile: null, }; +let mockOpenAISubscriptionConnected = false; + const getProfileNameParam = (value: unknown): string => decodeURIComponent( Array.isArray(value) ? String(value[0] ?? "") : String(value ?? ""), @@ -526,6 +536,7 @@ export const resetTestHandlersMockSettings = () => { MOCK_USER_PREFERENCES.settings = structuredClone(MOCK_DEFAULT_USER_SETTINGS); MOCK_LLM_PROFILES.profiles.clear(); MOCK_LLM_PROFILES.activeProfile = null; + mockOpenAISubscriptionConnected = false; }; // Mock model data used by provider/model endpoints @@ -548,6 +559,8 @@ const MOCK_MODELS = [ "sambanova/Meta-Llama-3.1-8B-Instruct", ]; +const MOCK_OPENAI_SUBSCRIPTION_MODELS = ["gpt-5.2", "gpt-5.3-codex"]; + const MOCK_VERIFIED_MODELS = new Set([ "anthropic/claude-opus-4-5-20251101", "anthropic/claude-opus-4-8", @@ -628,6 +641,49 @@ export const SETTINGS_HANDLERS = [ HttpResponse.json({ providers: MOCK_MODEL_PROVIDERS }), ), + http.get(`*${OPENAI_SUBSCRIPTION_MODELS_PATH}`, async () => + HttpResponse.json({ + vendor: OPENAI_SUBSCRIPTION_VENDOR, + models: MOCK_OPENAI_SUBSCRIPTION_MODELS, + }), + ), + + http.get(`*${OPENAI_SUBSCRIPTION_STATUS_PATH}`, async () => + HttpResponse.json({ + connected: mockOpenAISubscriptionConnected, + account_email: mockOpenAISubscriptionConnected + ? "mock-chatgpt@example.com" + : null, + expires_at: null, + }), + ), + + http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_START_PATH}`, async () => + HttpResponse.json({ + device_code: "mock-device-code", + user_code: "MOCK-CODE", + verification_uri: "https://auth.openai.com/activate", + verification_uri_complete: + "https://auth.openai.com/activate?user_code=MOCK-CODE", + interval: 1, + expires_in: 900, + }), + ), + + http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH}`, async () => { + mockOpenAISubscriptionConnected = true; + return HttpResponse.json({ + connected: true, + account_email: "mock-chatgpt@example.com", + expires_at: null, + }); + }), + + http.post(`*${OPENAI_SUBSCRIPTION_LOGOUT_PATH}`, async () => { + mockOpenAISubscriptionConnected = false; + return HttpResponse.json({ connected: false }); + }), + // V0 (legacy) models endpoint – still used for default_model http.get("*/api/options/models", async () => HttpResponse.json({ diff --git a/src/mocks/workspace-handlers.ts b/src/mocks/workspace-handlers.ts new file mode 100644 index 000000000..7c8a6d915 --- /dev/null +++ b/src/mocks/workspace-handlers.ts @@ -0,0 +1,89 @@ +import { http, HttpResponse } from "msw"; + +import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace"; + +interface MockWorkspacesState { + workspaces: LocalWorkspace[]; + workspaceParents: LocalWorkspaceParent[]; +} + +const mockWorkspaces: MockWorkspacesState = { + workspaces: [], + workspaceParents: [], +}; + +function workspacesResponse() { + return { + workspaces: mockWorkspaces.workspaces, + workspaceParents: mockWorkspaces.workspaceParents, + }; +} + +function readPath(url: URL) { + return url.searchParams.get("path") ?? ""; +} + +export function resetWorkspaceMockData() { + mockWorkspaces.workspaces = []; + mockWorkspaces.workspaceParents = []; +} + +export const WORKSPACE_HANDLERS = [ + http.get("*/api/workspaces", () => HttpResponse.json(workspacesResponse())), + http.post("*/api/workspaces", async ({ request }) => { + const body = (await request.json()) as { workspaces?: LocalWorkspace[] }; + const nextWorkspaces = body.workspaces ?? []; + const existingPaths = new Set( + mockWorkspaces.workspaces.map((workspace) => workspace.path), + ); + + for (const workspace of nextWorkspaces) { + if (!existingPaths.has(workspace.path)) { + mockWorkspaces.workspaces.push(workspace); + existingPaths.add(workspace.path); + } + } + + return HttpResponse.json(workspacesResponse()); + }), + http.delete("*/api/workspaces", ({ request }) => { + const path = readPath(new URL(request.url)); + mockWorkspaces.workspaces = mockWorkspaces.workspaces.filter( + (workspace) => workspace.path !== path, + ); + return HttpResponse.json({ ok: true }); + }), + http.post("*/api/workspaces/parents", async ({ request }) => { + const body = (await request.json()) as { parents?: LocalWorkspaceParent[] }; + const nextParents = body.parents ?? []; + const existingPaths = new Set( + mockWorkspaces.workspaceParents.map((parent) => parent.path), + ); + + for (const parent of nextParents) { + if (!existingPaths.has(parent.path)) { + mockWorkspaces.workspaceParents.push(parent); + existingPaths.add(parent.path); + } + } + + return HttpResponse.json(workspacesResponse()); + }), + http.delete("*/api/workspaces/parents", ({ request }) => { + const path = readPath(new URL(request.url)); + mockWorkspaces.workspaceParents = mockWorkspaces.workspaceParents.filter( + (parent) => parent.path !== path, + ); + mockWorkspaces.workspaces = mockWorkspaces.workspaces.filter( + (workspace) => workspace.parentPath !== path, + ); + return HttpResponse.json({ ok: true }); + }), + http.post("*/api/auth/workspace-session", () => + HttpResponse.json({ base_url: "/api/conversations/mock/workspace/" }), + ), + http.delete( + "*/api/auth/workspace-session", + () => new HttpResponse(null, { status: 204 }), + ), +]; diff --git a/src/routes/llm-settings.tsx b/src/routes/llm-settings.tsx index 60d21b3b0..37b96582e 100644 --- a/src/routes/llm-settings.tsx +++ b/src/routes/llm-settings.tsx @@ -4,6 +4,8 @@ import { ModelSelector } from "#/components/shared/modals/settings/model-selecto import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema"; import { useSettings } from "#/hooks/query/use-settings"; import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; +import { OpenAISubscriptionAuthCard } from "#/components/features/settings/llm-settings/openai-subscription-auth-card"; import { HelpLink } from "#/ui/help-link"; import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; import { @@ -22,9 +24,24 @@ import { type SettingsView, } from "#/utils/sdk-settings-schema"; import { DEFAULT_SETTINGS } from "#/services/settings"; +import { + LLM_AUTH_TYPE_API_KEY, + LLM_AUTH_TYPE_KEY, + LLM_AUTH_TYPE_SUBSCRIPTION, + LLM_SUBSCRIPTION_VENDOR_KEY, + OPENAI_SUBSCRIPTION_VENDOR, + resolveLlmAuthType, +} from "#/constants/llm-subscription"; +import { useOpenAISubscriptionModels } from "#/hooks/query/use-llm-subscription-models"; import { OPENHANDS_LLM_PROXY_BASE_URL } from "#/utils/openhands-llm"; -const LLM_EXCLUDED_KEYS = new Set(["llm.model", "llm.api_key", "llm.base_url"]); +const LLM_EXCLUDED_KEYS = new Set([ + "llm.model", + "llm.api_key", + "llm.base_url", + LLM_AUTH_TYPE_KEY, + LLM_SUBSCRIPTION_VENDOR_KEY, +]); const buildModelId = (provider: string | null, model: string | null) => { if (!provider || !model) return null; @@ -124,6 +141,32 @@ export function LlmSettingsScreen({ const { data: schema } = useAgentSettingsSchema( settings?.agent_settings_schema, ); + const persistedLlmSettings = settings?.agent_settings?.llm as + | Record + | undefined; + const initialAuthType = resolveLlmAuthType( + initialValueOverrides?.[LLM_AUTH_TYPE_KEY] ?? + persistedLlmSettings?.auth_type, + ); + const [enableSubscriptionModels, setEnableSubscriptionModels] = + React.useState(initialAuthType === LLM_AUTH_TYPE_SUBSCRIPTION); + const { + data: subscriptionModels, + isLoading: isSubscriptionModelsLoading, + isFetching: isSubscriptionModelsFetching, + } = useOpenAISubscriptionModels({ enabled: enableSubscriptionModels }); + const isWaitingForSubscriptionModels = + enableSubscriptionModels && + !subscriptionModels && + (isSubscriptionModelsLoading || isSubscriptionModelsFetching); + const lastApiKeyModelRef = React.useRef(null); + const lastSubscriptionModelRef = React.useRef(null); + + React.useEffect(() => { + if (initialAuthType === LLM_AUTH_TYPE_SUBSCRIPTION) { + setEnableSubscriptionModels(true); + } + }, [initialAuthType]); const defaultModel = String( (DEFAULT_SETTINGS.agent_settings?.llm as Record)?.model ?? @@ -160,6 +203,13 @@ export function LlmSettingsScreen({ ? values["llm.base_url"] : ""; const showOpenHandsApiKeyHelp = modelValue.startsWith("openhands/"); + const authType = resolveLlmAuthType(values[LLM_AUTH_TYPE_KEY]); + const isSubscriptionAuth = authType === LLM_AUTH_TYPE_SUBSCRIPTION; + const shouldDisableSubscriptionControls = + isDisabled || (isSubscriptionAuth && isWaitingForSubscriptionModels); + const subscriptionModelValue = subscriptionModels?.includes(modelValue) + ? modelValue + : (subscriptionModels?.[0] ?? ""); const apiKeyValue = typeof values["llm.api_key"] === "string" ? values["llm.api_key"] : ""; @@ -196,6 +246,95 @@ export function LlmSettingsScreen({ ); + const handleAuthTypeChange = (selectedKey: React.Key | null) => { + const nextAuthType = + selectedKey === LLM_AUTH_TYPE_SUBSCRIPTION + ? LLM_AUTH_TYPE_SUBSCRIPTION + : LLM_AUTH_TYPE_API_KEY; + onChange(LLM_AUTH_TYPE_KEY, nextAuthType); + + if (nextAuthType === LLM_AUTH_TYPE_SUBSCRIPTION) { + setEnableSubscriptionModels(true); + if (modelValue && !subscriptionModels?.includes(modelValue)) { + lastApiKeyModelRef.current = modelValue; + } + const restoredSubscriptionModel = + lastSubscriptionModelRef.current && + subscriptionModels?.includes(lastSubscriptionModelRef.current) + ? lastSubscriptionModelRef.current + : subscriptionModels?.[0]; + onChange(LLM_SUBSCRIPTION_VENDOR_KEY, OPENAI_SUBSCRIPTION_VENDOR); + if ( + !subscriptionModels?.includes(modelValue) && + restoredSubscriptionModel + ) { + onChange("llm.model", restoredSubscriptionModel); + } + return; + } + + if (modelValue && subscriptionModels?.includes(modelValue)) { + lastSubscriptionModelRef.current = modelValue; + onChange("llm.model", lastApiKeyModelRef.current ?? defaultModel); + } + }; + + const renderAuthTypeInput = () => ( + + ); + + const renderSubscriptionSettings = () => ( +
+ ({ + key: model, + label: model, + }))} + selectedKey={subscriptionModelValue} + isClearable={false} + required + isDisabled={ + shouldDisableSubscriptionControls || !subscriptionModels?.length + } + onSelectionChange={(selectedKey) => { + const nextModel = + typeof selectedKey === "string" + ? selectedKey + : subscriptionModels?.[0]; + if (nextModel) { + onChange("llm.model", nextModel); + } + }} + /> + +
+ ); + return (
{view === "basic" ? ( @@ -203,26 +342,34 @@ export function LlmSettingsScreen({ className="flex flex-col gap-6" data-testid="llm-settings-form-basic" > - { - const nextModel = buildModelId(provider, model); - if (nextModel) { - onChange("llm.model", nextModel); - } - }} - wrapperClassName="!flex-col !gap-6" - isDisabled={isDisabled} - /> - - {showOpenHandsApiKeyHelp ? ( - - ) : null} - - {renderApiKeyInput( - "llm-api-key-input", - "llm-api-key-help-anchor", + {renderAuthTypeInput()} + + {isSubscriptionAuth ? ( + renderSubscriptionSettings() + ) : ( + <> + { + const nextModel = buildModelId(provider, model); + if (nextModel) { + onChange("llm.model", nextModel); + } + }} + wrapperClassName="!flex-col !gap-6" + isDisabled={isDisabled} + /> + + {showOpenHandsApiKeyHelp ? ( + + ) : null} + + {renderApiKeyInput( + "llm-api-key-input", + "llm-api-key-help-anchor", + )} + )}
) : ( @@ -230,42 +377,57 @@ export function LlmSettingsScreen({ className="flex flex-col gap-6" data-testid="llm-settings-form-advanced" > - onChange("llm.model", value)} - isDisabled={isDisabled} - /> - - {showOpenHandsApiKeyHelp ? ( - - ) : null} - - onChange("llm.base_url", value)} - isDisabled={isDisabled} - /> - - {renderApiKeyInput( - "llm-api-key-input", - "llm-api-key-help-anchor-advanced", + {renderAuthTypeInput()} + + {isSubscriptionAuth ? ( + renderSubscriptionSettings() + ) : ( + <> + onChange("llm.model", value)} + isDisabled={isDisabled} + /> + + {showOpenHandsApiKeyHelp ? ( + + ) : null} + + onChange("llm.base_url", value)} + isDisabled={isDisabled} + /> + + {renderApiKeyInput( + "llm-api-key-input", + "llm-api-key-help-anchor-advanced", + )} + )} )} ); }, - [defaultModel, embedded, settings?.llm_api_key_set, t], + [ + defaultModel, + embedded, + isWaitingForSubscriptionModels, + settings?.llm_api_key_set, + subscriptionModels, + t, + ], ); const buildPayload = React.useCallback( @@ -273,6 +435,7 @@ export function LlmSettingsScreen({ defaultPayload: Record, context: { values: Record; + dirty: Record; view: SettingsView; }, ) => { @@ -282,17 +445,42 @@ export function LlmSettingsScreen({ const agentSettings = structuredClone( (defaultPayload.agent_settings_diff as Record) ?? {}, ); - const llm = (agentSettings.llm ?? {}) as Record; - - if (context.view === "basic") { - llm.base_url = getSchemaFieldDefaultValue(schema, "llm.base_url"); - agentSettings.llm = llm; + const authType = resolveLlmAuthType(context.values[LLM_AUTH_TYPE_KEY]); + + if (authType === LLM_AUTH_TYPE_SUBSCRIPTION) { + llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; + llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + const model = + typeof llm.model === "string" + ? llm.model + : String(context.values["llm.model"] ?? ""); + const fallbackSubscriptionModel = subscriptionModels?.[0]; + if ( + !subscriptionModels?.includes(model) && + !fallbackSubscriptionModel + ) { + throw new Error("Subscription models are not loaded yet."); + } + llm.model = subscriptionModels?.includes(model) + ? model + : fallbackSubscriptionModel; + delete llm.api_key; + delete llm.base_url; + } else { + if (context.dirty[LLM_AUTH_TYPE_KEY]) { + llm.auth_type = LLM_AUTH_TYPE_API_KEY; + llm.subscription_vendor = null; + } + if (context.view === "basic") { + llm.base_url = getSchemaFieldDefaultValue(schema, "llm.base_url"); + } } + agentSettings.llm = llm; return { agent_settings_diff: agentSettings }; }, - [schema], + [schema, subscriptionModels], ); return ( diff --git a/src/utils/llm-subscription-schema.ts b/src/utils/llm-subscription-schema.ts new file mode 100644 index 000000000..f599164af --- /dev/null +++ b/src/utils/llm-subscription-schema.ts @@ -0,0 +1,30 @@ +import type { SettingsSchema } from "#/types/settings"; +import { LLM_SUBSCRIPTION_SCHEMA_FIELDS } from "#/constants/llm-subscription"; + +const LLM_SECTION_KEY = "llm"; + +export function withLlmSubscriptionSchemaFields( + schema: SettingsSchema | null | undefined, +): SettingsSchema | null | undefined { + if (!schema?.sections) return schema; + + let changed = false; + const sections = schema.sections.map((section) => { + if (section.key !== LLM_SECTION_KEY) return section; + + const existingKeys = new Set(section.fields.map((field) => field.key)); + const missingFields = LLM_SUBSCRIPTION_SCHEMA_FIELDS.filter( + (field) => !existingKeys.has(field.key), + ); + + if (missingFields.length === 0) return section; + changed = true; + return { + ...section, + fields: [...section.fields, ...missingFields], + }; + }); + + if (!changed) return schema; + return { ...schema, sections }; +} diff --git a/src/utils/sdk-settings-schema.ts b/src/utils/sdk-settings-schema.ts index 8abea191a..e2a14505f 100644 --- a/src/utils/sdk-settings-schema.ts +++ b/src/utils/sdk-settings-schema.ts @@ -7,6 +7,10 @@ import { SettingsValue, } from "#/types/settings"; import { getSettingsFieldConstraints } from "#/utils/sdk-settings-field-metadata"; +import { + LLM_AUTH_TYPE_KEY, + LLM_SUBSCRIPTION_VENDOR_KEY, +} from "#/constants/llm-subscription"; export type SettingsFormValues = Record; export type SettingsDirtyState = Record; @@ -21,6 +25,8 @@ export const SPECIALLY_RENDERED_KEYS = new Set([ "llm.model", "llm.api_key", "llm.base_url", + LLM_AUTH_TYPE_KEY, + LLM_SUBSCRIPTION_VENDOR_KEY, ]); /** Prominence tiers visible at each view level. */