Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions __tests__/api/agent-server-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> } };

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<string, unknown> } };

expect(payload.agent_settings.llm.model).toBe("openai/gpt-4o");
});

it("forwards the switch-LLM setting to SDK agent settings", () => {
const payload = buildStartConversationRequest({
settings: {
Expand Down
115 changes: 115 additions & 0 deletions __tests__/api/llm-subscription-service.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof import("react")>("react");
return {
LlmSettingsScreen: ({
initialValueOverrides,
onSaveControlChange,
}: {
initialValueOverrides?: Record<string, string | boolean>;
onSaveControlChange?: (control: {
save: () => void;
isSaving: boolean;
isDirty: boolean;
values: Record<string, string | boolean>;
}) => 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 <div data-testid="mock-llm-settings-screen" />;
},
};
});

vi.mock("#/hooks/query/use-llm-profiles");
vi.mock("#/hooks/mutation/use-activate-llm-profile");
vi.mock("#/hooks/mutation/use-save-llm-profile");
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -189,55 +226,28 @@ describe("LlmSettingsLocalView", () => {
renderWithProviders(<LlmSettingsLocalView />);

// 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(<LlmSettingsLocalView />);

// 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", () => {
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions __tests__/routes/llm-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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", () => {
Expand Down
Loading