From 7f9ac16feb6ef301db4136f039eb5d66612dbb53 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:15:09 +0800 Subject: [PATCH 1/7] feat(providers): support StepFun Step 5 Preview Make the official StepFun endpoint selectable and preserve its model capabilities while first-party catalog coverage is incomplete. Keep reseller metadata separate and retain user overrides and safety limits. Cover discovery, saved bindings, reasoning replay and tool continuation through the real adapter, with no credentials in fixtures. fixes #738 --- .../desktop/electron/main/ipc/provider-ipc.ts | 4 +- .../electron/main/models-dev-catalog.ts | 13 +- .../electron/main/stepfun-model-metadata.ts | 46 ++++++ apps/desktop/test/stepfun-model.test.mjs | 140 ++++++++++++++++++ ...4-models-dev-sole-model-metadata-source.md | 6 +- docs/adr/README.md | 1 + .../adr/stepfun-first-party-model-metadata.md | 61 ++++++++ docs/guide/index.md | 4 + docs/guide/stepfun.md | 29 ++++ .../03-runtime/11-provider-model-system.md | 8 + .../03-runtime/12-provider-config-schema.md | 7 +- .../13-model-catalog-and-selection.md | 15 ++ docs/spec/06-delivery/04-e2e-test-plan.md | 22 +++ .../03-runtime/11-provider-model-system.md | 8 + .../03-runtime/12-provider-config-schema.md | 5 + .../13-model-catalog-and-selection.md | 15 ++ .../spec/06-delivery/04-e2e-test-plan.md | 22 +++ .../src/model-capabilities.test.ts | 8 + .../agent-runtime/src/model-capabilities.ts | 2 +- packages/agent-runtime/src/thinking-level.ts | 5 +- packages/i18n/src/locales/de/index.ts | 1 + packages/i18n/src/locales/en/index.ts | 1 + packages/i18n/src/locales/es/index.ts | 1 + packages/i18n/src/locales/fr/index.ts | 1 + packages/i18n/src/locales/ko/index.ts | 1 + packages/i18n/src/locales/tr/index.ts | 1 + packages/i18n/src/locales/zh-CN/index.ts | 1 + packages/i18n/src/locales/zh-TW/index.ts | 1 + packages/shared/src/provider-presets.ts | 8 + packages/shared/src/types/models.ts | 2 +- 30 files changed, 428 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/electron/main/stepfun-model-metadata.ts create mode 100644 apps/desktop/test/stepfun-model.test.mjs create mode 100644 docs/adr/stepfun-first-party-model-metadata.md create mode 100644 docs/guide/stepfun.md diff --git a/apps/desktop/electron/main/ipc/provider-ipc.ts b/apps/desktop/electron/main/ipc/provider-ipc.ts index f2fc844dfb..ded722432d 100644 --- a/apps/desktop/electron/main/ipc/provider-ipc.ts +++ b/apps/desktop/electron/main/ipc/provider-ipc.ts @@ -305,7 +305,9 @@ export function registerProviderIpc({ // ModelInfo is catalog metadata. Keep its published reasoning fields // intact; Composer and runtime resolve the exact user binding when // they need effective per-provider capabilities. - ...(modelsDevModel ? { catalogSource: "models.dev" as const } : {}), + ...(modelsDevModel + ? { catalogSource: modelsDevModel.metadataSource ?? "models.dev" as const } + : {}), }; }; diff --git a/apps/desktop/electron/main/models-dev-catalog.ts b/apps/desktop/electron/main/models-dev-catalog.ts index 99e9116a1d..afeb5fe3c3 100644 --- a/apps/desktop/electron/main/models-dev-catalog.ts +++ b/apps/desktop/electron/main/models-dev-catalog.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { stepfunModelSupplement } from "./stepfun-model-metadata.ts"; import { modelIdsMatch } from "@pi-desktop/shared"; import type { ModelCost, @@ -54,6 +55,8 @@ export type ModelsDevProvider = { }; export type ModelsDevModel = { + /** Absent for models.dev; explicit for a reviewed first-party supplement. */ + metadataSource?: "provider"; providerKey: string; providerName: string; providerApi?: string; @@ -717,7 +720,7 @@ export function modelInfoFromModelsDev( capabilities: capabilityList(model), supportedThinkingLevels: [...model.thinkingLevels], source: "discovered", - catalogSource: "models.dev", + catalogSource: model.metadataSource ?? "models.dev", }; } @@ -742,7 +745,7 @@ export function modelConfigFromModelsDev( ...(model.cost?.tiers ? { tiers: model.cost.tiers } : {}), }; const config: ModelConfig = { - source: "models.dev", + source: model.metadataSource ?? "models.dev", name: model.displayName, baseUrl: baseUrl ?? model.providerApi ?? "", reasoning, @@ -1002,7 +1005,11 @@ export class ModelsDevCatalog { candidates.sort((left, right) => right.score - left.score || left.model.modelId.length - right.model.modelId.length, ); - const result = candidates[0]?.model; + const supplement = stepfunModelSupplement(input); + const firstParty = supplement && preferred.find(({ model, provider }) => + apiMatches(input.baseUrl, provider.api) && modelIdsMatch(model.modelId, requested), + )?.model; + const result = firstParty ?? supplement ?? candidates[0]?.model; // Cache the result (a miss included) so a repeated miss is also O(1) and // cannot grow the candidate index with query-dependent keys. this.lookupMemo.set(memoKey, result); diff --git a/apps/desktop/electron/main/stepfun-model-metadata.ts b/apps/desktop/electron/main/stepfun-model-metadata.ts new file mode 100644 index 0000000000..e68b16ad53 --- /dev/null +++ b/apps/desktop/electron/main/stepfun-model-metadata.ts @@ -0,0 +1,46 @@ +import type { ModelsDevModel } from "./models-dev-catalog"; + +/** + * First-party metadata while models.dev has no StepFun Step 5 record. + * https://platform.stepfun.com/docs/zh/guides/models/step-5-preview + * Verified 2026-09-21: /v1/models publishes max_input_tokens=1024000, + * enable_vision_input=true and reasoning_effort_support_list=low/medium/high. + * The documented 64k output ceiling is represented conservatively as 64000. + * Never infer gateway capabilities from a model name or vendor label alone. + */ +export function stepfunModelSupplement(input: { + baseUrl?: string; + modelId: string; +}): ModelsDevModel | undefined { + if (input.modelId !== "step-5-preview" || !input.baseUrl) return undefined; + let endpoint: URL; + try { + endpoint = new URL(input.baseUrl); + } catch { + return undefined; + } + if ( + endpoint.origin !== "https://api.stepfun.com" || + endpoint.pathname.replace(/\/+$/, "") !== "/v1" || + endpoint.username || endpoint.password || endpoint.search || endpoint.hash + ) return undefined; + return { + providerKey: "stepfun", + providerName: "StepFun", + providerApi: "https://api.stepfun.com/v1", + metadataSource: "provider", + modelId: "step-5-preview", + displayName: "Step 5 Preview", + reasoning: true, + reasoningPublished: true, + thinkingLevels: ["low", "medium", "high"], + reasoningOptions: [{ type: "effort", values: ["low", "medium", "high"] }], + modalities: { input: ["text", "image", "video"], output: ["text"] }, + modalitiesPublished: true, + inputPublished: true, + outputPublished: true, + toolCall: true, + structuredOutput: true, + limit: { context: 1_024_000, input: 1_024_000, output: 64_000 }, + }; +} diff --git a/apps/desktop/test/stepfun-model.test.mjs b/apps/desktop/test/stepfun-model.test.mjs new file mode 100644 index 0000000000..81cb1e57d3 --- /dev/null +++ b/apps/desktop/test/stepfun-model.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { bindingFromModelInfo, matchNamedPreset } from "@pi-desktop/shared"; +import { modelConfigWithBinding } from "@pi-desktop/agent-runtime"; +import { + ModelsDevCatalog, modelConfigFromModelsDev, modelInfoFromModelsDev, +} from "../electron/main/models-dev-catalog.ts"; + +const baseUrl = "https://api.stepfun.com/v1"; +const catalogPath = new URL("../resources/models.dev/api.json", import.meta.url).pathname; +const target = { baseUrl, vendorKey: "stepfun", modelId: "step-5-preview" }; + +test("StepFun selection retains official capabilities through a saved model binding", async () => { + const preset = matchNamedPreset({ baseUrl }); + assert.equal(preset?.id, "stepfun"); + assert.equal(preset.apiStyle, "chat_completions"); + const catalog = new ModelsDevCatalog({ catalogPath }); + await catalog.ensureLoaded(); + const model = catalog.findModel(target); + assert.ok(model); + assert.equal(model.metadataSource, "provider"); + const info = modelInfoFromModelsDev(model, "saved-stepfun"); + assert.equal(info.catalogSource, "provider"); + assert.deepEqual(info.supportedThinkingLevels, ["low", "medium", "high"]); + assert.ok(info.capabilities.includes("tools")); + assert.ok(info.capabilities.includes("vision")); + const savedBinding = JSON.parse(JSON.stringify(bindingFromModelInfo(info))); + const config = modelConfigWithBinding(modelConfigFromModelsDev(model, baseUrl), savedBinding); + assert.equal(config.contextWindow, 1_024_000); + assert.equal(config.maxTokens, 64_000); + assert.deepEqual(config.input, ["text", "image"]); + assert.equal(config.reasoning, true); + const overridden = modelConfigWithBinding(config, { + ...savedBinding, contextWindow: 256_000, maxTokens: 4096, + supportsImages: false, thinkingLevels: ["low"], + }); + assert.equal(overridden.contextWindow, 256_000); + assert.equal(overridden.maxTokens, 4096); + assert.deepEqual(overridden.input, ["text"]); + assert.deepEqual(overridden.supportedThinkingLevels, ["low"]); +}); + +test("the StepFun supplement never changes gateways, other models, or lookalike hosts", async () => { + const catalog = new ModelsDevCatalog({ catalogPath }); + await catalog.ensureLoaded(); + for (const baseUrl of ["https://api.example.com/v1", "https://api.stepfun.com.evil.test/v1", "http://api.stepfun.com/v1", "https://api.stepfun.com/other/v1"]) { + assert.notEqual(catalog.findModel({ ...target, baseUrl })?.metadataSource, "provider"); + } + assert.notEqual(catalog.findModel({ ...target, modelId: "step-3.7-flash" })?.metadataSource, "provider"); +}); + +test("a published first-party catalog record supersedes the StepFun supplement after refresh", async () => { + const catalog = new ModelsDevCatalog({ catalogPath, fetchImpl: async () => new Response(JSON.stringify({ + stepfun: { id: "stepfun", name: "StepFun", api: baseUrl, models: { + "step-5-preview": { id: "step-5-preview", name: "Updated Step", reasoning: true, + tool_call: true, modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1_048_576, output: 32_768 } }, + } }, + }), { status: 200 }) }); + await catalog.ensureLoaded(); + assert.equal(catalog.findModel(target)?.limit.context, 1_024_000); + assert.equal(await catalog.refresh(), true); + const updated = catalog.findModel(target); + assert.notEqual(updated.metadataSource, "provider"); + assert.equal(updated.limit.context, 1_048_576); + assert.equal(modelConfigFromModelsDev(updated).source, "models.dev"); +}); + +test("discover, select, restore and stream a StepFun tool round trip through the real adapter", async (t) => { + const { discoverProviderModels } = await import("../electron/main/model-discovery.ts"); + const { buildProviderModel, createProviderModels } = await import("../../../packages/agent-runtime/dist/provider-binding.js"); + const discovery = t.mock.method(globalThis, "fetch", async (url, init) => { + assert.equal(url, `${baseUrl}/models`); + assert.equal(init.headers.Authorization, "Bearer test-credential"); + return Response.json({ data: [{ id: "step-5-preview", object: "model" }] }); + }); + const preset = matchNamedPreset({ baseUrl }); + const listed = await discoverProviderModels({ ...preset, apiKey: "test-credential" }); + discovery.mock.restore(); + const catalog = new ModelsDevCatalog({ catalogPath }); + await catalog.ensureLoaded(); + const metadata = catalog.findModel({ ...target, modelId: listed[0].modelId }); + const savedBinding = JSON.parse(JSON.stringify(bindingFromModelInfo(modelInfoFromModelsDev(metadata, "stepfun-row")))); + const provider = { id: "stepfun-row", name: preset.name, vendorKey: preset.vendorKey, baseUrl, + apiStyle: preset.apiStyle, modelId: savedBinding.id, apiKey: "test-credential", + supportsReasoning: true, supportedThinkingLevels: savedBinding.thinkingLevels, + modelConfig: modelConfigWithBinding(modelConfigFromModelsDev(metadata, baseUrl), savedBinding) }; + const model = buildProviderModel(provider); + const models = createProviderModels(provider, model); + const sse = (delta, finishReason) => new Response( + `data: ${JSON.stringify({ id: "test-stream", object: "chat.completion.chunk", model: model.id, + choices: [{ index: 0, delta, finish_reason: null }] })}\n\n` + + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 } })}\n\n` + + "data: [DONE]\n\n", { headers: { "Content-Type": "text/event-stream" } }, + ); + for (const reasoning of ["low", "medium", "high"]) { + let turn = 0; + const context = { systemPrompt: "Use the test tool.", messages: [{ role: "user", timestamp: 1, + content: [{ type: "text", text: "Read the probe." }, { type: "image", data: "dGVzdA==", mimeType: "image/png" }] }], + tools: [{ name: "read_probe", description: "Read the probe", parameters: { type: "object", properties: {} } }] }; + const fetch = async (url, init) => { + assert.equal(String(url), `${baseUrl}/chat/completions`); + const body = JSON.parse(init.body); + assert.equal(body.model, "step-5-preview"); + assert.equal(body.reasoning_effort, reasoning); + assert.equal(body.max_completion_tokens ?? body.max_tokens, 64_000); + assert.equal(body.messages[0].role, "system"); + assert.ok(body.messages[1].content.some(part => part.type === "image_url")); + if (turn++ === 0) return sse({ role: "assistant", reasoning_content: "Read the probe first.", + tool_calls: [{ index: 0, id: "probe-call", type: "function", function: { name: "read_probe", arguments: "{}" } }] }, "tool_calls"); + const assistant = body.messages.find(message => message.role === "assistant"); + assert.equal(assistant.reasoning_content, "Read the probe first."); + assert.equal(body.messages.at(-1).role, "tool"); + assert.equal(body.messages.at(-1).tool_call_id, "probe-call"); + assert.equal(body.messages.at(-1).content, "42"); + return sse({ role: "assistant", content: "42" }, "stop"); + }; + const first = await models.streamSimple(model, context, { reasoning, fetch }).result(); + assert.equal(first.stopReason, "toolUse"); + const call = first.content.find(block => block.type === "toolCall"); + assert.equal(call.name, "read_probe"); + // Reloaded transcript data must retain the reasoning field and tool identity. + context.messages.push(JSON.parse(JSON.stringify(first)), { + role: "toolResult", toolCallId: call.id, toolName: call.name, + content: [{ type: "text", text: "42" }], isError: false, timestamp: 2, + }); + const second = await models.streamSimple(model, context, { reasoning, fetch }).result(); + assert.equal(second.stopReason, "stop"); + assert.equal(second.content.find(block => block.type === "text").text, "42"); + assert.equal(second.usage.totalTokens, 20); + } +}); + +test("an unavailable catalog keeps an explicitly selected StepFun model usable without inventing availability", async () => { + const catalog = new ModelsDevCatalog({ catalogPath: "missing-stepfun-test-catalog.json" }); + assert.equal(await catalog.ensureLoaded(), false); + assert.equal(catalog.findModel(target)?.limit.context, 1_024_000); + assert.deepEqual(catalog.modelsForProvider({ ...target, providerId: "stepfun-row" }), []); +}); diff --git a/docs/adr/0134-models-dev-sole-model-metadata-source.md b/docs/adr/0134-models-dev-sole-model-metadata-source.md index b4cf22fccd..92703fc698 100644 --- a/docs/adr/0134-models-dev-sole-model-metadata-source.md +++ b/docs/adr/0134-models-dev-sole-model-metadata-source.md @@ -1,6 +1,6 @@ # ADR 0134: Use models.dev as the sole model metadata source with a local snapshot -- Status: Accepted +- Status: Accepted; amended by [StepFun first-party metadata](stepfun-first-party-model-metadata.md) - Date: 2026-08-29 - Deciders: PI-Desktop core - Amends: ADR 0027, ADR 0133, D136, D266 @@ -22,7 +22,9 @@ cost tiers. Those fields need one stable owner and a local offline snapshot. ## Decision -`https://models.dev/api.json` is the only model metadata/configuration source. +`https://models.dev/api.json` is the general model metadata/configuration source. +The StepFun amendment defines a source-labelled, exact-endpoint exception for +`step-5-preview` until its first-party record is available. 1. Electron main reads the bundled public document from `resources/models.dev/api.json` in development and diff --git a/docs/adr/README.md b/docs/adr/README.md index 917bd6cb02..3df93ee8b2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -331,3 +331,4 @@ Each ADR includes: | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | | provider-display-order | [Provider display order](provider-display-order.md) | Accepted | | provider-system-certificates | [Desktop sidecar uses OS-trusted certificates](provider-system-certificates.md) | Accepted | +| stepfun-first-party-model-metadata | [First-party StepFun metadata for a newly released model](stepfun-first-party-model-metadata.md) | Accepted (amends ADR 0134) | diff --git a/docs/adr/stepfun-first-party-model-metadata.md b/docs/adr/stepfun-first-party-model-metadata.md new file mode 100644 index 0000000000..5297e120c9 --- /dev/null +++ b/docs/adr/stepfun-first-party-model-metadata.md @@ -0,0 +1,61 @@ +# ADR: First-party StepFun metadata for a newly released model + +- Status: Accepted +- Date: 2026-09-21 +- Amends: ADR 0134 +- Related: issue #738 + +## Context + +StepFun serves `step-5-preview` through its authenticated model-list API, but +models.dev has no record for that model under the first-party StepFun provider. +Its aggregator records disagree on reasoning, tool support and output limits. +Falling across providers can therefore misconfigure a real, runnable model. +The StepFun API publishes a 1,024,000-token input window and low/medium/high +reasoning; its official model guide documents vision, tools and 64k output. + +## Decision + +Keep models.dev as the general catalog. Introduce one reviewed, source-labelled +supplement for exactly `step-5-preview` at `https://api.stepfun.com/v1`. +The supplement lives in a separate pure module in Electron main. A first-party +models.dev record for the same endpoint/model takes precedence as soon as it +exists; otherwise the supplement precedes cross-provider matches. It is also +available when the bundled catalog cannot load. No network fetch is added to +runtime startup or model lookup. + +Metadata carries `provider` provenance, never falsely `models.dev`. The existing +optional model-info catalog-source field and internal runtime source union +accept this additive value. Existing consumers and stored bindings need no +migration. Lookup still goes through the same settings/session/subagent path; +explicit binding overrides remain authoritative. + +Only exact HTTPS origin and version path matches qualify. Custom gateways, +lookalike hosts, subscription endpoints and other model IDs retain their +existing behavior. Live discovery remains the authority on availability: the +supplement does not insert a model into an endpoint's response or claim that +an API key has access. Video capability metadata does not add video attachment +transport to the desktop app. + +## Alternatives + +- Wait for models.dev: leaves a publicly available model misconfigured. +- Copy a reseller record: repeats inaccurate limits and capabilities. +- Modify the bundled models.dev document: falsely attributes vendor data to + models.dev and loses the change on catalog refresh. +- Introduce a broad parallel model catalog: unnecessary scope and maintenance. + +## Consequences + +The exception is narrow and removable once first-party catalog coverage is +stable. Its source must be rechecked when StepFun changes the model. The 64k +output ceiling is conservatively represented as 64,000 tokens; no unverified +pricing is supplied. Existing source values, protocols, persistence and secret +ownership remain unchanged. + +## References + +- [Step 5 Preview guide](https://platform.stepfun.com/docs/zh/guides/models/step-5-preview) +- `GET https://api.stepfun.com/v1/models` (verified 2026-09-21; no credentials + or account data are retained in repository fixtures) +- `apps/desktop/test/stepfun-model.test.mjs` diff --git a/docs/guide/index.md b/docs/guide/index.md index 4a077272bd..8bcd600bce 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -59,3 +59,7 @@ number; use the sidebar when you are exploring a domain. See the [AI development workflow](/spec/06-delivery/03-ai-development-workflow) and [change checklist](/spec/06-delivery/05-change-checklist) for the complete repository rules. + +## StepFun + +See [StepFun Step 5 Preview setup](stepfun.md). diff --git a/docs/guide/stepfun.md b/docs/guide/stepfun.md new file mode 100644 index 0000000000..8c78ce7d1c --- /dev/null +++ b/docs/guide/stepfun.md @@ -0,0 +1,29 @@ +# StepFun Step 5 Preview + +## Unreleased change + +Add StepFun to the AI-service picker and support Step 5 Preview with its +first-party context, vision, tool and reasoning capabilities. + +## Setup + +1. Open **Settings → Model configuration → Add AI service**. +2. Select **StepFun** and enter your StepFun API key. The preset uses + `https://api.stepfun.com/v1` and Chat Completions. +3. Fetch the model list and select `step-5-preview`, then save. +4. Select the saved model in a conversation. Choose low, medium or high + thinking intensity, or retain the default medium. + +If the service cannot list models, add `step-5-preview` as a custom model ID +on that same StepFun service. API entitlement is still required. + +The official endpoint reports a 1,024,000-token input limit, used here as a +conservative context window. The output cap uses a conservative 64,000 tokens from the documented 64k limit. +Text, images and tool calls use the existing conversation pipeline. The model's +video capability does not enable video attachments in PI-Desktop. + +Advanced per-model overrides remain available. Existing custom gateways keep +their own configuration; the official-endpoint supplement does not apply to +Step Plan or reseller URLs. This change does not install or save an API key. + +See the [official model guide](https://platform.stepfun.com/docs/zh/guides/models/step-5-preview). diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 4754f11808..f5ac40ba98 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -736,3 +736,11 @@ fix. - Automatic paid-plan discovery for every vendor portal - Proprietary non-HTTP SDKs without pi-ai support - Cloud-synced provider profiles + +### First-party StepFun metadata exception + +ADR `stepfun-first-party-model-metadata` narrowly amends the sole-source rule +above: official StepFun Step 5 Preview uses a source-labelled, reviewed +supplement until models.dev publishes its first-party record. The exception +is confined to the exact official HTTPS endpoint/model pair and preserves +user overrides. Other models retain the existing catalog behavior. diff --git a/docs/spec/03-runtime/12-provider-config-schema.md b/docs/spec/03-runtime/12-provider-config-schema.md index 8646115284..a08c1c8d55 100644 --- a/docs/spec/03-runtime/12-provider-config-schema.md +++ b/docs/spec/03-runtime/12-provider-config-schema.md @@ -383,7 +383,7 @@ type ModelCatalogCacheRecord = { contextWindow?: number source: "bundled" | "discovered" | "user" /** Renderer annotation for a row resolved from the bundled models.dev snapshot. */ - catalogSource?: "models.dev" + catalogSource?: "models.dev" | "provider" updatedAt: string raw?: unknown } @@ -569,3 +569,8 @@ secret:provider::oauth The two refs are independent, so one row may hold a key, a vendor account, or both; see [14-secrets-storage](14-secrets-storage.md) §10. Future multi-secret providers may add further suffixes (`:client_secret`, etc.). + +The optional `catalogSource: "provider"` identifies the reviewed first-party +StepFun Step 5 Preview supplement (ADR `stepfun-first-party-model-metadata`). +It is additive metadata, not a new persisted model availability source; +`source: bundled | discovered | user` and credential ownership are unchanged. diff --git a/docs/spec/03-runtime/13-model-catalog-and-selection.md b/docs/spec/03-runtime/13-model-catalog-and-selection.md index 540176f357..172c1486eb 100644 --- a/docs/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md @@ -538,3 +538,18 @@ same model to the check mark, the toggle and the duplicate guard. - [ ] compact limit text never reads above the published value, keeps the neighbouring 1M-line windows apart (`1M` / `1.05M` / `1.1M`), and never renders a `K` mantissa at or above 1000 + +### StepFun Step 5 Preview + +The StepFun preset selects `https://api.stepfun.com/v1` and Chat Completions. +Discovery still determines which models the key can use. For exactly +`step-5-preview` at that endpoint, a reviewed first-party metadata supplement +provides a 1,024,000-token context/input window, a conservative 64,000-token +output cap, image and tool support, and low/medium/high reasoning when the +first-party models.dev record is absent. Third-party catalog records do not +override that supplement. A first-party record supersedes it after refresh. + +The metadata source is `provider`, distinct from `models.dev`; user binding +overrides keep their existing precedence. Other endpoints and model IDs are +unchanged. The desktop does not add video attachment transport. See ADR +`stepfun-first-party-model-metadata` and the StepFun setup guide. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 97391becec..a718d92fd8 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7858,6 +7858,7 @@ identify the platform validation still needed. | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — Sidebar row states | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings | +| B / C / Quality — StepFun Step 5 Preview | E2E-PROVIDER-stepfun-preview-tool-round-trip | | B / F / Security — Provider copy | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists | | A — App startup | E2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204 | @@ -14102,3 +14103,24 @@ the latest destination. These assertions measure work counts, not device FPS. - **Milestone**: Maintenance. - **Status**: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required. + +### E2E-PROVIDER-stepfun-preview-tool-round-trip + +- **Preconditions:** Isolated task candidate; no production profile. Mock the + external HTTP boundary for automated checks; live API use requires explicit + authorization and a temporary credential. +- **Steps:** Select StepFun, discover `step-5-preview`, select/save its binding, + restore the binding, send text with an image, execute a returned tool call, + and continue with the tool result. Repeat low, medium and high reasoning. + Refresh to a catalog with a first-party record; retain user overrides. +- **Expected:** Exact official endpoint and model ID; 1,024,000 context and + 64,000 output before first-party catalog coverage; image, reasoning and tools + remain enabled after restoration. Reasoning content and tool IDs survive + replay. Final text and usage are delivered. Gateways and other models remain + unchanged; new first-party catalog metadata supersedes the supplement. +- **Specs:** `03-runtime/13-model-catalog-and-selection.md`; ADR + `stepfun-first-party-model-metadata`. +- **Acceptance:** B (models), C (conversation), Quality. +- **Status:** Automated service/adapter user path in + `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation + is opt-in. No desktop visual verification is implied. diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index 4278942ee0..b7b4a9c063 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -646,3 +646,11 @@ OpenAI Responses 适配器必须把 `response.completed`(以及 - 自动发现每个供应商门户的付费计划 - 不支持 pi-ai 的专有非 HTTP SDK - 云同步的提供商配置文件 + +### First-party StepFun metadata exception + +ADR `stepfun-first-party-model-metadata` narrowly amends the sole-source rule +above: official StepFun Step 5 Preview uses a source-labelled, reviewed +supplement until models.dev publishes its first-party record. The exception +is confined to the exact official HTTPS endpoint/model pair and preserves +user overrides. Other models retain the existing catalog behavior. diff --git a/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md b/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md index 92dcada186..10a1a54673 100644 --- a/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md +++ b/docs/zh-CN/spec/03-runtime/12-provider-config-schema.md @@ -423,3 +423,8 @@ secret:provider::oauth 两个引用相互独立,因此一行可以只有密钥、只有厂商账户,或两者兼有;参见 [14-secrets-storage](14-secrets-storage.md) §10。未来的多重秘密提供商可能会 继续添加后缀(`:client_secret` 等)。 + +The optional `catalogSource: "provider"` identifies the reviewed first-party +StepFun Step 5 Preview supplement (ADR `stepfun-first-party-model-metadata`). +It is additive metadata, not a new persisted model availability source; +`source: bundled | discovered | user` and credential ownership are unchanged. diff --git a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md index 28d0cf990d..3f58128e84 100644 --- a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md @@ -329,3 +329,18 @@ Electron 使用本地 `models.dev` 记录装饰缓存和新发现的模型行。 - [ ] 来源标记能在提供商保存/读取往返后保留,未标记记录仍可正常使用 - [ ] 紧凑上限文本不会高于已发布值,1M 附近的相邻窗口保持可区分 (`1M` / `1.05M` / `1.1M`),且永远不会渲染出大于等于 1000 的 `K` 尾数 + +### StepFun Step 5 Preview + +The StepFun preset selects `https://api.stepfun.com/v1` and Chat Completions. +Discovery still determines which models the key can use. For exactly +`step-5-preview` at that endpoint, a reviewed first-party metadata supplement +provides a 1,024,000-token context/input window, a conservative 64,000-token +output cap, image and tool support, and low/medium/high reasoning when the +first-party models.dev record is absent. Third-party catalog records do not +override that supplement. A first-party record supersedes it after refresh. + +The metadata source is `provider`, distinct from `models.dev`; user binding +overrides keep their existing precedence. Other endpoints and model IDs are +unchanged. The desktop does not add video attachment transport. See ADR +`stepfun-first-party-model-metadata` and the StepFun setup guide. diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5ef8346c60..0df63d83f5 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5170,6 +5170,7 @@ IPC 请求无法关闭。 | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — 侧边栏行状态 | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — 侧栏材质与设置返回 | E2E-LAYOUT-sidebar-settings | +| B / C / Quality — StepFun Step 5 Preview | E2E-PROVIDER-stepfun-preview-tool-round-trip | | B / F / Security — 提供商复制 | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — 已选模型顺序 | E2E-MODEL-selected-order-persists | | A — 应用程序启动 | E2E-001、E2E-002、E2E-003、E2E-004、E2E-067、E2E-076、E2E-079、E2E-092、E2E-097、E2E-143、E2E-150、E2E-168、E2E-204、E2E-217 | @@ -8333,3 +8334,24 @@ the latest destination. These assertions measure work counts, not device FPS. - **Milestone**: Maintenance. - **Status**: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required. + +### E2E-PROVIDER-stepfun-preview-tool-round-trip + +- **Preconditions:** Isolated task candidate; no production profile. Mock the + external HTTP boundary for automated checks; live API use requires explicit + authorization and a temporary credential. +- **Steps:** Select StepFun, discover `step-5-preview`, select/save its binding, + restore the binding, send text with an image, execute a returned tool call, + and continue with the tool result. Repeat low, medium and high reasoning. + Refresh to a catalog with a first-party record; retain user overrides. +- **Expected:** Exact official endpoint and model ID; 1,024,000 context and + 64,000 output before first-party catalog coverage; image, reasoning and tools + remain enabled after restoration. Reasoning content and tool IDs survive + replay. Final text and usage are delivered. Gateways and other models remain + unchanged; new first-party catalog metadata supersedes the supplement. +- **Specs:** `03-runtime/13-model-catalog-and-selection.md`; ADR + `stepfun-first-party-model-metadata`. +- **Acceptance:** B (models), C (conversation), Quality. +- **Status:** Automated service/adapter user path in + `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation + is opt-in. No desktop visual verification is implied. diff --git a/packages/agent-runtime/src/model-capabilities.test.ts b/packages/agent-runtime/src/model-capabilities.test.ts index 54cb30bf32..ca4e4cc11a 100644 --- a/packages/agent-runtime/src/model-capabilities.test.ts +++ b/packages/agent-runtime/src/model-capabilities.test.ts @@ -26,6 +26,14 @@ function knownModel(): ModelConfig { } describe("main-supplied model capabilities", () => { + it("retains a first-party published window as the safety ceiling", () => { + const configured = modelConfigWithBinding({ + ...knownModel(), source: "provider", contextWindow: 1_024_000, + }, { contextWindow: 2_000_000, maxTokens: 64_000, thinkingLevels: ["high"] }); + expect(configured.contextWindow).toBe(2_000_000); + expect(configured.catalogContextWindow).toBe(1_024_000); + }); + it("uses models.dev modalities for visual transport", () => { expect(visionFromModelConfig(knownModel())).toBe(true); expect( diff --git a/packages/agent-runtime/src/model-capabilities.ts b/packages/agent-runtime/src/model-capabilities.ts index e7a04c7a57..359a64c2d8 100644 --- a/packages/agent-runtime/src/model-capabilities.ts +++ b/packages/agent-runtime/src/model-capabilities.ts @@ -103,7 +103,7 @@ export function modelConfigWithBinding( model.contextWindow; const catalogContextWindow = model.catalogContextWindow ?? - (model.source === "models.dev" && model.contextWindow > 0 + ((model.source === "models.dev" || model.source === "provider") && model.contextWindow > 0 ? model.contextWindow : undefined); return { diff --git a/packages/agent-runtime/src/thinking-level.ts b/packages/agent-runtime/src/thinking-level.ts index 32cb0625ed..2f003c29cc 100644 --- a/packages/agent-runtime/src/thinking-level.ts +++ b/packages/agent-runtime/src/thinking-level.ts @@ -16,12 +16,13 @@ export type ThinkingCapabilitySet = { }; /** - * Serializable model metadata resolved in Electron main from models.dev. + * Serializable model metadata resolved in Electron main from models.dev or a + * reviewed first-party supplement. * pi-ai consumes this record through its selected transport adapter but does * not provide model names, limits, modalities, thinking levels, or prices. */ export type ModelConfig = { - source: "models.dev" | "generic"; + source: "models.dev" | "provider" | "generic"; name: string; baseUrl: string; description?: string; diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 4c4fba2d24..72805c07ee 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1058,6 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi für Codierung", + presetStepfun: "StepFun", "presetXiaomi": "Xiaomi", "apiStyleResponses": "OpenAI-Antworten", "apiStyleAnthropic": "Anthropic Messages", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index e3b69ebcc5..d400edd273 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1070,6 +1070,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", + presetStepfun: "StepFun", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 667a54c07c..ade0289b89 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1058,6 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi para codificación", + presetStepfun: "StepFun", "presetXiaomi": "Xiaomi", "apiStyleResponses": "Respuestas de OpenAI", "apiStyleAnthropic": "Mensajes antrópicos", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index d69c1f39a1..068af3e25c 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1058,6 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi pour le codage", + presetStepfun: "StepFun", "presetXiaomi": "Xiaomi", "apiStyleResponses": "Réponses OpenAI", "apiStyleAnthropic": "Messages anthropiques", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 9c9cb78c7f..92737e13d1 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1071,6 +1071,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", + presetStepfun: "StepFun", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index a3e57db14f..7ef9aa583b 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1071,6 +1071,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", + presetStepfun: "StepFun", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 6e6020c2d8..5f736735e2 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1058,6 +1058,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax(OpenAI)", presetKimiCoding: "Kimi 编程", + presetStepfun: "阶跃星辰", presetXiaomi: "小米", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 6bd8270358..ac26886c24 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1058,6 +1058,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax(OpenAI)", presetKimiCoding: "Kimi 程式設計", + presetStepfun: "階躍星辰", presetXiaomi: "小米", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/shared/src/provider-presets.ts b/packages/shared/src/provider-presets.ts index b8659be11d..8d509befb3 100644 --- a/packages/shared/src/provider-presets.ts +++ b/packages/shared/src/provider-presets.ts @@ -203,6 +203,14 @@ export const NAMED_ENDPOINT_PRESETS: readonly NamedEndpointPreset[] = [ labelKey: "settings.presetMinimaxCnOpenai", aliases: ["minimax-openai", "minimax-compatible"], }, + { + id: "stepfun", + vendorKey: "stepfun", + name: "StepFun", + baseUrl: "https://api.stepfun.com/v1", + apiStyle: "chat_completions", + labelKey: "settings.presetStepfun", + }, { id: "xiaomi", vendorKey: "xiaomi", diff --git a/packages/shared/src/types/models.ts b/packages/shared/src/types/models.ts index c91f044e13..d0cdf60fa6 100644 --- a/packages/shared/src/types/models.ts +++ b/packages/shared/src/types/models.ts @@ -216,7 +216,7 @@ export type ModelInfo = { supportedThinkingLevels?: ThinkingLevel[]; source: "bundled" | "discovered" | "user"; /** Metadata catalog that supplied this row, when it is a known model. */ - catalogSource?: "models.dev"; + catalogSource?: "models.dev" | "provider"; }; /** From e0e41426d40a914e08a8e181f3cdcbf5159433d0 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:35:31 +0800 Subject: [PATCH 2/7] fix(providers): use StepFun Plan and preserve retry settings Route the service preset to the subscription endpoint so Step API keys can discover Step 5 Preview. Keep saved ordinary endpoints unchanged. Normalize disabled retry settings to false on both read paths so an unrelated settings edit can pass the existing strict write validation. --- .../electron/main/runtime/provider-catalog.ts | 4 +-- .../electron/main/stepfun-model-metadata.ts | 4 +-- apps/desktop/src/lib/api.ts | 4 +-- .../test/provider-catalog-runtime.test.mjs | 10 +++++++ apps/desktop/test/stepfun-model.test.mjs | 7 ++--- apps/desktop/test/turn-process.test.mjs | 27 ++++++++++++++++++- .../adr/stepfun-first-party-model-metadata.md | 5 ++-- docs/guide/stepfun.md | 12 ++++++--- docs/spec/03-runtime/02-agent-runtime.md | 6 +++++ .../13-model-catalog-and-selection.md | 7 +++-- docs/spec/06-delivery/04-e2e-test-plan.md | 12 ++++++++- .../zh-CN/spec/03-runtime/02-agent-runtime.md | 6 +++++ .../13-model-catalog-and-selection.md | 7 +++-- .../spec/06-delivery/04-e2e-test-plan.md | 12 ++++++++- packages/i18n/src/locales/de/index.ts | 2 +- packages/i18n/src/locales/en/index.ts | 2 +- packages/i18n/src/locales/es/index.ts | 2 +- packages/i18n/src/locales/fr/index.ts | 2 +- packages/i18n/src/locales/ko/index.ts | 2 +- packages/i18n/src/locales/tr/index.ts | 2 +- packages/i18n/src/locales/zh-CN/index.ts | 2 +- packages/i18n/src/locales/zh-TW/index.ts | 2 +- packages/shared/src/provider-presets.ts | 10 +++---- 23 files changed, 112 insertions(+), 37 deletions(-) diff --git a/apps/desktop/electron/main/runtime/provider-catalog.ts b/apps/desktop/electron/main/runtime/provider-catalog.ts index 8da390e3d7..e3d0eccd46 100644 --- a/apps/desktop/electron/main/runtime/provider-catalog.ts +++ b/apps/desktop/electron/main/runtime/provider-catalog.ts @@ -181,9 +181,7 @@ export function createProviderCatalogRuntime({ return { ...(value as T), infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown }) - .infiniteProviderRetry === true - ? true - : undefined, + .infiniteProviderRetry === true, defaultCommandShell: isCommandShellId(value.defaultCommandShell) ? value.defaultCommandShell : defaultCommandShellForPlatform(process.platform), diff --git a/apps/desktop/electron/main/stepfun-model-metadata.ts b/apps/desktop/electron/main/stepfun-model-metadata.ts index e68b16ad53..e4825be4f3 100644 --- a/apps/desktop/electron/main/stepfun-model-metadata.ts +++ b/apps/desktop/electron/main/stepfun-model-metadata.ts @@ -21,13 +21,13 @@ export function stepfunModelSupplement(input: { } if ( endpoint.origin !== "https://api.stepfun.com" || - endpoint.pathname.replace(/\/+$/, "") !== "/v1" || + !["/v1", "/step_plan/v1"].includes(endpoint.pathname.replace(/\/+$/, "")) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash ) return undefined; return { providerKey: "stepfun", providerName: "StepFun", - providerApi: "https://api.stepfun.com/v1", + providerApi: `${endpoint.origin}${endpoint.pathname.replace(/\/+$/, "")}`, metadataSource: "provider", modelId: "step-5-preview", displayName: "Step 5 Preview", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 3ef5e7f7ba..845162960c 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -344,9 +344,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { ...settings, defaultMode: normalizeMode((settings as { defaultMode?: unknown }).defaultMode), infiniteProviderRetry: - (settings as { infiniteProviderRetry?: unknown }).infiniteProviderRetry === true - ? true - : undefined, + (settings as { infiniteProviderRetry?: unknown }).infiniteProviderRetry === true, defaultCommandShell: isCommandShellId( (settings as { defaultCommandShell?: unknown }).defaultCommandShell, ) diff --git a/apps/desktop/test/provider-catalog-runtime.test.mjs b/apps/desktop/test/provider-catalog-runtime.test.mjs index f5a37f2106..dbbbf4fcb2 100644 --- a/apps/desktop/test/provider-catalog-runtime.test.mjs +++ b/apps/desktop/test/provider-catalog-runtime.test.mjs @@ -151,3 +151,13 @@ test("a bulk session refresh reuses catalog matches while preserving each sessio } assert.equal(modelReads, 0, "session list refreshes must not repeat catalog matching work"); }); + +test("loaded retry defaults survive a settings edit and save", async () => { + const { runtime } = await fixtureRuntime(); + for (const settings of [{}, { infiniteProviderRetry: false }, { infiniteProviderRetry: true }]) { + const loaded = runtime.normalizeSettings(settings); + const edited = { ...loaded, theme: "light" }; + assert.equal(edited.infiniteProviderRetry, settings.infiniteProviderRetry === true); + assert.equal(runtime.validateSettingsWrite(edited), edited); + } +}); diff --git a/apps/desktop/test/stepfun-model.test.mjs b/apps/desktop/test/stepfun-model.test.mjs index 81cb1e57d3..5341e091b8 100644 --- a/apps/desktop/test/stepfun-model.test.mjs +++ b/apps/desktop/test/stepfun-model.test.mjs @@ -6,13 +6,14 @@ import { ModelsDevCatalog, modelConfigFromModelsDev, modelInfoFromModelsDev, } from "../electron/main/models-dev-catalog.ts"; -const baseUrl = "https://api.stepfun.com/v1"; +const baseUrl = "https://api.stepfun.com/step_plan/v1"; const catalogPath = new URL("../resources/models.dev/api.json", import.meta.url).pathname; -const target = { baseUrl, vendorKey: "stepfun", modelId: "step-5-preview" }; +const target = { baseUrl, vendorKey: "stepfun-plan", modelId: "step-5-preview" }; test("StepFun selection retains official capabilities through a saved model binding", async () => { const preset = matchNamedPreset({ baseUrl }); - assert.equal(preset?.id, "stepfun"); + assert.equal(preset?.id, "stepfun-plan"); + assert.equal(matchNamedPreset({ baseUrl: "https://api.stepfun.com/v1", vendorKey: "stepfun" }), undefined); assert.equal(preset.apiStyle, "chat_completions"); const catalog = new ModelsDevCatalog({ catalogPath }); await catalog.ensureLoaded(); diff --git a/apps/desktop/test/turn-process.test.mjs b/apps/desktop/test/turn-process.test.mjs index 661d74fcc3..6e9bd25bef 100644 --- a/apps/desktop/test/turn-process.test.mjs +++ b/apps/desktop/test/turn-process.test.mjs @@ -169,7 +169,8 @@ test("user boundaries retain independent processes and delegation details stay a }); test("settings writes validate the mode without changing other preferences", async () => { - const { validateSettingsWrite } = await import("../src/lib/api.ts"); + const { api, normalizeSettings, validateSettingsWrite } = await import("../src/lib/api.ts"); + const { IPC } = await import("@pi-desktop/shared"); const settings = { defaultMode: "agent", theme: "dark", @@ -177,6 +178,30 @@ test("settings writes validate the mode without changing other preferences", asy onboardingDismissed: false, }; assert.equal(validateSettingsWrite(settings), settings); + for (const infiniteProviderRetry of [undefined, false, true]) { + const loaded = normalizeSettings({ ...settings, defaultCommandShell: "bash", infiniteProviderRetry }); + const edited = { ...loaded, theme: "light" }; + assert.equal(edited.infiniteProviderRetry, infiniteProviderRetry === true); + assert.equal(validateSettingsWrite(edited), edited); + const previousWindow = globalThis.window; + let persisted; + globalThis.window = { piDesktop: { platform: "darwin", invoke: async (channel, input) => { + if (channel === IPC.invoke.settingsGet) return { ok: true, data: { ...settings, infiniteProviderRetry } }; + assert.equal(channel, IPC.invoke.settingsSet); + persisted = input; + return { ok: true, data: {} }; + } } }; + try { + const current = await api.getSettings(); + await api.setSettings({ ...current, theme: "light" }); + assert.equal(persisted.theme, "light"); + assert.equal(persisted.infiniteProviderRetry, infiniteProviderRetry === true); + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + } + + } const infiniteSettings = { ...settings, infiniteProviderRetry: true }; assert.equal(validateSettingsWrite(infiniteSettings), infiniteSettings); assert.throws( diff --git a/docs/adr/stepfun-first-party-model-metadata.md b/docs/adr/stepfun-first-party-model-metadata.md index 5297e120c9..d21ab498fd 100644 --- a/docs/adr/stepfun-first-party-model-metadata.md +++ b/docs/adr/stepfun-first-party-model-metadata.md @@ -17,7 +17,8 @@ reasoning; its official model guide documents vision, tools and 64k output. ## Decision Keep models.dev as the general catalog. Introduce one reviewed, source-labelled -supplement for exactly `step-5-preview` at `https://api.stepfun.com/v1`. +supplement for exactly `step-5-preview` at `https://api.stepfun.com/v1` +and the official Step Plan endpoint `https://api.stepfun.com/step_plan/v1`. The supplement lives in a separate pure module in Electron main. A first-party models.dev record for the same endpoint/model takes precedence as soon as it exists; otherwise the supplement precedes cross-provider matches. It is also @@ -31,7 +32,7 @@ migration. Lookup still goes through the same settings/session/subagent path; explicit binding overrides remain authoritative. Only exact HTTPS origin and version path matches qualify. Custom gateways, -lookalike hosts, subscription endpoints and other model IDs retain their +lookalike hosts and other model IDs retain their existing behavior. Live discovery remains the authority on availability: the supplement does not insert a model into an endpoint's response or claim that an API key has access. Video capability metadata does not add video attachment diff --git a/docs/guide/stepfun.md b/docs/guide/stepfun.md index 8c78ce7d1c..cf1f1ceef6 100644 --- a/docs/guide/stepfun.md +++ b/docs/guide/stepfun.md @@ -2,14 +2,16 @@ ## Unreleased change -Add StepFun to the AI-service picker and support Step 5 Preview with its +Add StepFun Plan to the AI-service picker and support Step 5 Preview with its first-party context, vision, tool and reasoning capabilities. ## Setup 1. Open **Settings → Model configuration → Add AI service**. -2. Select **StepFun** and enter your StepFun API key. The preset uses - `https://api.stepfun.com/v1` and Chat Completions. +2. Select **StepFun Plan** (`https://api.stepfun.com/step_plan/v1`) and enter your + Step API key. The preset uses Chat Completions and subscription credits. + The ordinary API has no dedicated picker entry; existing saved custom + services keep their endpoint and model configuration. 3. Fetch the model list and select `step-5-preview`, then save. 4. Select the saved model in a conversation. Choose low, medium or high thinking intensity, or retain the default medium. @@ -24,6 +26,8 @@ video capability does not enable video attachments in PI-Desktop. Advanced per-model overrides remain available. Existing custom gateways keep their own configuration; the official-endpoint supplement does not apply to -Step Plan or reseller URLs. This change does not install or save an API key. +reseller URLs. This change does not install or save an API key. See the [official model guide](https://platform.stepfun.com/docs/zh/guides/models/step-5-preview). + +See the [official Step Plan guide](https://platform.stepfun.com/docs/zh/step-plan/overview). diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index 6cbca8b4da..4d339c3cc0 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -1468,3 +1468,9 @@ cause survives adapter message flattening, remains on the final error row, and never triggers a provider transport rebuild. Protocol errors such as `EPROTO` keep their existing retry behavior. See [certificate trust ADR](../../adr/provider-system-certificates.md). + +### Retry preference read/write compatibility + +Settings reads normalize missing or disabled `infiniteProviderRetry` to the +boolean `false`. A loaded settings object remains valid when another preference +is changed and saved. Explicit invalid non-boolean writes remain rejected. diff --git a/docs/spec/03-runtime/13-model-catalog-and-selection.md b/docs/spec/03-runtime/13-model-catalog-and-selection.md index 172c1486eb..3716733898 100644 --- a/docs/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/spec/03-runtime/13-model-catalog-and-selection.md @@ -541,9 +541,12 @@ same model to the check mark, the toggle and the duplicate guard. ### StepFun Step 5 Preview -The StepFun preset selects `https://api.stepfun.com/v1` and Chat Completions. +The service picker exposes only StepFun Plan for StepFun, selecting +`https://api.stepfun.com/step_plan/v1` and Chat Completions. Ordinary API +services remain usable as custom configurations; existing saved endpoints +are never rewritten or relabelled as subscription services. Discovery still determines which models the key can use. For exactly -`step-5-preview` at that endpoint, a reviewed first-party metadata supplement +`step-5-preview` at the plan endpoint or a saved ordinary API endpoint, a reviewed first-party metadata supplement provides a 1,024,000-token context/input window, a conservative 64,000-token output cap, image and tool support, and low/medium/high reasoning when the first-party models.dev record is absent. Third-party catalog records do not diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index a718d92fd8..8802b36f2b 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -14109,7 +14109,9 @@ the latest destination. These assertions measure work counts, not device FPS. - **Preconditions:** Isolated task candidate; no production profile. Mock the external HTTP boundary for automated checks; live API use requires explicit authorization and a temporary credential. -- **Steps:** Select StepFun, discover `step-5-preview`, select/save its binding, +- **Steps:** Select StepFun Plan, verify its subscription endpoint and that no + ordinary StepFun API preset is offered or rematched to Plan, discover + `step-5-preview`, select/save its binding, restore the binding, send text with an image, execute a returned tool call, and continue with the tool result. Repeat low, medium and high reasoning. Refresh to a catalog with a first-party record; retain user overrides. @@ -14124,3 +14126,11 @@ the latest destination. These assertions measure work counts, not device FPS. - **Status:** Automated service/adapter user path in `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation is opt-in. No desktop visual verification is implied. + +### E2E-SETTINGS-retry-default-round-trip + +Load legacy settings without `infiniteProviderRetry`, change an unrelated +preference, and save. Repeat with retry explicitly false and true. Both +renderer and main validation must accept the normalized boolean while retaining +the chosen retry state. Reject invalid non-boolean writes. Covered by settings +round-trip tests in `turn-process.test.mjs` and `provider-catalog-runtime.test.mjs`. diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index d80bc2e15c..f96de9165a 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -1076,3 +1076,9 @@ System/Direct/Custom 代理路由保持不变。 终态。结构化原因会穿过 adapter 的错误扁平化,保留在最终错误行中,也不会触发 provider transport 重建。`EPROTO` 等协议错误继续使用原有重试行为。详见 [证书信任 ADR](../../../adr/provider-system-certificates.md)。 + +### Retry preference read/write compatibility + +Settings reads normalize missing or disabled `infiniteProviderRetry` to the +boolean `false`. A loaded settings object remains valid when another preference +is changed and saved. Explicit invalid non-boolean writes remain rejected. diff --git a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md index 3f58128e84..4d4cb76fd9 100644 --- a/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md +++ b/docs/zh-CN/spec/03-runtime/13-model-catalog-and-selection.md @@ -332,9 +332,12 @@ Electron 使用本地 `models.dev` 记录装饰缓存和新发现的模型行。 ### StepFun Step 5 Preview -The StepFun preset selects `https://api.stepfun.com/v1` and Chat Completions. +The service picker exposes only StepFun Plan for StepFun, selecting +`https://api.stepfun.com/step_plan/v1` and Chat Completions. Ordinary API +services remain usable as custom configurations; existing saved endpoints +are never rewritten or relabelled as subscription services. Discovery still determines which models the key can use. For exactly -`step-5-preview` at that endpoint, a reviewed first-party metadata supplement +`step-5-preview` at the plan endpoint or a saved ordinary API endpoint, a reviewed first-party metadata supplement provides a 1,024,000-token context/input window, a conservative 64,000-token output cap, image and tool support, and low/medium/high reasoning when the first-party models.dev record is absent. Third-party catalog records do not diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 0df63d83f5..ea3942096b 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8340,7 +8340,9 @@ the latest destination. These assertions measure work counts, not device FPS. - **Preconditions:** Isolated task candidate; no production profile. Mock the external HTTP boundary for automated checks; live API use requires explicit authorization and a temporary credential. -- **Steps:** Select StepFun, discover `step-5-preview`, select/save its binding, +- **Steps:** Select StepFun Plan, verify its subscription endpoint and that no + ordinary StepFun API preset is offered or rematched to Plan, discover + `step-5-preview`, select/save its binding, restore the binding, send text with an image, execute a returned tool call, and continue with the tool result. Repeat low, medium and high reasoning. Refresh to a catalog with a first-party record; retain user overrides. @@ -8355,3 +8357,11 @@ the latest destination. These assertions measure work counts, not device FPS. - **Status:** Automated service/adapter user path in `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation is opt-in. No desktop visual verification is implied. + +### E2E-SETTINGS-retry-default-round-trip + +Load legacy settings without `infiniteProviderRetry`, change an unrelated +preference, and save. Repeat with retry explicitly false and true. Both +renderer and main validation must accept the normalized boolean while retaining +the chosen retry state. Reject invalid non-boolean writes. Covered by settings +round-trip tests in `turn-process.test.mjs` and `provider-catalog-runtime.test.mjs`. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 72805c07ee..dce4d986e2 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1058,7 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi für Codierung", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", "presetXiaomi": "Xiaomi", "apiStyleResponses": "OpenAI-Antworten", "apiStyleAnthropic": "Anthropic Messages", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index d400edd273..fa543d7aac 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1070,7 +1070,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index ade0289b89..97ad4c51e4 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1058,7 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi para codificación", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", "presetXiaomi": "Xiaomi", "apiStyleResponses": "Respuestas de OpenAI", "apiStyleAnthropic": "Mensajes antrópicos", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 068af3e25c..db534ff396 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1058,7 +1058,7 @@ sklm: { "presetMinimaxCn": "MiniMax", "presetMinimaxCnOpenai": "MiniMax (OpenAI)", "presetKimiCoding": "Kimi pour le codage", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", "presetXiaomi": "Xiaomi", "apiStyleResponses": "Réponses OpenAI", "apiStyleAnthropic": "Messages anthropiques", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 92737e13d1..24f720bc20 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1071,7 +1071,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 7ef9aa583b..1c67562dea 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1071,7 +1071,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax (OpenAI)", presetKimiCoding: "Kimi For Coding", - presetStepfun: "StepFun", + presetStepfunPlan: "StepFun Plan", presetXiaomi: "Xiaomi", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 5f736735e2..dbb503218b 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1058,7 +1058,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax(OpenAI)", presetKimiCoding: "Kimi 编程", - presetStepfun: "阶跃星辰", + presetStepfunPlan: "StepFun Plan(订阅)", presetXiaomi: "小米", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index ac26886c24..709ac8b402 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1058,7 +1058,7 @@ sklm: { presetMinimaxCn: "MiniMax", presetMinimaxCnOpenai: "MiniMax(OpenAI)", presetKimiCoding: "Kimi 程式設計", - presetStepfun: "階躍星辰", + presetStepfunPlan: "StepFun Plan(訂閱)", presetXiaomi: "小米", apiStyleResponses: "OpenAI Responses", apiStyleAnthropic: "Anthropic Messages", diff --git a/packages/shared/src/provider-presets.ts b/packages/shared/src/provider-presets.ts index 8d509befb3..a214ed6a74 100644 --- a/packages/shared/src/provider-presets.ts +++ b/packages/shared/src/provider-presets.ts @@ -204,12 +204,12 @@ export const NAMED_ENDPOINT_PRESETS: readonly NamedEndpointPreset[] = [ aliases: ["minimax-openai", "minimax-compatible"], }, { - id: "stepfun", - vendorKey: "stepfun", - name: "StepFun", - baseUrl: "https://api.stepfun.com/v1", + id: "stepfun-plan", + vendorKey: "stepfun-plan", + name: "StepFun Plan", + baseUrl: "https://api.stepfun.com/step_plan/v1", apiStyle: "chat_completions", - labelKey: "settings.presetStepfun", + labelKey: "settings.presetStepfunPlan", }, { id: "xiaomi", From a6dbf0e8b81b5ce13cdff2991eaf0d58e2470676 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:08:15 +0800 Subject: [PATCH 3/7] chore(providers): remove newly added tests from StepFun Plan PR Honor the requested scope by retaining the existing test suite and removing the new provider and settings cases and their coverage claims. Production behavior remains unchanged. --- .../test/provider-catalog-runtime.test.mjs | 10 -- apps/desktop/test/stepfun-model.test.mjs | 141 ------------------ apps/desktop/test/turn-process.test.mjs | 27 +--- .../adr/stepfun-first-party-model-metadata.md | 2 +- docs/spec/06-delivery/04-e2e-test-plan.md | 31 ---- .../spec/06-delivery/04-e2e-test-plan.md | 32 ---- .../src/model-capabilities.test.ts | 8 - 7 files changed, 2 insertions(+), 249 deletions(-) delete mode 100644 apps/desktop/test/stepfun-model.test.mjs diff --git a/apps/desktop/test/provider-catalog-runtime.test.mjs b/apps/desktop/test/provider-catalog-runtime.test.mjs index dbbbf4fcb2..f5a37f2106 100644 --- a/apps/desktop/test/provider-catalog-runtime.test.mjs +++ b/apps/desktop/test/provider-catalog-runtime.test.mjs @@ -151,13 +151,3 @@ test("a bulk session refresh reuses catalog matches while preserving each sessio } assert.equal(modelReads, 0, "session list refreshes must not repeat catalog matching work"); }); - -test("loaded retry defaults survive a settings edit and save", async () => { - const { runtime } = await fixtureRuntime(); - for (const settings of [{}, { infiniteProviderRetry: false }, { infiniteProviderRetry: true }]) { - const loaded = runtime.normalizeSettings(settings); - const edited = { ...loaded, theme: "light" }; - assert.equal(edited.infiniteProviderRetry, settings.infiniteProviderRetry === true); - assert.equal(runtime.validateSettingsWrite(edited), edited); - } -}); diff --git a/apps/desktop/test/stepfun-model.test.mjs b/apps/desktop/test/stepfun-model.test.mjs deleted file mode 100644 index 5341e091b8..0000000000 --- a/apps/desktop/test/stepfun-model.test.mjs +++ /dev/null @@ -1,141 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { bindingFromModelInfo, matchNamedPreset } from "@pi-desktop/shared"; -import { modelConfigWithBinding } from "@pi-desktop/agent-runtime"; -import { - ModelsDevCatalog, modelConfigFromModelsDev, modelInfoFromModelsDev, -} from "../electron/main/models-dev-catalog.ts"; - -const baseUrl = "https://api.stepfun.com/step_plan/v1"; -const catalogPath = new URL("../resources/models.dev/api.json", import.meta.url).pathname; -const target = { baseUrl, vendorKey: "stepfun-plan", modelId: "step-5-preview" }; - -test("StepFun selection retains official capabilities through a saved model binding", async () => { - const preset = matchNamedPreset({ baseUrl }); - assert.equal(preset?.id, "stepfun-plan"); - assert.equal(matchNamedPreset({ baseUrl: "https://api.stepfun.com/v1", vendorKey: "stepfun" }), undefined); - assert.equal(preset.apiStyle, "chat_completions"); - const catalog = new ModelsDevCatalog({ catalogPath }); - await catalog.ensureLoaded(); - const model = catalog.findModel(target); - assert.ok(model); - assert.equal(model.metadataSource, "provider"); - const info = modelInfoFromModelsDev(model, "saved-stepfun"); - assert.equal(info.catalogSource, "provider"); - assert.deepEqual(info.supportedThinkingLevels, ["low", "medium", "high"]); - assert.ok(info.capabilities.includes("tools")); - assert.ok(info.capabilities.includes("vision")); - const savedBinding = JSON.parse(JSON.stringify(bindingFromModelInfo(info))); - const config = modelConfigWithBinding(modelConfigFromModelsDev(model, baseUrl), savedBinding); - assert.equal(config.contextWindow, 1_024_000); - assert.equal(config.maxTokens, 64_000); - assert.deepEqual(config.input, ["text", "image"]); - assert.equal(config.reasoning, true); - const overridden = modelConfigWithBinding(config, { - ...savedBinding, contextWindow: 256_000, maxTokens: 4096, - supportsImages: false, thinkingLevels: ["low"], - }); - assert.equal(overridden.contextWindow, 256_000); - assert.equal(overridden.maxTokens, 4096); - assert.deepEqual(overridden.input, ["text"]); - assert.deepEqual(overridden.supportedThinkingLevels, ["low"]); -}); - -test("the StepFun supplement never changes gateways, other models, or lookalike hosts", async () => { - const catalog = new ModelsDevCatalog({ catalogPath }); - await catalog.ensureLoaded(); - for (const baseUrl of ["https://api.example.com/v1", "https://api.stepfun.com.evil.test/v1", "http://api.stepfun.com/v1", "https://api.stepfun.com/other/v1"]) { - assert.notEqual(catalog.findModel({ ...target, baseUrl })?.metadataSource, "provider"); - } - assert.notEqual(catalog.findModel({ ...target, modelId: "step-3.7-flash" })?.metadataSource, "provider"); -}); - -test("a published first-party catalog record supersedes the StepFun supplement after refresh", async () => { - const catalog = new ModelsDevCatalog({ catalogPath, fetchImpl: async () => new Response(JSON.stringify({ - stepfun: { id: "stepfun", name: "StepFun", api: baseUrl, models: { - "step-5-preview": { id: "step-5-preview", name: "Updated Step", reasoning: true, - tool_call: true, modalities: { input: ["text", "image"], output: ["text"] }, - limit: { context: 1_048_576, output: 32_768 } }, - } }, - }), { status: 200 }) }); - await catalog.ensureLoaded(); - assert.equal(catalog.findModel(target)?.limit.context, 1_024_000); - assert.equal(await catalog.refresh(), true); - const updated = catalog.findModel(target); - assert.notEqual(updated.metadataSource, "provider"); - assert.equal(updated.limit.context, 1_048_576); - assert.equal(modelConfigFromModelsDev(updated).source, "models.dev"); -}); - -test("discover, select, restore and stream a StepFun tool round trip through the real adapter", async (t) => { - const { discoverProviderModels } = await import("../electron/main/model-discovery.ts"); - const { buildProviderModel, createProviderModels } = await import("../../../packages/agent-runtime/dist/provider-binding.js"); - const discovery = t.mock.method(globalThis, "fetch", async (url, init) => { - assert.equal(url, `${baseUrl}/models`); - assert.equal(init.headers.Authorization, "Bearer test-credential"); - return Response.json({ data: [{ id: "step-5-preview", object: "model" }] }); - }); - const preset = matchNamedPreset({ baseUrl }); - const listed = await discoverProviderModels({ ...preset, apiKey: "test-credential" }); - discovery.mock.restore(); - const catalog = new ModelsDevCatalog({ catalogPath }); - await catalog.ensureLoaded(); - const metadata = catalog.findModel({ ...target, modelId: listed[0].modelId }); - const savedBinding = JSON.parse(JSON.stringify(bindingFromModelInfo(modelInfoFromModelsDev(metadata, "stepfun-row")))); - const provider = { id: "stepfun-row", name: preset.name, vendorKey: preset.vendorKey, baseUrl, - apiStyle: preset.apiStyle, modelId: savedBinding.id, apiKey: "test-credential", - supportsReasoning: true, supportedThinkingLevels: savedBinding.thinkingLevels, - modelConfig: modelConfigWithBinding(modelConfigFromModelsDev(metadata, baseUrl), savedBinding) }; - const model = buildProviderModel(provider); - const models = createProviderModels(provider, model); - const sse = (delta, finishReason) => new Response( - `data: ${JSON.stringify({ id: "test-stream", object: "chat.completion.chunk", model: model.id, - choices: [{ index: 0, delta, finish_reason: null }] })}\n\n` + - `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: finishReason }], - usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 } })}\n\n` + - "data: [DONE]\n\n", { headers: { "Content-Type": "text/event-stream" } }, - ); - for (const reasoning of ["low", "medium", "high"]) { - let turn = 0; - const context = { systemPrompt: "Use the test tool.", messages: [{ role: "user", timestamp: 1, - content: [{ type: "text", text: "Read the probe." }, { type: "image", data: "dGVzdA==", mimeType: "image/png" }] }], - tools: [{ name: "read_probe", description: "Read the probe", parameters: { type: "object", properties: {} } }] }; - const fetch = async (url, init) => { - assert.equal(String(url), `${baseUrl}/chat/completions`); - const body = JSON.parse(init.body); - assert.equal(body.model, "step-5-preview"); - assert.equal(body.reasoning_effort, reasoning); - assert.equal(body.max_completion_tokens ?? body.max_tokens, 64_000); - assert.equal(body.messages[0].role, "system"); - assert.ok(body.messages[1].content.some(part => part.type === "image_url")); - if (turn++ === 0) return sse({ role: "assistant", reasoning_content: "Read the probe first.", - tool_calls: [{ index: 0, id: "probe-call", type: "function", function: { name: "read_probe", arguments: "{}" } }] }, "tool_calls"); - const assistant = body.messages.find(message => message.role === "assistant"); - assert.equal(assistant.reasoning_content, "Read the probe first."); - assert.equal(body.messages.at(-1).role, "tool"); - assert.equal(body.messages.at(-1).tool_call_id, "probe-call"); - assert.equal(body.messages.at(-1).content, "42"); - return sse({ role: "assistant", content: "42" }, "stop"); - }; - const first = await models.streamSimple(model, context, { reasoning, fetch }).result(); - assert.equal(first.stopReason, "toolUse"); - const call = first.content.find(block => block.type === "toolCall"); - assert.equal(call.name, "read_probe"); - // Reloaded transcript data must retain the reasoning field and tool identity. - context.messages.push(JSON.parse(JSON.stringify(first)), { - role: "toolResult", toolCallId: call.id, toolName: call.name, - content: [{ type: "text", text: "42" }], isError: false, timestamp: 2, - }); - const second = await models.streamSimple(model, context, { reasoning, fetch }).result(); - assert.equal(second.stopReason, "stop"); - assert.equal(second.content.find(block => block.type === "text").text, "42"); - assert.equal(second.usage.totalTokens, 20); - } -}); - -test("an unavailable catalog keeps an explicitly selected StepFun model usable without inventing availability", async () => { - const catalog = new ModelsDevCatalog({ catalogPath: "missing-stepfun-test-catalog.json" }); - assert.equal(await catalog.ensureLoaded(), false); - assert.equal(catalog.findModel(target)?.limit.context, 1_024_000); - assert.deepEqual(catalog.modelsForProvider({ ...target, providerId: "stepfun-row" }), []); -}); diff --git a/apps/desktop/test/turn-process.test.mjs b/apps/desktop/test/turn-process.test.mjs index 6e9bd25bef..661d74fcc3 100644 --- a/apps/desktop/test/turn-process.test.mjs +++ b/apps/desktop/test/turn-process.test.mjs @@ -169,8 +169,7 @@ test("user boundaries retain independent processes and delegation details stay a }); test("settings writes validate the mode without changing other preferences", async () => { - const { api, normalizeSettings, validateSettingsWrite } = await import("../src/lib/api.ts"); - const { IPC } = await import("@pi-desktop/shared"); + const { validateSettingsWrite } = await import("../src/lib/api.ts"); const settings = { defaultMode: "agent", theme: "dark", @@ -178,30 +177,6 @@ test("settings writes validate the mode without changing other preferences", asy onboardingDismissed: false, }; assert.equal(validateSettingsWrite(settings), settings); - for (const infiniteProviderRetry of [undefined, false, true]) { - const loaded = normalizeSettings({ ...settings, defaultCommandShell: "bash", infiniteProviderRetry }); - const edited = { ...loaded, theme: "light" }; - assert.equal(edited.infiniteProviderRetry, infiniteProviderRetry === true); - assert.equal(validateSettingsWrite(edited), edited); - const previousWindow = globalThis.window; - let persisted; - globalThis.window = { piDesktop: { platform: "darwin", invoke: async (channel, input) => { - if (channel === IPC.invoke.settingsGet) return { ok: true, data: { ...settings, infiniteProviderRetry } }; - assert.equal(channel, IPC.invoke.settingsSet); - persisted = input; - return { ok: true, data: {} }; - } } }; - try { - const current = await api.getSettings(); - await api.setSettings({ ...current, theme: "light" }); - assert.equal(persisted.theme, "light"); - assert.equal(persisted.infiniteProviderRetry, infiniteProviderRetry === true); - } finally { - if (previousWindow === undefined) delete globalThis.window; - else globalThis.window = previousWindow; - } - - } const infiniteSettings = { ...settings, infiniteProviderRetry: true }; assert.equal(validateSettingsWrite(infiniteSettings), infiniteSettings); assert.throws( diff --git a/docs/adr/stepfun-first-party-model-metadata.md b/docs/adr/stepfun-first-party-model-metadata.md index d21ab498fd..4c0daa88d2 100644 --- a/docs/adr/stepfun-first-party-model-metadata.md +++ b/docs/adr/stepfun-first-party-model-metadata.md @@ -59,4 +59,4 @@ ownership remain unchanged. - [Step 5 Preview guide](https://platform.stepfun.com/docs/zh/guides/models/step-5-preview) - `GET https://api.stepfun.com/v1/models` (verified 2026-09-21; no credentials or account data are retained in repository fixtures) -- `apps/desktop/test/stepfun-model.test.mjs` +- `apps/desktop/electron/main/stepfun-model-metadata.ts` diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 512a1d3a53..b4905bff85 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7902,7 +7902,6 @@ identify the platform validation still needed. | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — Sidebar row states | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings | -| B / C / Quality — StepFun Step 5 Preview | E2E-PROVIDER-stepfun-preview-tool-round-trip | | B / F / Security — Provider copy | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists | | A — App startup | E2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204 | @@ -14203,36 +14202,6 @@ the latest destination. These assertions measure work counts, not device FPS. - **Status**: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required. -### E2E-PROVIDER-stepfun-preview-tool-round-trip - -- **Preconditions:** Isolated task candidate; no production profile. Mock the - external HTTP boundary for automated checks; live API use requires explicit - authorization and a temporary credential. -- **Steps:** Select StepFun Plan, verify its subscription endpoint and that no - ordinary StepFun API preset is offered or rematched to Plan, discover - `step-5-preview`, select/save its binding, - restore the binding, send text with an image, execute a returned tool call, - and continue with the tool result. Repeat low, medium and high reasoning. - Refresh to a catalog with a first-party record; retain user overrides. -- **Expected:** Exact official endpoint and model ID; 1,024,000 context and - 64,000 output before first-party catalog coverage; image, reasoning and tools - remain enabled after restoration. Reasoning content and tool IDs survive - replay. Final text and usage are delivered. Gateways and other models remain - unchanged; new first-party catalog metadata supersedes the supplement. -- **Specs:** `03-runtime/13-model-catalog-and-selection.md`; ADR - `stepfun-first-party-model-metadata`. -- **Acceptance:** B (models), C (conversation), Quality. -- **Status:** Automated service/adapter user path in - `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation - is opt-in. No desktop visual verification is implied. - -### E2E-SETTINGS-retry-default-round-trip - -Load legacy settings without `infiniteProviderRetry`, change an unrelated -preference, and save. Repeat with retry explicitly false and true. Both -renderer and main validation must accept the normalized boolean while retaining -the chosen retry state. Reject invalid non-boolean writes. Covered by settings -round-trip tests in `turn-process.test.mjs` and `provider-catalog-runtime.test.mjs`. #### E2E-CHAT-parenthesized-url: Complete URLs in user messages diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index cb2572941c..454f181fa1 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5189,7 +5189,6 @@ eleven-tool-round desktop paths are verified by | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — 侧边栏行状态 | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — 侧栏材质与设置返回 | E2E-LAYOUT-sidebar-settings | -| B / C / Quality — StepFun Step 5 Preview | E2E-PROVIDER-stepfun-preview-tool-round-trip | | B / F / Security — 提供商复制 | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — 已选模型顺序 | E2E-MODEL-selected-order-persists | | A — 应用程序启动 | E2E-001、E2E-002、E2E-003、E2E-004、E2E-067、E2E-076、E2E-079、E2E-092、E2E-097、E2E-143、E2E-150、E2E-168、E2E-204、E2E-217 | @@ -8373,37 +8372,6 @@ the latest destination. These assertions measure work counts, not device FPS. - **Status**: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required. -### E2E-PROVIDER-stepfun-preview-tool-round-trip - -- **Preconditions:** Isolated task candidate; no production profile. Mock the - external HTTP boundary for automated checks; live API use requires explicit - authorization and a temporary credential. -- **Steps:** Select StepFun Plan, verify its subscription endpoint and that no - ordinary StepFun API preset is offered or rematched to Plan, discover - `step-5-preview`, select/save its binding, - restore the binding, send text with an image, execute a returned tool call, - and continue with the tool result. Repeat low, medium and high reasoning. - Refresh to a catalog with a first-party record; retain user overrides. -- **Expected:** Exact official endpoint and model ID; 1,024,000 context and - 64,000 output before first-party catalog coverage; image, reasoning and tools - remain enabled after restoration. Reasoning content and tool IDs survive - replay. Final text and usage are delivered. Gateways and other models remain - unchanged; new first-party catalog metadata supersedes the supplement. -- **Specs:** `03-runtime/13-model-catalog-and-selection.md`; ADR - `stepfun-first-party-model-metadata`. -- **Acceptance:** B (models), C (conversation), Quality. -- **Status:** Automated service/adapter user path in - `apps/desktop/test/stepfun-model.test.mjs`; authenticated live API validation - is opt-in. No desktop visual verification is implied. - -### E2E-SETTINGS-retry-default-round-trip - -Load legacy settings without `infiniteProviderRetry`, change an unrelated -preference, and save. Repeat with retry explicitly false and true. Both -renderer and main validation must accept the normalized boolean while retaining -the chosen retry state. Reject invalid non-boolean writes. Covered by settings -round-trip tests in `turn-process.test.mjs` and `provider-catalog-runtime.test.mjs`. - ### E2E-PLUGIN-crash-report-names-the-exit-code - **先决条件:** 已加载的插件宿主进程自行死亡——夹具为写一行 stderr 后 `process.exit(7)`——并带一个常驻服务,使监督器路径也被覆盖。 diff --git a/packages/agent-runtime/src/model-capabilities.test.ts b/packages/agent-runtime/src/model-capabilities.test.ts index ca4e4cc11a..54cb30bf32 100644 --- a/packages/agent-runtime/src/model-capabilities.test.ts +++ b/packages/agent-runtime/src/model-capabilities.test.ts @@ -26,14 +26,6 @@ function knownModel(): ModelConfig { } describe("main-supplied model capabilities", () => { - it("retains a first-party published window as the safety ceiling", () => { - const configured = modelConfigWithBinding({ - ...knownModel(), source: "provider", contextWindow: 1_024_000, - }, { contextWindow: 2_000_000, maxTokens: 64_000, thinkingLevels: ["high"] }); - expect(configured.contextWindow).toBe(2_000_000); - expect(configured.catalogContextWindow).toBe(1_024_000); - }); - it("uses models.dev modalities for visual transport", () => { expect(visionFromModelConfig(knownModel())).toBe(true); expect( From 893d11d1fabc45964358a5810a866f3d64821b71 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:49 +0800 Subject: [PATCH 4/7] fix(providers): default StepFun Plan to Anthropic Messages Preserve explicitly saved OpenAI accounts while using the existing Anthropic adapter for new StepFun Plan entries. Restore CI after upstream voice integration: synchronize dependencies, align SDK and IPC contracts, reuse settings persistence, and keep lifecycle wiring within the entrypoint budget. Repair existing fixture assumptions and allow bounded real-transport retries enough time to finish. --- apps/desktop/electron/main/index.ts | 9 - apps/desktop/electron/main/ipc/register.ts | 3 +- apps/desktop/electron/main/ipc/voice-ipc.ts | 8 +- apps/desktop/electron/main/voice-service.ts | 10 +- .../src/features/settings/SettingsPage.tsx | 2 +- .../settings/voice/VoiceSettingsSection.tsx | 20 +- apps/desktop/src/styles/voice.css | 16 +- .../desktop/test/packaging-footprint.test.mjs | 2 +- .../session-message-presentation.test.mjs | 1 + .../03-runtime/12-provider-config-schema.md | 9 + docs/spec/06-delivery/04-e2e-test-plan.md | 5 +- .../src/subagent-fallback.test.ts | 2 +- packages/shared/src/provider-presets.ts | 2 +- packages/voice-runtime/src/externals.d.ts | 10 - .../voice-runtime/src/model-downloader.ts | 8 +- pnpm-lock.yaml | 581 ++++++++++++++++++ 16 files changed, 640 insertions(+), 48 deletions(-) diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index de108674d7..f346f35ac4 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -114,7 +114,6 @@ import { registerWindowIpc } from "./ipc/window-ipc"; import { registerPullsIpc } from "./ipc/pulls-ipc"; import { registerAgentIpc } from "./ipc/agent-ipc"; import { registerIpcHandlers } from "./ipc/register"; -import { VoiceService } from "./voice-service"; import { type WindowLifecycleState, } from "./bootstrap/window"; @@ -1247,13 +1246,6 @@ runtimeLifecycle = createRuntimeLifecycle({ }); const { bootHostStatus, runtimeArch, bootBackends } = runtimeLifecycle; -// Voice service — created lazily on first use, disposed on quit. -const voiceService = new VoiceService( - dataDir + "/voice-models", - () => mainWindow, -); -app.once("before-quit", () => voiceService.dispose()); - function registerIpc() { return registerIpcHandlers({ traySessions: applicationLifecycle!.traySessions, @@ -1345,7 +1337,6 @@ function registerIpc() { getPluginPanelTheme: () => pluginPanelTheme, isDeveloperMode: () => developerMode, sendToRenderer, - voiceService, }); } diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 09a147eff9..704f2585b6 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -32,6 +32,7 @@ import { createComposerTemplateLoader, registerWorkspaceIpc } from "./workspace- import { registerComposerIpc } from "./composer-ipc"; import { registerSpeechIpc } from "./speech-ipc"; import { registerVoiceIpc } from "./voice-ipc"; +import { createVoiceService } from "../voice-service"; import type { IpcRegistrar } from "./types"; import type { createTraySessions } from "../tray-sessions"; @@ -154,7 +155,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { getPluginPanelTheme, isDeveloperMode, sendToRenderer, - voiceService, + voiceService = createVoiceService(dataDir, getMainWindow), } = dependencies; diff --git a/apps/desktop/electron/main/ipc/voice-ipc.ts b/apps/desktop/electron/main/ipc/voice-ipc.ts index f511c72048..1afa81d03f 100644 --- a/apps/desktop/electron/main/ipc/voice-ipc.ts +++ b/apps/desktop/electron/main/ipc/voice-ipc.ts @@ -22,16 +22,16 @@ export function registerVoiceIpc({ handle(IPC.invoke.voiceStop, () => voiceService.stop()); - handle(IPC.invoke.voiceCancel, () => { + handle(IPC.invoke.voiceCancel, async () => { voiceService.cancel(); return { ok: true }; }); - handle(IPC.invoke.voiceGetState, () => voiceService.getState()); + handle(IPC.invoke.voiceGetState, async () => voiceService.getState()); handle(IPC.invoke.voiceGetDevices, () => voiceService.getDevices()); - handle(IPC.invoke.voiceGetModels, () => voiceService.getModels()); + handle(IPC.invoke.voiceGetModels, async () => voiceService.getModels()); handle(IPC.invoke.voiceDownloadModel, (input: unknown) => { const { modelId } = input as { modelId: string }; @@ -43,7 +43,7 @@ export function registerVoiceIpc({ return voiceService.deleteModel(modelId); }); - handle(IPC.invoke.voiceUpdateSettings, (input: unknown) => + handle(IPC.invoke.voiceUpdateSettings, async (input: unknown) => voiceService.updateSettings(input as any), ); diff --git a/apps/desktop/electron/main/voice-service.ts b/apps/desktop/electron/main/voice-service.ts index 6413c5589c..abb0da9ecf 100644 --- a/apps/desktop/electron/main/voice-service.ts +++ b/apps/desktop/electron/main/voice-service.ts @@ -5,7 +5,8 @@ * means zero import overhead at startup. */ -import { BrowserWindow } from "electron"; +import { app, BrowserWindow } from "electron"; +import { join } from "node:path"; import { PvRecorderBackend, checkMicrophonePermission, requestMicrophonePermission } from "./audio-backend"; import type { AudioCaptureFactory, @@ -171,3 +172,10 @@ export class VoiceService { } } } + +/** Create the IPC-owned service and release it when Electron quits. */ +export function createVoiceService(dataDir: string, getWindow: () => BrowserWindow | null): VoiceService { + const service = new VoiceService(join(dataDir, "voice-models"), getWindow); + app.once("before-quit", () => service.dispose()); + return service; +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 3060b43ec8..d279fdddec 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -548,7 +548,7 @@ export function SettingsPage() { )} {tab === "voice" && settings && ( - + )} {tab === "shortcuts" && settings && ( diff --git a/apps/desktop/src/features/settings/voice/VoiceSettingsSection.tsx b/apps/desktop/src/features/settings/voice/VoiceSettingsSection.tsx index c24c80068a..0d9ea9e61e 100644 --- a/apps/desktop/src/features/settings/voice/VoiceSettingsSection.tsx +++ b/apps/desktop/src/features/settings/voice/VoiceSettingsSection.tsx @@ -3,6 +3,7 @@ */ import { useCallback, useEffect, useState } from "react"; +import type { AppSettings, VoiceInputSettings } from "@pi-desktop/shared"; import type { TFunction } from "i18next"; import { useAppStore } from "../../../stores/app-store"; import { voiceIpc } from "../../voice/voice-ipc"; @@ -26,9 +27,11 @@ interface ModelState { error?: string; } -export function VoiceSettingsSection({ t }: { t: TFunction }) { +export function VoiceSettingsSection({ t, saveSettings }: { + t: TFunction; + saveSettings: (patch: Partial) => Promise; +}) { const settings = useAppStore((s) => s.settings); - const updateSettings = useAppStore((s) => s.updateSettings); const voiceSettings = settings?.voice ?? { enabled: false, @@ -63,12 +66,12 @@ export function VoiceSettingsSection({ t }: { t: TFunction }) { }, []); const update = useCallback( - (patch: Record) => { + (patch: Partial) => { const next = { ...voiceSettings, ...patch }; - void updateSettings({ voice: next as any }); + void saveSettings({ voice: next }); void voiceIpc.updateSettings(next); }, - [voiceSettings, updateSettings], + [voiceSettings, saveSettings], ); const formatSize = (bytes: number) => { @@ -146,7 +149,12 @@ export function VoiceSettingsSection({ t }: { t: TFunction }) {