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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ Web 模型设置提供“账户登录”“第三方模型提供商”和“自

Web 可以添加或编辑自定义模型、保存服务商 API Key。模型写入 Pi 原生 `models.json`,凭据通过 Pi 登录接口写入原生存储;已有凭据、授权链接和授权码不会进入模型对话,已有密钥不会回显。写入要求活动会话空闲;外部配置发生变化时需刷新后重试。“已配置”只表示存在凭据,不代表接口已验证可用。Web 显示偏好直接保存;技能、插件、子代理及其他 OpenPI 自有配置表单提交到 `/openpi-setup`,进度与确认留在会话中;setup 提交回执不代表配置已保存。

模型高级参数可多选支持的思考等级,包括 `off / minimal / low / medium / high / xhigh / max`。默认“自动”沿用 Pi 的模型能力;自定义选择写入原生 `thinkingLevelMap`,保留已有提供商映射,至少保留一个等级。保存后刷新当前会话的模型与可用等级,聊天选择器和配置页的可见等级同步读取 Pi 状态;当前等级被移除时由 Pi 调整。“使用 Pi 默认等级”删除该覆盖,关闭推理支持时只提供 `off`。这与全局隐藏部分 Web 等级的显示偏好相互独立。

技能和插件页沿用同一设置框架,提供名称/来源搜索、范围筛选、添加来源、单项和当前筛选项的批量配置入口。安装、更新、移除及调用设置继续通过 `/openpi-setup` 交给当前模型审查并使用 Pi 原生能力执行,不增加另一套安装器或技能加载协议。插件目录同时读取 Pi 已保存的软件包配置与当前会话资源,展示禁用、缺失、未加载状态及已安装/配置版本;目录读取不执行扩展或补装软件包。添加表单可打开 skills.sh 查找来源,外部站点结果不等于已安装资源。

刷新只重新读取目录;“重新加载资源”经用户确认,在活动会话空闲且文件身份匹配时调用 Pi 原生 reload。缺失或版本不匹配的软件包须先通过 setup 解决。资源配置回合完成后仍以 Pi 原生目录与实际会话加载状态核对结果,不以 OpenPI 自有偏好的保存回执推断资源安装成功。
Expand Down
2 changes: 2 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ The Web model page offers Account, Third-party providers, and Custom model API w

After saving a connection, choose a model for the current Session and optionally make it the default for new Sessions. The separate New-session default row reads and writes Pi's global `settings.json` through its native SettingsManager, confirming persistence before displaying success. Changing only this default leaves existing Sessions unchanged; trusted project model overrides are displayed separately. Current-Session selection uses Pi's Session model and exact provider/model identity, so identical model names on different connections remain distinct. Provider marks use restrained brand accents. Pi keeps one credential per provider ID; reauthentication replaces it, while distinct custom connections use distinct IDs.

Each editable model's advanced parameters can select its supported thinking levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Automatic uses Pi's native defaults. A custom selection writes the native `thinkingLevelMap`, preserves existing provider mappings and requires at least one level. Saving refreshes the current Session's model and the available levels shown in the conversation and configuration pickers; Pi adjusts a current level that is no longer supported. Use Pi defaults removes the override. Disabling reasoning exposes only `off`. This changes model capabilities independently of the presentation-only Web hidden-level preference; it adds no OpenPI-owned setting.

Web shows the original setup command and its results, including failed or unapplied requests, rather than hiding the configuration episode. Appearance controls wait for the current run to settle before accepting another change. Chat-width and font-size reset buttons request the existing defaults through the same setup entry point. The model tree selects the editable Pi definition and retains unsaved drafts within the same Session; narrow screens use a labeled settings-section selector.

In the TUI, setup requests default to `[OpenPi] <command>` and the original user request (including the `my-pi-setup` alias); long or multiline requests wrap naturally. Pi's `app.tools.expand` action (`Ctrl+O` by default) shows the complete original prompt through native rendering. Expansion is host-owned: hosts without a custom-message disclosure-click API do not offer a click toggle. Legacy or invalid command/request metadata falls back to Pi's full native display. This is presentation only: Session content, model input, setup authority, and receipts are unchanged. Web keeps its existing command/request projection; RPC/JSON and HTML exports retain the full prompt rather than the TUI summary. See [Issue #654](https://github.com/openpi-dev/openpi/issues/654).
Expand Down
42 changes: 42 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4475,6 +4475,48 @@ describe("thinking level picker", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});

it("discards a slider preview when the model's supported levels change", () => {
const snapshot = idleThinkingSnapshot();
const props = thinkingProps(snapshot);
const selectThinking = vi.fn();
props.actions = { ...props.actions, selectThinking };
const view = renderWithI18n(createElement(Composer, props));
fireEvent.click(
screen.getByRole("button", { name: thinkingPickerName("medium") }),
);
const stale = screen.getByRole("slider", { name: i18n.t("thinkingLevel") });
fireEvent.change(stale, { target: { value: "4" } });
view.rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(Composer, {
...props,
snapshot: {
...snapshot,
thinking: {
revision: snapshot.thinking!.revision + 1,
supported: true,
level: "low",
available: ["low", "high", "max"],
},
},
}),
),
);
fireEvent.pointerUp(stale);
expect(selectThinking).not.toHaveBeenCalled();
const current = screen.getByRole<HTMLInputElement>("slider", {
name: i18n.t("thinkingLevel"),
});
expect(current.max).toBe("2");
expect(current.value).toBe("0");
expect(current.getAttribute("aria-valuetext")).toBe("Low");
fireEvent.change(current, { target: { value: "2" } });
fireEvent.pointerUp(current);
expect(selectThinking).toHaveBeenCalledExactlyOnceWith("max");
});

it("keeps a pending picker interactive while the send button is disabled", () => {
const snapshot = idleThinkingSnapshot();
const props = thinkingProps(snapshot);
Expand Down
122 changes: 122 additions & 0 deletions tests/web/model-configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
import { ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
import {
readModelConfigurations,
Expand Down Expand Up @@ -356,6 +357,113 @@ test("a built-in provider can override its connection while retaining its native
}
});

test("native thinking capabilities round-trip, preserve provider mappings and explicitly reset", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "openpi-thinking-config-"));
t.after(() => rm(directory, { recursive: true, force: true }));
const thinkingLevelMap = {
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "xhigh",
};
const before = await readModelConfigurations(directory);
await saveModelConfiguration(directory, before.revision, {
...model,
thinkingLevelMap,
});
const saved = await readModelConfigurations(directory);
assert.deepEqual(saved.models[0]?.thinkingLevelMap, thinkingLevelMap);
const options = {
modelsPath: join(directory, "models.json"),
authPath: join(directory, "auth.json"),
allowModelNetwork: false,
};
const native = await ModelRuntime.create(options);
assert.equal(native.getError(), undefined);
assert.deepEqual(
getSupportedThinkingLevels(native.getModel(model.provider, model.id)!),
["low", "high", "max"],
);
// An older caller that omits the field preserves the native mapping.
await saveModelConfiguration(directory, saved.revision, {
...model,
name: "Renamed",
});
const retained = await readModelConfigurations(directory);
assert.deepEqual(retained.models[0]?.thinkingLevelMap, thinkingLevelMap);
await changeProviderConfiguration(directory, retained.revision, {
action: "save",
configuration: {
provider: model.provider,
name: "Fixture",
baseUrl: model.baseUrl,
api: model.api,
models: [{ ...model, thinkingLevelMap: null }],
},
});
const reset = await readModelConfigurations(directory);
assert.equal(Object.hasOwn(reset.models[0]!, "thinkingLevelMap"), false);
const defaults = await ModelRuntime.create(options);
assert.deepEqual(
getSupportedThinkingLevels(defaults.getModel(model.provider, model.id)!),
["off", "minimal", "low", "medium", "high"],
);
});

test("invalid thinking maps fail before writing native configuration", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "openpi-thinking-invalid-"));
t.after(() => rm(directory, { recursive: true, force: true }));
const before = await readModelConfigurations(directory);
for (const thinkingLevelMap of [
[],
"high",
{ ultra: "ultra" },
{ high: 10 },
{ high: "" },
{ high: "bad\nvalue" },
{ off: null, minimal: null, low: null, medium: null, high: null },
]) {
const invalid = {
...model,
thinkingLevelMap,
} as unknown as WebModelConfiguration;
assert.equal(validModelConfiguration(invalid), false);
await assert.rejects(
saveModelConfiguration(directory, before.revision, invalid),
/Invalid model configurations/u,
);
}
assert.deepEqual(await readModelConfigurations(directory), before);
});

test("automatic thinking choices project native catalog mappings independently of overrides", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "openpi-thinking-defaults-"));
t.after(() => rm(directory, { recursive: true, force: true }));
const builtin = getBuiltinModels("openai").find(
(entry) => entry.reasoning && entry.thinkingLevelMap?.max,
);
assert.ok(builtin);
await saveModelConfiguration(
directory,
(await readModelConfigurations(directory)).revision,
{
...model,
provider: "openai",
id: builtin.id,
thinkingLevelMap: { max: null },
},
);
const projected = await readModelConfigurations(directory);
assert.deepEqual(
projected.thinkingDefaults[`openai/${builtin.id}`],
builtin.thinkingLevelMap,
);
assert.deepEqual(projected.models[0]?.thinkingLevelMap, { max: null });
});

test("batch model additions commit together and reject invalid or stale batches without partial writes", async () => {
const directory = await mkdtemp(join(tmpdir(), "openpi-model-batch-"));
try {
Expand Down Expand Up @@ -565,10 +673,24 @@ test("Web saves a custom provider key through Pi login, reloads it, and rejects
...model,
baseUrl: "http://127.0.0.1:10/v1",
name: "Updated current model",
thinkingLevelMap: {
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
},
},
);
assert.equal(session.model.baseUrl, "http://127.0.0.1:10/v1");
assert.equal(session.model.name, "Updated current model");
assert.deepEqual(getSupportedThinkingLevels(session.model), [
"low",
"high",
"max",
]);
await assert.rejects(
runtime.changeProviderConfiguration(
sessionId,
Expand Down
118 changes: 117 additions & 1 deletion tests/web/openpi-web-provider.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ test("uploaded screenshots reach the real Host and provider without preview meta

test("thinking level reaches the provider request end to end", async ({
page,
}) => {
}, testInfo) => {
const provider = await startFakeProvider();
const workspace = await mkdtemp(join(tmpdir(), "openpi-thinking-provider-"));
try {
Expand Down Expand Up @@ -445,6 +445,122 @@ test("thinking level reaches the provider request end to end", async ({
const offBody = provider.requests[1].body as Record<string, unknown>;
expect("reasoning_effort" in offBody).toBe(false);
await expect(thinkingPicker).toBeEnabled();

// Edit the model's native capabilities through the actual Settings form.
await page.goto("/?settings=models", { waitUntil: "domcontentloaded" });
await page
.getByRole("button", { name: `编辑 ${PROVIDER_ID}`, exact: true })
.click();
await page.getByText("自定义设置", { exact: true }).click();
await page
.getByRole("button", { name: "模型参数:Fake Reasoner", exact: true })
.click();
await page
.getByRole("button", { name: "支持的思考等级 1", exact: true })
.click();
await page
.getByRole("menuitemcheckbox", { name: "off", exact: true })
.click();
await page
.getByRole("menuitemcheckbox", { name: "max", exact: true })
.click();
await expect(
page.getByRole("menuitemcheckbox", { name: "max", exact: true }),
).toHaveAttribute("aria-checked", "true");
await page.setViewportSize({ width: 1280, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addStyleTag({
content:
"*, *::before, *::after { animation: none !important; transition: none !important; }",
});
await page.screenshot({
path: testInfo.outputPath("thinking-model-desktop.png"),
fullPage: true,
});
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 890, height: 792 });
await page
.getByRole("button", { name: "支持的思考等级 1", exact: true })
.scrollIntoViewIfNeeded();
await page
.getByRole("button", { name: "支持的思考等级 1", exact: true })
.click();
await page.screenshot({
path: testInfo.outputPath("thinking-model-user-890.png"),
fullPage: true,
});
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await page
.getByRole("button", { name: "支持的思考等级 1", exact: true })
.scrollIntoViewIfNeeded();
await page
.getByRole("button", { name: "支持的思考等级 1", exact: true })
.click();
await page.screenshot({
path: testInfo.outputPath("thinking-model-mobile.png"),
fullPage: true,
});
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 1280, height: 900 });
const capabilityWrite = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/providers/configuration" &&
response.request().method() === "POST",
);
await page.getByRole("button", { name: "保存", exact: true }).click();
expect((await capabilityWrite).status()).toBe(200);
await expect(
page.getByText(`已保存 ${PROVIDER_ID}。`, { exact: true }),
).toBeVisible();
const updated = await page.request.get("/api/snapshot", {
headers: authHeaders,
});
const updatedSnapshot = await updated.json();
expect(updatedSnapshot.thinking.available).toEqual(["low", "high", "max"]);
expect(updatedSnapshot.thinking.level).toBe("low");
const nativeModels = JSON.parse(
await readFile(
join(process.env.OPENPI_WEB_PROVIDER_AGENT_DIR!, "models.json"),
"utf8",
),
);
expect(
nativeModels.providers[PROVIDER_ID].models[0].thinkingLevelMap,
).toEqual({
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
await page.getByRole("button", { name: "关闭", exact: true }).click();
await thinkingPicker.click();
const supportedSlider = page.getByRole("slider", { name: "思考等级" });
await expect(supportedSlider).toHaveAttribute("max", "2");
await expect(
page.getByText("可选等级:Low · High · Max", { exact: true }),
).toBeVisible();
await supportedSlider.press("End");
await expect(thinkingWrap).toHaveAttribute("data-level", "max");
await page.screenshot({
path: testInfo.outputPath("thinking-picker-desktop.png"),
fullPage: true,
});
await page
.getByRole("textbox", { name: "描述任务" })
.fill("Confirm the configured maximum thinking level");
await page.getByRole("button", { name: "发送", exact: true }).click();
await expect
.poll(() => provider.requests.length, { timeout: 15_000 })
.toBe(3);
expect(
(provider.requests[2].body as Record<string, unknown>).reasoning_effort,
).toBe("max");
await expect(thinkingPicker).toBeEnabled();
} finally {
await provider.close();
await rm(workspace, { recursive: true, force: true });
Expand Down
Loading
Loading