diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 2f6ad67b7..37fc32105 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -13,6 +13,10 @@ import { setStoredConversationMetadata, } from "#/api/conversation-metadata-store"; import { DEFAULT_SETTINGS } from "#/services/settings"; +import { + LLM_AUTH_TYPE_SUBSCRIPTION, + OPENAI_SUBSCRIPTION_VENDOR, +} from "#/constants/llm-subscription"; const { mockGetAgentServerWorkingDir, @@ -127,6 +131,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..b1a44b6ae --- /dev/null +++ b/__tests__/api/llm-subscription-service.test.ts @@ -0,0 +1,115 @@ +import { LLMMetadataClient } from "@openhands/typescript-client/clients"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LLMSubscriptionService from "#/api/llm-subscription-service"; +const { + mockGetStatus, + mockStartDeviceLogin, + mockPollDeviceLogin, + mockLogout, + mockClose, +} = vi.hoisted(() => ({ + mockGetStatus: vi.fn(), + mockStartDeviceLogin: vi.fn(), + mockPollDeviceLogin: vi.fn(), + mockLogout: vi.fn(), + mockClose: vi.fn(), +})); + +vi.mock("@openhands/typescript-client/clients", async () => { + const actual = await vi.importActual< + typeof import("@openhands/typescript-client/clients") + >("@openhands/typescript-client/clients"); + return { + ...actual, + LLMMetadataClient: vi.fn(function LLMMetadataClientMock() { + return { + getOpenAISubscriptionStatus: mockGetStatus, + startOpenAISubscriptionDeviceLogin: mockStartDeviceLogin, + pollOpenAISubscriptionDeviceLogin: mockPollDeviceLogin, + logoutOpenAISubscription: mockLogout, + close: mockClose, + }; + }), + }; +}); + +vi.mock("#/api/agent-server-client-options", () => ({ + getAgentServerClientOptions: vi.fn(() => ({ + host: "http://localhost:18000", + apiKey: "session-key", + workingDir: "/workspace/project", + })), +})); + +describe("LLMSubscriptionService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("normalizes OpenAI subscription status", async () => { + mockGetStatus.mockResolvedValue({ + authenticated: true, + email: "user@example.com", + expires_at: 123, + }); + + await expect(LLMSubscriptionService.getOpenAIStatus()).resolves.toEqual({ + vendor: "openai", + connected: true, + accountEmail: "user@example.com", + expiresAt: 123, + }); + + expect(LLMMetadataClient).toHaveBeenCalledWith({ + host: "http://localhost:18000", + apiKey: "session-key", + workingDir: "/workspace/project", + }); + expect(mockGetStatus).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it("normalizes device login challenge responses", async () => { + mockStartDeviceLogin.mockResolvedValue({ + device_code: "device-123", + user_code: "ABCD-EFGH", + verification_uri: "https://auth.openai.com/activate", + verification_uri_complete: "https://auth.openai.com/activate?code=ABCD", + expires_in: 900, + interval: 5, + }); + + await expect( + LLMSubscriptionService.startOpenAIDeviceLogin(), + ).resolves.toEqual({ + deviceCode: "device-123", + userCode: "ABCD-EFGH", + verificationUri: "https://auth.openai.com/activate", + verificationUriComplete: "https://auth.openai.com/activate?code=ABCD", + expiresAt: 900, + intervalSeconds: 5, + }); + + expect(mockStartDeviceLogin).toHaveBeenCalled(); + }); + + it("posts the device code when polling login", async () => { + mockPollDeviceLogin.mockResolvedValue({ connected: true }); + + await expect( + LLMSubscriptionService.pollOpenAIDeviceLogin("device-123"), + ).resolves.toMatchObject({ connected: true }); + + expect(mockPollDeviceLogin).toHaveBeenCalledWith("device-123"); + }); + + it("calls the logout endpoint", async () => { + mockLogout.mockResolvedValue({ connected: false }); + + await expect(LLMSubscriptionService.logoutOpenAI()).resolves.toMatchObject({ + connected: false, + }); + + expect(mockLogout).toHaveBeenCalled(); + }); +}); 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 c48a4449a..0eb32cf3b 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 { LlmSettingsLocalView } from "#/components/features/settings/llm-profiles/llm-settings-local-view"; @@ -9,6 +9,41 @@ 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; + values: Record; + }) => void; + }) => { + const initialValueOverridesRef = React.useRef(initialValueOverrides); + React.useEffect(() => { + onSaveControlChange?.({ + save: vi.fn(), + isSaving: false, + isDirty: true, + values: { + "llm.model": "openai/gpt-4o", + "llm.api_key": "test-api-key", + "llm.base_url": "", + ...(initialValueOverridesRef.current ?? {}), + }, + }); + }, [onSaveControlChange]); + + return
; + }, + }; +}); + vi.mock("#/hooks/query/use-llm-profiles"); vi.mock("#/hooks/mutation/use-activate-llm-profile"); vi.mock("#/hooks/mutation/use-save-llm-profile"); @@ -128,7 +163,9 @@ describe("LlmSettingsLocalView", () => { expect( screen.getByText(/Add LLM Profile|SETTINGS\$ADD_LLM_PROFILE/), ).toBeInTheDocument(); - expect(screen.getByTestId("profile-editor-description")).toBeInTheDocument(); + expect( + screen.getByTestId("profile-editor-description"), + ).toBeInTheDocument(); }); it("returns to list view when back button clicked", async () => { @@ -189,55 +226,28 @@ describe("LlmSettingsLocalView", () => { renderWithProviders(); // Error message component should be rendered (text is a translation key) - expect(screen.getByText("SETTINGS$PROFILES_LOAD_ERROR")).toBeInTheDocument(); + expect( + screen.getByText("SETTINGS$PROFILES_LOAD_ERROR"), + ).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", () => { @@ -340,9 +350,9 @@ describe("LlmSettingsLocalView", () => { expect( screen.getByText(/Edit LLM Profile|SETTINGS\$EDIT_LLM_PROFILE/), ).toBeInTheDocument(); - expect(screen.getByTestId("profile-editor-description")).toHaveTextContent( - /gpt-4-profile|SETTINGS\$PROFILE_LOADED/, - ); + expect( + screen.getByTestId("profile-editor-description"), + ).toHaveTextContent(/gpt-4-profile|SETTINGS\$PROFILE_LOADED/); // Verify getProfile was called with the correct profile name expect(ProfilesService.getProfile).toHaveBeenCalledWith( diff --git a/__tests__/routes/llm-settings.test.tsx b/__tests__/routes/llm-settings.test.tsx index 58b0ab315..5cae7e507 100644 --- a/__tests__/routes/llm-settings.test.tsx +++ b/__tests__/routes/llm-settings.test.tsx @@ -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,38 @@ 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(); + }); }); describe("LlmSettingsRoute - backend mode rendering", () => { diff --git a/package-lock.json b/package-lock.json index 53c706ff2..c92d21fe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@microlink/react-json-view": "1.31.20", "@monaco-editor/react": "4.7.0", "@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#7b33f64ffccf95f7ccd2ec640aeae84ab1cc2c75", - "@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0", + "@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#add-llm-subscription-client", "@react-router/node": "7.14.2", "@react-router/serve": "7.14.2", "@tailwindcss/vite": "4.2.4", @@ -3368,6 +3368,7 @@ "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3455,7 +3456,7 @@ }, "node_modules/@openhands/typescript-client": { "version": "0.1.1", - "resolved": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0", + "resolved": "git+https://github.com/OpenHands/typescript-client.git#c06163a030074cd2e2aa7acfe359cafd216457a9", "license": "MIT", "dependencies": { "@openrouter/sdk": "^0.12.35", @@ -6697,6 +6698,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", diff --git a/package.json b/package.json index aa2a67354..59028d1e2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@microlink/react-json-view": "1.31.20", "@monaco-editor/react": "4.7.0", "@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#7b33f64ffccf95f7ccd2ec640aeae84ab1cc2c75", - "@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0", + "@openhands/typescript-client": "git+https://github.com/OpenHands/typescript-client.git#add-llm-subscription-client", "@react-router/node": "7.14.2", "@react-router/serve": "7.14.2", "@tailwindcss/vite": "4.2.4", diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index a9d1f4287..8c0989dd9 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -19,6 +19,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; @@ -579,6 +585,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; @@ -793,6 +809,21 @@ export function buildStartConversationRequest( return payload; } +export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR = + "Connect your ChatGPT subscription before starting a conversation with this LLM profile."; + +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; @@ -811,6 +842,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 5fe8a66e1..ddd3c9d8c 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -32,6 +32,7 @@ import { } from "../cloud/conversation-service.api"; import { DirectConversationInfo, + assertSubscriptionAuthReady, buildStartConversationRequestWithEncryptedSettings, emptyHooksResponse, getDefaultConversationTitle, @@ -653,6 +654,8 @@ class AgentServerConversationService { throw new Error(INVALID_PROFILE_CONFIG_MESSAGE); } + await assertSubscriptionAuthReady({ llm: profile.config }); + await new ConversationClient(getAgentServerClientOptions()).switchLLM( conversationId, profile.config, diff --git a/src/api/llm-subscription-service.ts b/src/api/llm-subscription-service.ts new file mode 100644 index 000000000..5825c3944 --- /dev/null +++ b/src/api/llm-subscription-service.ts @@ -0,0 +1,162 @@ +import { LLMMetadataClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "./agent-server-client-options"; +import { 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; +} + +const readString = ( + value: Record, + keys: string[], +): string | null => { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + 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 withLlmClient( + callback: (client: LLMMetadataClient) => Promise, +): Promise { + const client = new LLMMetadataClient(getAgentServerClientOptions()); + try { + return await callback(client); + } finally { + client.close(); + } +} + +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 { + return withLlmClient(async (client) => { + const models = await client.getModels("chatgpt"); + return (models ?? []).map((m: string) => m.replace(/^chatgpt\//, "")); + }); + } + + static async getOpenAIStatus(): Promise { + return withLlmClient(async (client) => { + const response = await client.getOpenAISubscriptionStatus(); + return normalizeStatus(response as unknown as RawSubscriptionStatus); + }); + } + + static async startOpenAIDeviceLogin(): Promise { + return withLlmClient(async (client) => { + const response = await client.startOpenAISubscriptionDeviceLogin(); + return normalizeDeviceChallenge(response as unknown as RawDeviceStart); + }); + } + + static async pollOpenAIDeviceLogin( + deviceCode: string, + ): Promise { + return withLlmClient(async (client) => { + const response = + await client.pollOpenAISubscriptionDeviceLogin(deviceCode); + return normalizeStatus(response as unknown as RawSubscriptionStatus); + }); + } + + static async logoutOpenAI(): Promise { + return withLlmClient(async (client) => { + const response = await client.logoutOpenAISubscription(); + return normalizeStatus(response as unknown as RawSubscriptionStatus); + }); + } +} + +export default LLMSubscriptionService; 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 d83a23757..3dba64d35 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 @@ -20,6 +20,14 @@ import { } from "#/utils/derive-profile-name"; import { SdkSectionSaveControl } from "../sdk-settings/sdk-section-page"; import { SettingsFormValues } from "#/utils/sdk-settings-schema"; +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 { ArrowLeft } from "lucide-react"; import { Typography } from "#/ui/typography"; import { useSettingsSectionHeader } from "#/contexts/settings-section-header-context"; @@ -109,6 +117,10 @@ export function LlmSettingsLocalView() { "llm.model": (config.model as string) ?? "", "llm.api_key": (config.api_key as string) ?? "", "llm.base_url": (config.base_url as string) ?? "", + [LLM_AUTH_TYPE_KEY]: resolveLlmAuthType(config.auth_type), + [LLM_SUBSCRIPTION_VENDOR_KEY]: + (config.subscription_vendor as string) ?? + OPENAI_SUBSCRIPTION_VENDOR, }; setEditingProfile({ profile, initialValues }); @@ -163,6 +175,7 @@ export function LlmSettingsLocalView() { typeof values["llm.api_key"] === "string" ? values["llm.api_key"] : ""; const baseUrl = typeof values["llm.base_url"] === "string" ? values["llm.base_url"] : ""; + const authType = resolveLlmAuthType(values[LLM_AUTH_TYPE_KEY]); if (!model) { displayErrorToast(t(I18nKey.SETTINGS$MODEL_REQUIRED)); @@ -182,30 +195,25 @@ export function LlmSettingsLocalView() { await ProfilesService.renameProfile(originalName, trimmedName); } - // Build the LLM config object const llmConfig: Record = { model }; - // API key handling: - // - If user entered a new key, use it - // - In edit mode with no new key, preserve the existing encrypted key - // (fetched with exposeSecrets='encrypted' and passed back to server) - // - In create mode with no key, omit api_key entirely - // - // Note: The current UX doesn't support explicitly clearing an API key. - // If needed, a future enhancement could add a "Clear API Key" option. - // The encrypted key format is stable and can be round-tripped to the server. - if (apiKey) { - llmConfig.api_key = apiKey; - } else if ( - viewMode === "edit" && - editingProfile?.initialValues["llm.api_key"] - ) { - llmConfig.api_key = editingProfile.initialValues["llm.api_key"]; - } + if (authType === LLM_AUTH_TYPE_SUBSCRIPTION) { + llmConfig.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; + llmConfig.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + } else { + llmConfig.auth_type = LLM_AUTH_TYPE_API_KEY; + if (apiKey) { + llmConfig.api_key = apiKey; + } else if ( + viewMode === "edit" && + editingProfile?.initialValues["llm.api_key"] + ) { + llmConfig.api_key = editingProfile.initialValues["llm.api_key"]; + } - // Only include base_url if set - if (baseUrl) { - llmConfig.base_url = baseUrl; + if (baseUrl) { + llmConfig.base_url = baseUrl; + } } await saveProfile.mutateAsync({ @@ -318,7 +326,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..928d5b229 --- /dev/null +++ b/src/components/features/settings/llm-settings/openai-subscription-auth-card.tsx @@ -0,0 +1,201 @@ +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"; + +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 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 handleStartLogin = async () => { + try { + const nextChallenge = await startLogin.mutateAsync(); + setChallenge(nextChallenge); + openVerificationUrl(nextChallenge); + } catch { + displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR)); + } + }; + + const handlePollLogin = async () => { + if (!challenge) return; + try { + const nextStatus = await pollLogin.mutateAsync(challenge.deviceCode); + if (nextStatus.connected) { + setChallenge(null); + displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECTED_TOAST)); + } else { + displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_PENDING_TOAST)); + } + } catch { + displayErrorToast(t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT_ERROR)); + } + }; + + const handleLogout = async () => { + try { + await logout.mutateAsync(); + setChallenge(null); + displaySuccessToast(t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECTED_TOAST)); + } catch { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + 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)} + + +
+ ) : null} + +
+ {connected ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_DISCONNECT)} + + ) : ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_CONNECT)} + + )} + + {challenge ? ( + + {t(I18nKey.SETTINGS$SUBSCRIPTION_FINISH_SIGN_IN)} + + ) : null} +
+
+ ); +} diff --git a/src/constants/llm-subscription.ts b/src/constants/llm-subscription.ts new file mode 100644 index 000000000..5c65b3acf --- /dev/null +++ b/src/constants/llm-subscription.ts @@ -0,0 +1,73 @@ +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_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 2bf9661c9..803ce7413 100644 --- a/src/hooks/query/use-agent-settings-schema.ts +++ b/src/hooks/query/use-agent-settings-schema.ts @@ -1,6 +1,8 @@ +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import SettingsService from "#/api/settings-service/settings-service.api"; import { SettingsSchema } from "#/types/settings"; +import { withLlmSubscriptionSchemaFields } from "#/utils/llm-subscription-schema"; import { useIsAuthed } from "./use-is-authed"; const useSettingsSchema = ( @@ -24,9 +26,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, @@ -34,7 +49,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..5b201b5f5 --- /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 * 30, + meta: { + disableToast: true, + }, + }); +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 0ca4a0e16..4a780141d 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -27556,6 +27556,363 @@ "uk": "Модель обов'язкова", "ca": "El model és obligatori" }, + "SETTINGS$LLM_AUTH_TYPE": { + "en": "Authentication", + "ja": "Authentication", + "zh-CN": "Authentication", + "zh-TW": "Authentication", + "ko-KR": "Authentication", + "no": "Authentication", + "it": "Authentication", + "pt": "Authentication", + "es": "Authentication", + "ar": "Authentication", + "fr": "Authentication", + "tr": "Authentication", + "de": "Authentication", + "uk": "Authentication", + "ca": "Authentication" + }, + "SETTINGS$LLM_AUTH_TYPE_API_KEY": { + "en": "API key", + "ja": "API key", + "zh-CN": "API key", + "zh-TW": "API key", + "ko-KR": "API key", + "no": "API key", + "it": "API key", + "pt": "API key", + "es": "API key", + "ar": "API key", + "fr": "API key", + "tr": "API key", + "de": "API key", + "uk": "API key", + "ca": "API key" + }, + "SETTINGS$LLM_AUTH_TYPE_SUBSCRIPTION": { + "en": "ChatGPT subscription", + "ja": "ChatGPT subscription", + "zh-CN": "ChatGPT subscription", + "zh-TW": "ChatGPT subscription", + "ko-KR": "ChatGPT subscription", + "no": "ChatGPT subscription", + "it": "ChatGPT subscription", + "pt": "ChatGPT subscription", + "es": "ChatGPT subscription", + "ar": "ChatGPT subscription", + "fr": "ChatGPT subscription", + "tr": "ChatGPT subscription", + "de": "ChatGPT subscription", + "uk": "ChatGPT subscription", + "ca": "ChatGPT subscription" + }, + "SETTINGS$SUBSCRIPTION_MODEL": { + "en": "Subscription model", + "ja": "Subscription model", + "zh-CN": "Subscription model", + "zh-TW": "Subscription model", + "ko-KR": "Subscription model", + "no": "Subscription model", + "it": "Subscription model", + "pt": "Subscription model", + "es": "Subscription model", + "ar": "Subscription model", + "fr": "Subscription model", + "tr": "Subscription model", + "de": "Subscription model", + "uk": "Subscription model", + "ca": "Subscription model" + }, + "SETTINGS$SUBSCRIPTION_CARD_TITLE": { + "en": "ChatGPT subscription", + "ja": "ChatGPT subscription", + "zh-CN": "ChatGPT subscription", + "zh-TW": "ChatGPT subscription", + "ko-KR": "ChatGPT subscription", + "no": "ChatGPT subscription", + "it": "ChatGPT subscription", + "pt": "ChatGPT subscription", + "es": "ChatGPT subscription", + "ar": "ChatGPT subscription", + "fr": "ChatGPT subscription", + "tr": "ChatGPT subscription", + "de": "ChatGPT subscription", + "uk": "ChatGPT subscription", + "ca": "ChatGPT subscription" + }, + "SETTINGS$SUBSCRIPTION_CARD_DESCRIPTION": { + "en": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "ja": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "zh-CN": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "zh-TW": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "ko-KR": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "no": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "it": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "pt": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "es": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "ar": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "fr": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "tr": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "de": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "uk": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key.", + "ca": "Use a connected ChatGPT Plus or Pro subscription to run supported Codex models without an OpenAI API key." + }, + "SETTINGS$SUBSCRIPTION_STATUS_CHECKING": { + "en": "Checking subscription connection…", + "ja": "Checking subscription connection…", + "zh-CN": "Checking subscription connection…", + "zh-TW": "Checking subscription connection…", + "ko-KR": "Checking subscription connection…", + "no": "Checking subscription connection…", + "it": "Checking subscription connection…", + "pt": "Checking subscription connection…", + "es": "Checking subscription connection…", + "ar": "Checking subscription connection…", + "fr": "Checking subscription connection…", + "tr": "Checking subscription connection…", + "de": "Checking subscription connection…", + "uk": "Checking subscription connection…", + "ca": "Checking subscription connection…" + }, + "SETTINGS$SUBSCRIPTION_STATUS_CONNECTED": { + "en": "ChatGPT subscription connected", + "ja": "ChatGPT subscription connected", + "zh-CN": "ChatGPT subscription connected", + "zh-TW": "ChatGPT subscription connected", + "ko-KR": "ChatGPT subscription connected", + "no": "ChatGPT subscription connected", + "it": "ChatGPT subscription connected", + "pt": "ChatGPT subscription connected", + "es": "ChatGPT subscription connected", + "ar": "ChatGPT subscription connected", + "fr": "ChatGPT subscription connected", + "tr": "ChatGPT subscription connected", + "de": "ChatGPT subscription connected", + "uk": "ChatGPT subscription connected", + "ca": "ChatGPT subscription connected" + }, + "SETTINGS$SUBSCRIPTION_STATUS_DISCONNECTED": { + "en": "ChatGPT subscription not connected", + "ja": "ChatGPT subscription not connected", + "zh-CN": "ChatGPT subscription not connected", + "zh-TW": "ChatGPT subscription not connected", + "ko-KR": "ChatGPT subscription not connected", + "no": "ChatGPT subscription not connected", + "it": "ChatGPT subscription not connected", + "pt": "ChatGPT subscription not connected", + "es": "ChatGPT subscription not connected", + "ar": "ChatGPT subscription not connected", + "fr": "ChatGPT subscription not connected", + "tr": "ChatGPT subscription not connected", + "de": "ChatGPT subscription not connected", + "uk": "ChatGPT subscription not connected", + "ca": "ChatGPT subscription not connected" + }, + "SETTINGS$SUBSCRIPTION_STATUS_UNAVAILABLE": { + "en": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "ja": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "zh-CN": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "zh-TW": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "ko-KR": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "no": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "it": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "pt": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "es": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "ar": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "fr": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "tr": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "de": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "uk": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints.", + "ca": "Subscription status is unavailable. Upgrade the agent server to a version with subscription auth endpoints." + }, + "SETTINGS$SUBSCRIPTION_ACCOUNT": { + "en": "Account: {{account}}", + "ja": "Account: {{account}}", + "zh-CN": "Account: {{account}}", + "zh-TW": "Account: {{account}}", + "ko-KR": "Account: {{account}}", + "no": "Account: {{account}}", + "it": "Account: {{account}}", + "pt": "Account: {{account}}", + "es": "Account: {{account}}", + "ar": "Account: {{account}}", + "fr": "Account: {{account}}", + "tr": "Account: {{account}}", + "de": "Account: {{account}}", + "uk": "Account: {{account}}", + "ca": "Account: {{account}}" + }, + "SETTINGS$SUBSCRIPTION_CONNECT": { + "en": "Connect ChatGPT", + "ja": "Connect ChatGPT", + "zh-CN": "Connect ChatGPT", + "zh-TW": "Connect ChatGPT", + "ko-KR": "Connect ChatGPT", + "no": "Connect ChatGPT", + "it": "Connect ChatGPT", + "pt": "Connect ChatGPT", + "es": "Connect ChatGPT", + "ar": "Connect ChatGPT", + "fr": "Connect ChatGPT", + "tr": "Connect ChatGPT", + "de": "Connect ChatGPT", + "uk": "Connect ChatGPT", + "ca": "Connect ChatGPT" + }, + "SETTINGS$SUBSCRIPTION_DISCONNECT": { + "en": "Disconnect", + "ja": "Disconnect", + "zh-CN": "Disconnect", + "zh-TW": "Disconnect", + "ko-KR": "Disconnect", + "no": "Disconnect", + "it": "Disconnect", + "pt": "Disconnect", + "es": "Disconnect", + "ar": "Disconnect", + "fr": "Disconnect", + "tr": "Disconnect", + "de": "Disconnect", + "uk": "Disconnect", + "ca": "Disconnect" + }, + "SETTINGS$SUBSCRIPTION_DEVICE_INSTRUCTIONS": { + "en": "Finish signing in with OpenAI in your browser, then return here.", + "ja": "Finish signing in with OpenAI in your browser, then return here.", + "zh-CN": "Finish signing in with OpenAI in your browser, then return here.", + "zh-TW": "Finish signing in with OpenAI in your browser, then return here.", + "ko-KR": "Finish signing in with OpenAI in your browser, then return here.", + "no": "Finish signing in with OpenAI in your browser, then return here.", + "it": "Finish signing in with OpenAI in your browser, then return here.", + "pt": "Finish signing in with OpenAI in your browser, then return here.", + "es": "Finish signing in with OpenAI in your browser, then return here.", + "ar": "Finish signing in with OpenAI in your browser, then return here.", + "fr": "Finish signing in with OpenAI in your browser, then return here.", + "tr": "Finish signing in with OpenAI in your browser, then return here.", + "de": "Finish signing in with OpenAI in your browser, then return here.", + "uk": "Finish signing in with OpenAI in your browser, then return here.", + "ca": "Finish signing in with OpenAI in your browser, then return here." + }, + "SETTINGS$SUBSCRIPTION_USER_CODE": { + "en": "Code: {{code}}", + "ja": "Code: {{code}}", + "zh-CN": "Code: {{code}}", + "zh-TW": "Code: {{code}}", + "ko-KR": "Code: {{code}}", + "no": "Code: {{code}}", + "it": "Code: {{code}}", + "pt": "Code: {{code}}", + "es": "Code: {{code}}", + "ar": "Code: {{code}}", + "fr": "Code: {{code}}", + "tr": "Code: {{code}}", + "de": "Code: {{code}}", + "uk": "Code: {{code}}", + "ca": "Code: {{code}}" + }, + "SETTINGS$SUBSCRIPTION_OPEN_LOGIN": { + "en": "Open sign-in page", + "ja": "Open sign-in page", + "zh-CN": "Open sign-in page", + "zh-TW": "Open sign-in page", + "ko-KR": "Open sign-in page", + "no": "Open sign-in page", + "it": "Open sign-in page", + "pt": "Open sign-in page", + "es": "Open sign-in page", + "ar": "Open sign-in page", + "fr": "Open sign-in page", + "tr": "Open sign-in page", + "de": "Open sign-in page", + "uk": "Open sign-in page", + "ca": "Open sign-in page" + }, + "SETTINGS$SUBSCRIPTION_FINISH_SIGN_IN": { + "en": "I've finished signing in", + "ja": "I've finished signing in", + "zh-CN": "I've finished signing in", + "zh-TW": "I've finished signing in", + "ko-KR": "I've finished signing in", + "no": "I've finished signing in", + "it": "I've finished signing in", + "pt": "I've finished signing in", + "es": "I've finished signing in", + "ar": "I've finished signing in", + "fr": "I've finished signing in", + "tr": "I've finished signing in", + "de": "I've finished signing in", + "uk": "I've finished signing in", + "ca": "I've finished signing in" + }, + "SETTINGS$SUBSCRIPTION_CONNECT_ERROR": { + "en": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "ja": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "zh-CN": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "zh-TW": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "ko-KR": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "no": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "it": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "pt": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "es": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "ar": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "fr": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "tr": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "de": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "uk": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs.", + "ca": "Unable to connect your ChatGPT subscription. Try again or check the agent-server logs." + }, + "SETTINGS$SUBSCRIPTION_CONNECTED_TOAST": { + "en": "ChatGPT subscription connected", + "ja": "ChatGPT subscription connected", + "zh-CN": "ChatGPT subscription connected", + "zh-TW": "ChatGPT subscription connected", + "ko-KR": "ChatGPT subscription connected", + "no": "ChatGPT subscription connected", + "it": "ChatGPT subscription connected", + "pt": "ChatGPT subscription connected", + "es": "ChatGPT subscription connected", + "ar": "ChatGPT subscription connected", + "fr": "ChatGPT subscription connected", + "tr": "ChatGPT subscription connected", + "de": "ChatGPT subscription connected", + "uk": "ChatGPT subscription connected", + "ca": "ChatGPT subscription connected" + }, + "SETTINGS$SUBSCRIPTION_DISCONNECTED_TOAST": { + "en": "ChatGPT subscription disconnected", + "ja": "ChatGPT subscription disconnected", + "zh-CN": "ChatGPT subscription disconnected", + "zh-TW": "ChatGPT subscription disconnected", + "ko-KR": "ChatGPT subscription disconnected", + "no": "ChatGPT subscription disconnected", + "it": "ChatGPT subscription disconnected", + "pt": "ChatGPT subscription disconnected", + "es": "ChatGPT subscription disconnected", + "ar": "ChatGPT subscription disconnected", + "fr": "ChatGPT subscription disconnected", + "tr": "ChatGPT subscription disconnected", + "de": "ChatGPT subscription disconnected", + "uk": "ChatGPT subscription disconnected", + "ca": "ChatGPT subscription disconnected" + }, + "SETTINGS$SUBSCRIPTION_PENDING_TOAST": { + "en": "Sign-in is not complete yet. Finish the browser flow and try again.", + "ja": "Sign-in is not complete yet. Finish the browser flow and try again.", + "zh-CN": "Sign-in is not complete yet. Finish the browser flow and try again.", + "zh-TW": "Sign-in is not complete yet. Finish the browser flow and try again.", + "ko-KR": "Sign-in is not complete yet. Finish the browser flow and try again.", + "no": "Sign-in is not complete yet. Finish the browser flow and try again.", + "it": "Sign-in is not complete yet. Finish the browser flow and try again.", + "pt": "Sign-in is not complete yet. Finish the browser flow and try again.", + "es": "Sign-in is not complete yet. Finish the browser flow and try again.", + "ar": "Sign-in is not complete yet. Finish the browser flow and try again.", + "fr": "Sign-in is not complete yet. Finish the browser flow and try again.", + "tr": "Sign-in is not complete yet. Finish the browser flow and try again.", + "de": "Sign-in is not complete yet. Finish the browser flow and try again.", + "uk": "Sign-in is not complete yet. Finish the browser flow and try again.", + "ca": "Sign-in is not complete yet. Finish the browser flow and try again." + }, "STATUS$SAVING": { "en": "Saving...", "ja": "保存中...", diff --git a/src/mocks/settings-handlers.ts b/src/mocks/settings-handlers.ts index ab91a3d3e..ec2558abc 100644 --- a/src/mocks/settings-handlers.ts +++ b/src/mocks/settings-handlers.ts @@ -3,6 +3,12 @@ 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_STATUS_PATH, +} from "#/constants/llm-subscription"; /** Simple recursive merge — objects merge, scalars overwrite. */ function deepMerge( @@ -346,6 +352,8 @@ const MOCK_LLM_PROFILES: { activeProfile: null, }; +let mockOpenAISubscriptionConnected = false; + const getProfileNameParam = (value: unknown): string => decodeURIComponent( Array.isArray(value) ? String(value[0] ?? "") : String(value ?? ""), @@ -428,6 +436,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 @@ -526,6 +535,42 @@ export const SETTINGS_HANDLERS = [ HttpResponse.json({ providers: MOCK_MODEL_PROVIDERS }), ), + 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/routes/llm-settings.tsx b/src/routes/llm-settings.tsx index e0df4dd2a..1101cfcea 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,8 +24,23 @@ import { type SettingsView, } from "#/utils/sdk-settings-schema"; import { DEFAULT_SETTINGS } from "#/services/settings"; - -const LLM_EXCLUDED_KEYS = new Set(["llm.model", "llm.api_key", "llm.base_url"]); +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"; + +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; @@ -120,6 +137,7 @@ export function LlmSettingsScreen({ const { data: schema } = useAgentSettingsSchema( settings?.agent_settings_schema, ); + const { data: subscriptionModels } = useOpenAISubscriptionModels(); const defaultModel = String( (DEFAULT_SETTINGS.agent_settings?.llm as Record)?.model ?? @@ -156,6 +174,11 @@ 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 subscriptionModelValue = subscriptionModels?.includes(modelValue) + ? modelValue + : (subscriptionModels?.[0] ?? ""); const apiKeyValue = typeof values["llm.api_key"] === "string" ? values["llm.api_key"] : ""; @@ -192,6 +215,74 @@ 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) { + onChange(LLM_SUBSCRIPTION_VENDOR_KEY, OPENAI_SUBSCRIPTION_VENDOR); + if (!subscriptionModels?.includes(modelValue)) { + onChange("llm.model", subscriptionModels?.[0] ?? ""); + } + } else if (subscriptionModels?.includes(modelValue)) { + onChange("llm.model", defaultModel); + } + }; + + const renderAuthTypeInput = () => ( + + ); + + const renderSubscriptionSettings = () => ( +
+ ({ + key: model, + label: model, + }))} + selectedKey={subscriptionModelValue} + isClearable={false} + required + isDisabled={isDisabled} + onSelectionChange={(selectedKey) => { + onChange( + "llm.model", + String(selectedKey ?? subscriptionModels?.[0] ?? ""), + ); + }} + /> + +
+ ); + return (
{view === "basic" ? ( @@ -199,26 +290,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", + )} + )}
) : ( @@ -226,35 +325,43 @@ 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", + )} + )}
)} @@ -269,19 +376,37 @@ export function LlmSettingsScreen({ basePayload: Record, context: { values: Record; + dirty: Record; view: SettingsView; }, ) => { - // basePayload is a nested dict (e.g. {llm: {model: "gpt-4"}}) const agentSettings = structuredClone(basePayload); - 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"] ?? ""); + llm.model = subscriptionModels?.includes(model) + ? model + : (subscriptionModels?.[0] ?? ""); + 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], 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 bc8144584..9f2553026 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. */