Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
06f18ec
Add ChatGPT subscription LLM support
openhands-agent May 23, 2026
83e343c
Use typed LLM subscription client
openhands-agent May 23, 2026
b89360b
Fix subscription model list and device code UX
openhands-agent May 28, 2026
25a3db6
Add gpt-5.5 (and gpt-5.4/5.4-pro) to subscription model fallback list
openhands-agent May 28, 2026
47bbca5
Simplify subscription model source to chatgpt/ provider only
openhands-agent May 28, 2026
1e94188
Merge main and address subscription review feedback
openhands-agent May 28, 2026
5780fb7
test: link tracking issue #917 to typescript-client git-pin exemption
openhands-agent May 28, 2026
3da19b0
Address subscription settings review feedback
openhands-agent May 28, 2026
65e1712
Add workspace mock handlers for snapshots
openhands-agent May 28, 2026
e5e5bff
Mock MCP test endpoint in snapshot tests
openhands-agent May 28, 2026
13ede8a
Stabilize home snapshot waits
openhands-agent May 28, 2026
97a8d32
Fix subscription settings dark theme contrast
openhands-agent May 28, 2026
6a5799a
Merge remote-tracking branch 'origin/main' into add-llm-subscription-…
openhands-agent May 29, 2026
5ca3e55
Merge remote-tracking branch 'origin/main' into add-llm-subscription-…
openhands-agent May 30, 2026
d7ce01d
Merge main into subscription LLM PR
neubig Jun 12, 2026
581fc5c
Format subscription service
neubig Jun 12, 2026
254aaba
Merge branch 'main' into add-llm-subscription-support
neubig Jun 12, 2026
1cdaf9f
Fix subscription settings CI coverage
neubig Jun 12, 2026
9a20a11
Merge branch 'main' into add-llm-subscription-support
neubig Jun 12, 2026
909082f
Guard subscription model loading
neubig Jun 12, 2026
358faba
Auto-poll subscription device login
neubig Jun 12, 2026
f96869d
Format subscription auth auto-poll changes
neubig Jun 12, 2026
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 @@ -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,
Expand Down Expand Up @@ -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<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
95 changes: 95 additions & 0 deletions __tests__/api/llm-subscription-service.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
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 {
Expand All @@ -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<typeof import("react")>("react");
return {
LlmSettingsScreen: ({
initialValueOverrides,
onSaveControlChange,
}: {
initialValueOverrides?: Record<string, string | boolean>;
onSaveControlChange?: (control: {
save: () => void;
isSaving: boolean;
isDirty: boolean;
view: "basic" | "all";
values: Record<string, string | boolean>;
getDirtyPayload: () => { llm: Record<string, unknown> };
}) => 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 (
<div data-testid="mock-llm-settings-screen">
<button
data-testid="sdk-section-basic-toggle"
type="button"
onClick={() => setView("basic")}
>
Basic
</button>
<button
data-testid="sdk-section-all-toggle"
type="button"
onClick={() => setView("all")}
>
All
</button>
{view === "all" ? (
<input
data-testid="sdk-settings-llm.temperature"
value={temperature}
onChange={(event) => setTemperature(event.currentTarget.value)}
/>
) : null}
</div>
);
},
};
});

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 @@ -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(<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
5 changes: 4 additions & 1 deletion __tests__/hooks/query/use-agent-settings-schema.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
Expand Down
17 changes: 12 additions & 5 deletions __tests__/package-library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
all-hands-bot marked this conversation as resolved.
// 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,
Expand Down
Loading
Loading