From e7701d1f2c38b590c27118a917e66213bdc10410 Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Sat, 12 Sep 2026 17:43:00 +0800
Subject: [PATCH 1/2] docs: record model capability assessment
---
.../20260912-model-capabilities-c48271f9.md | 58 +++++++++++++++++++
1 file changed, 58 insertions(+)
create mode 100644 .project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
diff --git a/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
new file mode 100644
index 0000000..b20e3a7
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
@@ -0,0 +1,58 @@
+# Task: Inspect model capabilities across gateway server and client
+
+## Identity
+
+- Task ID: 20260912-model-capabilities-c48271f9
+- Mode: Feature
+- Branch: codex/20260912-model-capabilities-c48271f9-model-capabilities
+- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260912-model-capabilities-c48271f9
+- Base commit: 70fa916edf74bf44308b279f90ccd945f8269f48
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- Read-only assessment of Makelore imported model capabilities, local profiles, model UI and Pi runtime capability resolution.
+- Product source, test suites, main checkout and other task records remain unchanged.
+- Maintain only this task record.
+
+## Intent And Constraints
+
+- User requested independent assessment across Works Square, one-api and Makelore; no subagents were created.
+- Concurrent Task Gate: Passed. Bundled check/start/status verified this task ID, feature mode, branch, exact base and owned isolated worktree. The original checkout had unrelated uncommitted task documents.
+- Project Context Loaded: read entry/memory index, active task, positioning/current-state, decision index, system/module/data-flow documents, domain/success criteria, relevant evidence/reflection/commitment/stale indexes; also AGENTS.md, README and ADR-006 Pi hard cutover.
+- Goal and focus: Makelore provides Code/Canvas/Robot with Main-owned providers and Pi 0.84.2.
+- Planning Gate: Passed for assessment only. Proposals do not enact architecture changes or promote canonical memory.
+- Peer scopes were read via registry task-record paths only. Numerous historical owners and placeholder scopes remain; these are unknown coordination state, not authority to adopt work. Assessment is confined to a fixed committed baseline and task-owned records, so there is no shared source writer or conflicting implementation decision.
+- Relevant established boundary: Works already owns optional per-model reasoning metadata; client local profiles supply fallback and provider wire adaptations. Replacing this authority is a proposal requiring agreement, not a presumed accepted ADR.
+- Stale/unknown context: client positioning contains placeholders, so current README/AGENTS/ADR-006 bound the assessment; historical OpenCode/gateway notes are not runtime truth. Installed/deployed state, live provider/account coverage, and one-api internals are unverified.
+- Diagnosis scope: no production failure or provider response was supplied. Used real source functions to reproduce concrete contract gaps; no speculative runtime root cause, temporary instrumentation, or remediation loop is claimed.
+
+## Outcome
+
+- Confirmed server reasoning override exists, but low/high/max normalization drops other efforts and modalities. Model labels depend on local profiles, runtime image support combines other local sources, and live effort controls use Main/Pi snapshots. No product change. Cross-project one-api inspection remains blocked pending user exception.
+- Detailed evidence: D:\Datas\PythonProjects\.codex-worktrees\works-square-server\20260912-model-capabilities-a7e194c2\.project-docs\50-evidence\topics\20260912-model-capabilities-a7e194c2__model-capability-assessment.md
+- Recommended provider/channel capability catalog -> Works business-filtered projection -> one resolved client UI/runtime capability. This remains a recommendation.
+- one-api gate exception request remains pending; no inference from elapsed time.
+
+## Verification
+
+- Bundled check_project_docs/start/status passed for this owned worktree.
+- Actual shared TypeScript modules transpiled in memory and executed: low/medium/xhigh reduced to low, modality removed, gpt-4.1-mini label = text; intentional preservation assertion exited 1.
+- Official Alibaba/Anthropic/OpenAI/Google docs were checked for metadata availability; no paid model/network API probe.
+- No full application build or broad tests: no product behavior changed. Documentation drift and final Git boundary check are the completion checks.
+
+## Follow-ups
+
+- Finish one-api read-only source tracing only after user permission or a functioning task gate.
+- Agree capability ownership and contract, then implement coordinated server/client/gateway changes with end-to-end fixture coverage and provider serialization tests.
+- Actual deployment, refresh and account-specific provider metadata coverage require later verification.
+
+## Promotion Candidates
+
+- Target: canonical model/provider architecture and data-flow docs.
+- Proposal: centralize platform-managed effective capability in a provider-aware one-api catalog and project it through Works Square to Main/Pi and UI; distinguish unknown, unsupported, switch-only, effort and budget.
+- Evidence: source functions and dated official references in the assessment.
+- Future impact: model onboarding, UI labels, attachments, reasoning selection and wire serialization.
+- Semantic conflict: existing canonical rules currently make Works server static metadata authoritative with client local fallback; no canonical edit is made.
+- Human confirmation required: yes, before accepting the architecture and implementing the cross-repository behavior.
From 26cbb29aa904dba776f0b8776b7c6a10f9d64628 Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Sat, 12 Sep 2026 21:37:18 +0800
Subject: [PATCH 2/2] feat: use managed model capabilities for reasoning and
image input
---
.../20260912-model-capabilities-c48271f9.md | 47 ++--
README.md | 2 +
electron/api/routes/coding-conversations.ts | 6 +-
electron/api/routes/providers.ts | 15 +-
electron/coding-projects/project-config.ts | 5 +
.../coding-runtime/conversation-service.ts | 4 +-
electron/coding-runtime/pi/extension-host.ts | 7 +-
.../pi/extensions/makelore-runtime.ts | 16 +-
electron/coding-runtime/pi/provider-config.ts | 29 ++-
electron/coding-runtime/pi/runtime.ts | 48 +++-
.../coding-runtime/pi/session-registry.ts | 3 +-
electron/coding-runtime/pi/subagent-child.ts | 4 +
.../services/providers/provider-service.ts | 3 +
electron/shared/providers/types.ts | 1 +
shared/coding-conversation-contracts.ts | 4 +
shared/coding-conversation-reducer.ts | 3 +
shared/managed-model-capabilities.ts | 193 ++++++++++++++
shared/user-model-config.ts | 2 +-
src/lib/coding-conversations.ts | 3 +-
src/lib/coding-model-options.ts | 5 +
src/lib/providers.ts | 1 +
src/pages/Chat/CodingComposer.tsx | 14 +-
.../Chat/CodingComposerRuntimeControls.tsx | 26 +-
src/pages/ProjectConfiguration/index.tsx | 7 +-
tests/e2e/pi-coding-first-chat.spec.ts | 47 +++-
tests/unit/managed-model-capabilities.test.ts | 112 ++++++++
tests/unit/pi-conversation-runtime.test.ts | 56 +++-
tests/unit/pi-provider-config.test.ts | 241 +++---------------
tests/unit/provider-routes.test.ts | 19 ++
29 files changed, 652 insertions(+), 271 deletions(-)
create mode 100644 shared/managed-model-capabilities.ts
create mode 100644 tests/unit/managed-model-capabilities.test.ts
diff --git a/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
index b20e3a7..24c58da 100644
--- a/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
+++ b/.project-docs/30-worklog/tasks/20260912-model-capabilities-c48271f9.md
@@ -12,47 +12,36 @@
## Scope
-- Read-only assessment of Makelore imported model capabilities, local profiles, model UI and Pi runtime capability resolution.
-- Product source, test suites, main checkout and other task records remain unchanged.
-- Maintain only this task record.
+- Implement authoritative managed-model v2 import and persistence, provider input metadata, durable native reasoning choices, Main validation/frozen request context, Pi hook and child-worker propagation, runtime/image UI and focused unit/Electron coverage.
## Intent And Constraints
-- User requested independent assessment across Works Square, one-api and Makelore; no subagents were created.
-- Concurrent Task Gate: Passed. Bundled check/start/status verified this task ID, feature mode, branch, exact base and owned isolated worktree. The original checkout had unrelated uncommitted task documents.
-- Project Context Loaded: read entry/memory index, active task, positioning/current-state, decision index, system/module/data-flow documents, domain/success criteria, relevant evidence/reflection/commitment/stale indexes; also AGENTS.md, README and ADR-006 Pi hard cutover.
-- Goal and focus: Makelore provides Code/Canvas/Robot with Main-owned providers and Pi 0.84.2.
-- Planning Gate: Passed for assessment only. Proposals do not enact architecture changes or promote canonical memory.
-- Peer scopes were read via registry task-record paths only. Numerous historical owners and placeholder scopes remain; these are unknown coordination state, not authority to adopt work. Assessment is confined to a fixed committed baseline and task-owned records, so there is no shared source writer or conflicting implementation decision.
-- Relevant established boundary: Works already owns optional per-model reasoning metadata; client local profiles supply fallback and provider wire adaptations. Replacing this authority is a proposal requiring agreement, not a presumed accepted ADR.
-- Stale/unknown context: client positioning contains placeholders, so current README/AGENTS/ADR-006 bound the assessment; historical OpenCode/gateway notes are not runtime truth. Installed/deployed state, live provider/account coverage, and one-api internals are unverified.
-- Diagnosis scope: no production failure or provider response was supplied. Used real source functions to reproduce concrete contract gaps; no speculative runtime root cause, temporary instrumentation, or remediation loop is claimed.
+- The user approved the detailed design and requested implementation. Concurrent Task Gate and Planning Gate passed in this exact owned feature worktree before edits; start/status matched task/branch/base and peer records were read. Numerous historical placeholder peers remain unknown coordination state, with no unresolved concrete dependency blocking these isolated changes.
+- Applied maintain-project-docs from C:/Users/7brot/.codex/skills/maintain-project-docs. Relevant entry, task, positioning/decisions/architecture/domain/evidence and commitments were loaded. No subagents, main-checkout changes, canonical-memory promotion, deployment or paid inference calls.
+- Related task IDs: Works 20260912-model-capabilities-a7e194c2; one-api 20260912-model-capabilities-b6392e1d; Makelore 20260912-model-capabilities-c48271f9.
## Outcome
-- Confirmed server reasoning override exists, but low/high/max normalization drops other efforts and modalities. Model labels depend on local profiles, runtime image support combines other local sources, and live effort controls use Main/Pi snapshots. No product change. Cross-project one-api inspection remains blocked pending user exception.
-- Detailed evidence: D:\Datas\PythonProjects\.codex-worktrees\works-square-server\20260912-model-capabilities-a7e194c2\.project-docs\50-evidence\topics\20260912-model-capabilities-a7e194c2__model-capability-assessment.md
-- Recommended provider/channel capability catalog -> Works business-filtered projection -> one resolved client UI/runtime capability. This remains a recommendation.
-- one-api gate exception request remains pending; no inference from elapsed time.
+- Managed niancode-user-models consumes only server v2 facts for image/reasoning capabilities. Missing/malformed capabilities stay unknown instead of borrowing local profiles; BYOK retains existing behavior. V2 public aliases and arbitrary native effort strings are preserved.
+- ProductModelRef durably stores default/disabled/enabled+native effort separately from the Pi carrier enum. Model switches reset managed choice to default; refresh-invalid choices and unsupported image submissions are rejected by Main before prompt acceptance.
+- Main writes a prepared managed request into the existing worker context, including child workers. The materialized Pi before_provider_request hook removes SDK-injected thinking controls and applies the frozen fields on each request/tool continuation. Default omits controls instead of accidentally disabling thinking.
+- Renderer menus show model default, switch options and original effort strings (including xhigh/new values). Main/provider descriptor and image attachment UI agree; the project model cards display authoritative capability summaries. Existing Web Search and provider refresh coordination are retained.
+- The legacy snapshot reducer rejected empty availableThinkingLevels, discovered by the new runtime test; managed snapshots now carry the separate capability field without emitting an invalid empty legacy enum list.
## Verification
-- Bundled check_project_docs/start/status passed for this owned worktree.
-- Actual shared TypeScript modules transpiled in memory and executed: low/medium/xhigh reduced to low, modality removed, gpt-4.1-mini label = text; intentional preservation assertion exited 1.
-- Official Alibaba/Anthropic/OpenAI/Google docs were checked for metadata availability; no paid model/network API probe.
-- No full application build or broad tests: no product behavior changed. Documentation drift and final Git boundary check are the completion checks.
+- Task Documentation Gate: source changes reviewed and bundled task-aware documentation drift check passed. The result and this Ready for Integration record are committed together; registry completion follows the commit.
+
+- 70 tests passed in 11 focused files covering managed normalization/options and actual Pi HTTP payload, provider projection/import, runtime persistence/invalid selection rejection, session registry, project config, extension host/bundle, provider refresh and composer behavior. The updated provider import test also passed separately after adding v2 persistence coverage.
+- Actual Electron E2E passed: v2 exposes native xhigh, submits {mode: enabled, effort: xhigh}, offers model-default/disabled, and disables image attachment for a text-only model.
+- pnpm run typecheck and pnpm run build:vite passed. Additional full Main tsc reported 61 diagnostics; an in-memory TypeScript baseline comparison against recorded base found the same 61 and zero additions. The temporary comparison script was removed.
+- HTTP serialization uses a local server and actual Pi 0.84.2 provider plus the materialized extension, covering default/disabled/enabled/low/medium/xhigh/max/future-native with Qwen and DeepSeek controls. No paid inference request was sent.
+- Git working and committed changes since the recorded base were inspected. Documentation drift and final committed ownership completion recorded below.
## Follow-ups
-- Finish one-api read-only source tracing only after user permission or a functioning task gate.
-- Agree capability ownership and contract, then implement coordinated server/client/gateway changes with end-to-end fixture coverage and provider serialization tests.
-- Actual deployment, refresh and account-specific provider metadata coverage require later verification.
+- Integrate alongside one-api and Works. Deployment acceptance must verify the real account catalog/group and provider HTTP responses. Existing 61 Main type errors are outside this change. Budget DTO is preserved with no first-delivery budget editor.
## Promotion Candidates
-- Target: canonical model/provider architecture and data-flow docs.
-- Proposal: centralize platform-managed effective capability in a provider-aware one-api catalog and project it through Works Square to Main/Pi and UI; distinguish unknown, unsupported, switch-only, effort and budget.
-- Evidence: source functions and dated official references in the assessment.
-- Future impact: model onboarding, UI labels, attachments, reasoning selection and wire serialization.
-- Semantic conflict: existing canonical rules currently make Works server static metadata authoritative with client local fallback; no canonical edit is made.
-- Human confirmation required: yes, before accepting the architecture and implementing the cross-repository behavior.
+- Promote v2 authority for managed modalities/reasoning, durable native product choice and Main-owned Pi payload preparation to canonical architecture/ADR/current-state during integration. Evidence: new actual HTTP test, runtime regression, Electron E2E and server/gateway linked tasks. User approved the design and implementation; BYOK and existing Web Search remain separate, with v1 reserved for older client support.
diff --git a/README.md b/README.md
index 91ca899..d5b2555 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
**一念成光,万物可创。**
+平台托管模型的图片输入与思考选项由 Works Square 的 `model_capabilities_v2` 下发。未知能力保持未知;会话保存“模型默认 / 关闭 / 开启及原生强度”,Pi 请求前按冻结的选择写入供应商参数。配置刷新后失效的选择会在发送前提示重新选择;BYOK 模型沿用自身配置。
+
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts
index 8666664..edf9dbd 100644
--- a/electron/api/routes/coding-conversations.ts
+++ b/electron/api/routes/coding-conversations.ts
@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
+import { parseManagedReasoningChoice } from '../../../shared/managed-model-capabilities';
import type { ConversationInteractionResponse } from '../../coding-runtime/contracts';
import { CodingConversationServiceError } from '../../coding-runtime/conversation-service';
import { normalizeProductModelRef } from '../../coding-projects/project-config';
@@ -182,12 +183,15 @@ export async function handleCodingConversationRoutes(
return true;
}
if (action === 'thinking' && req.method === 'POST') {
- const body = await parseJsonBody<{ thinkingLevel?: unknown }>(req);
+ const body = await parseJsonBody<{ thinkingLevel?: unknown; reasoningChoice?: unknown }>(req);
+ const reasoningChoice = body.reasoningChoice === undefined ? undefined : parseManagedReasoningChoice(body.reasoningChoice);
+ if (reasoningChoice === null) invalidRequest('Reasoning choice is invalid');
if (!THINKING_LEVELS.has(String(body.thinkingLevel))) invalidRequest('Thinking level is invalid');
sendJson(res, 200, {
model: await service.setThinking(
conversationId,
body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max',
+ reasoningChoice ?? undefined,
),
});
return true;
diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts
index e5d5050..0a3fbb6 100644
--- a/electron/api/routes/providers.ts
+++ b/electron/api/routes/providers.ts
@@ -136,7 +136,10 @@ function normalizeWorksSquareAiGatewayBaseUrl(baseUrl: string, credentialMode: s
return normalized;
}
+import { normalizeManagedModelCatalog } from '../../../shared/managed-model-capabilities';
+
type ImportedUserModelConfig = {
+ modelCapabilitiesV2?: import('../../../shared/managed-model-capabilities').ManagedModelCatalog;
label: string;
baseUrl: string;
apiKey: string;
@@ -176,7 +179,10 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser
const apiKeyExpiresIn = typeof apiKeyExpiresInRaw === 'number' && Number.isFinite(apiKeyExpiresInRaw)
? Math.max(0, Math.floor(apiKeyExpiresInRaw))
: null;
- const models = normalizeImportedModels(record.models);
+ const v2 = normalizeManagedModelCatalog(record.model_capabilities_v2);
+ const models = v2 && Array.isArray(record.models)
+ ? [...new Set(record.models.filter((id): id is string => typeof id === 'string' && !!id.trim()).map(id => id.trim()))]
+ : normalizeImportedModels(record.models);
if (models.length === 0) {
throw new Error('Works Square model config response has no models');
}
@@ -185,6 +191,7 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser
? record.label.trim()
: NIANCODE_USER_MODEL_ACCOUNT_LABEL;
const modelCapabilities = normalizeImportedModelCapabilities(record.model_capabilities, models);
+ const modelCapabilitiesV2 = normalizeManagedModelCatalog(record.model_capabilities_v2, models);
return {
label,
@@ -194,6 +201,7 @@ export function normalizeImportedUserModelConfig(payload: unknown): ImportedUser
apiKeyExpiresIn,
models,
...(modelCapabilities ? { modelCapabilities } : {}),
+ ...(modelCapabilitiesV2 ? { modelCapabilitiesV2 } : {}),
};
}
@@ -217,9 +225,11 @@ function importedUserModelMetadata(
const metadata = { ...(existing?.metadata ?? {}) };
delete metadata.worksSquareCredentialExpiresAt;
delete metadata.worksSquareModelCapabilities;
+ delete metadata.worksSquareModelCapabilitiesV2;
return {
...metadata,
customModels: modelConfig.models,
+ ...(modelConfig.modelCapabilitiesV2 ? { worksSquareModelCapabilitiesV2: modelConfig.modelCapabilitiesV2 } : {}),
...(modelConfig.modelCapabilities
? { worksSquareModelCapabilities: modelConfig.modelCapabilities }
: {}),
@@ -232,12 +242,14 @@ function importedUserModelMetadata(
delete metadata.worksSquareCredentialExpiresAt;
delete metadata.worksSquareOneApiBaseUrl;
delete metadata.worksSquareModelCapabilities;
+ delete metadata.worksSquareModelCapabilitiesV2;
const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null
? undefined
: new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString();
return {
...metadata,
customModels: modelConfig.models,
+ ...(modelConfig.modelCapabilitiesV2 ? { worksSquareModelCapabilitiesV2: modelConfig.modelCapabilitiesV2 } : {}),
...(modelConfig.modelCapabilities
? { worksSquareModelCapabilities: modelConfig.modelCapabilities }
: {}),
@@ -309,6 +321,7 @@ function providerAccountRuntimeShape(account: ProviderAccount): unknown {
metadata: {
customModels: account.metadata?.customModels,
worksSquareModelCapabilities: account.metadata?.worksSquareModelCapabilities,
+ worksSquareModelCapabilitiesV2: account.metadata?.worksSquareModelCapabilitiesV2,
worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode,
worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl,
},
diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts
index 95aeafe..ef6352a 100644
--- a/electron/coding-projects/project-config.ts
+++ b/electron/coding-projects/project-config.ts
@@ -109,6 +109,8 @@ function normalizeResponsibility(value: unknown): ProjectAgentResponsibility {
};
}
+import { parseManagedReasoningChoice } from '../../shared/managed-model-capabilities';
+
export function normalizeProductModelRef(value: unknown): ProductModelRef {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Product model must be an object');
@@ -123,6 +125,9 @@ export function normalizeProductModelRef(value: unknown): ProductModelRef {
accountId,
modelId,
thinkingLevel: record.thinkingLevel as ConversationThinkingLevel,
+ ...(record.reasoningChoice !== undefined ? {
+ reasoningChoice: parseManagedReasoningChoice(record.reasoningChoice) ?? (() => { throw new Error('Invalid reasoning choice'); })(),
+ } : {}),
};
}
diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts
index 8103cf2..432845b 100644
--- a/electron/coding-runtime/conversation-service.ts
+++ b/electron/coding-runtime/conversation-service.ts
@@ -476,11 +476,11 @@ export class CodingConversationService {
} catch (error) { runtimeError(error); }
}
- async setThinking(conversationId: string, thinkingLevel: ProductModelRef['thinkingLevel']): Promise {
+ async setThinking(conversationId: string, thinkingLevel: ProductModelRef['thinkingLevel'], reasoningChoice?: ProductModelRef['reasoningChoice']): Promise {
await this.ensurePrepared(conversationId);
const { project } = await this.projects.findActiveConversation(conversationId);
try {
- const state = await this.runtime.setThinking({ conversationId, thinkingLevel });
+ const state = await this.runtime.setThinking({ conversationId, thinkingLevel, reasoningChoice });
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
return state;
} catch (error) { runtimeError(error); }
diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts
index 9072a07..7754162 100644
--- a/electron/coding-runtime/pi/extension-host.ts
+++ b/electron/coding-runtime/pi/extension-host.ts
@@ -30,6 +30,7 @@ const CORE_PRODUCT_TOOL_NAMES = new Set([
]);
interface WorkerRegistrationRecord {
+ managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest;
token: string;
conversationId: string;
generation: number;
@@ -59,6 +60,7 @@ export interface PiExtensionWorkerRegistration {
}
export interface RegisterPiExtensionWorkerInput {
+ managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest;
conversationId: string;
generation: number;
projectId: string;
@@ -246,6 +248,7 @@ export class PiManagedExtensionHost {
const token = randomBytes(32).toString('base64url');
const contextFile = path.join(input.extensionsDir, `worker-${randomUUID()}.json`);
const record: WorkerRegistrationRecord = {
+ managedModelRequest: input.managedModelRequest,
token,
conversationId: input.conversationId,
generation: input.generation,
@@ -292,7 +295,7 @@ export class PiManagedExtensionHost {
};
}
- async bindRun(conversationId: string, generation: number, runId: string): Promise {
+ async bindRun(conversationId: string, generation: number, runId: string, managedModelRequest?: import('../../../shared/managed-model-capabilities').ManagedModelRequest): Promise {
const record = this.findWorker(conversationId, generation);
if (!record) throw new Error('Pi extension worker registration is unavailable');
if (this.productTools && record.projectPath) {
@@ -301,6 +304,7 @@ export class PiManagedExtensionHost {
this.releaseWorkerResources(record);
this.runBindings.set(conversationId, runId);
record.runId = runId;
+ if (managedModelRequest) record.managedModelRequest = structuredClone(managedModelRequest);
await this.writeContext(record);
}
@@ -664,6 +668,7 @@ export class PiManagedExtensionHost {
tools: record.tools,
projectWriteLeaseToolNames: record.projectWriteLeaseToolNames,
...(record.runId ? { runId: record.runId } : {}),
+ ...(record.managedModelRequest ? { managedModelRequest: record.managedModelRequest } : {}),
});
}
diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts
index 9330c42..e1944b9 100644
--- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts
+++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts
@@ -1,7 +1,7 @@
import path from 'node:path';
import { atomicWriteText } from '../../../coding-projects/atomic-json';
-export const MAKELORE_PI_EXTENSION_VERSION = 6;
+export const MAKELORE_PI_EXTENSION_VERSION = 7;
export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`;
const BUNDLE_SOURCE = String.raw`
@@ -434,6 +434,20 @@ export function createMakeloreRuntime(runtimeDefaults = {}) {
await registerDynamicProductTools();
+ pi.on('before_provider_request', async (event) => {
+ const context = await readWorkerContext();
+ const managed = context.managedModelRequest;
+ if (!managed || !context.runId) return;
+ if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) return;
+ if (event.payload.model !== managed.modelId) return;
+ const payload = { ...event.payload };
+ delete payload.thinking;
+ delete payload.enable_thinking;
+ delete payload.reasoning_effort;
+ delete payload.thinking_budget;
+ return { ...payload, ...managed.reasoningFields };
+ });
+
pi.on('tool_call', async (event, ctx) => {
if (!MUTATION_TOOLS.has(event.toolName) && !dynamicLeaseTools.has(event.toolName)) return;
const input = event.input || event.arguments || event.args || {};
diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts
index a3ac93f..54b3940 100644
--- a/electron/coding-runtime/pi/provider-config.ts
+++ b/electron/coding-runtime/pi/provider-config.ts
@@ -35,8 +35,10 @@ export const PI_PROVIDER_APIS = [
] as const;
export type PiProviderApi = (typeof PI_PROVIDER_APIS)[number];
+import { unknownManagedModelCapability } from '../../../shared/managed-model-capabilities';
export interface PiProviderModelDescriptor {
+ managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability;
id: string;
name: string;
input: Array<'text' | 'image'>;
@@ -119,6 +121,7 @@ export async function buildPiProviderCatalogFromProviderService(
}
export interface PiProviderSelection {
+ managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability;
accountId: string;
runtimeProviderId: string;
modelId: string;
@@ -258,7 +261,7 @@ function normalizeModelId(rawModelId: string | undefined, account: ProviderAccou
const unqualified = modelId.startsWith(runtimePrefix)
? modelId.slice(runtimePrefix.length)
: modelId;
- return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID
+ return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID && !account.metadata?.worksSquareModelCapabilitiesV2
? normalizeImportedUserModelId(unqualified)
: unqualified;
}
@@ -306,41 +309,52 @@ function modelDescriptor(
const backend = backendModels.get(modelId);
const profile = getImportedModelProfile(modelId);
const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId];
+ const managed = account.id === 'niancode-user-models'
+ ? account.metadata?.worksSquareModelCapabilitiesV2?.models[modelId] ?? unknownManagedModelCapability()
+ : undefined;
const backendInput = Array.isArray(backend?.input)
? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image')
: [];
- const supportsImage = Boolean(
+ const supportsImage = managed ? managed.inputModalities?.includes('image') === true : Boolean(
summary?.supportsVision
|| profile?.modalities.input.includes('image')
|| backendInput.includes('image'),
);
- const contextWindow = finitePositiveInteger(summary?.contextWindow)
+ const contextWindow = finitePositiveInteger(managed?.limits?.contextWindow)
+ ?? finitePositiveInteger(summary?.contextWindow)
?? finitePositiveInteger(profile?.limit?.context)
?? finitePositiveInteger(backend?.contextWindow);
- const maxOutputTokens = finitePositiveInteger(profile?.limit?.output)
+ const maxOutputTokens = finitePositiveInteger(managed?.limits?.maxOutputTokens)
+ ?? finitePositiveInteger(profile?.limit?.output)
?? finitePositiveInteger(backend?.maxTokens);
return {
id: modelId,
name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId,
input: supportsImage ? ['text', 'image'] : ['text'],
- reasoning: serverCapability
+ ...(managed ? { managedCapability: managed } : {}),
+ reasoning: managed ? managed.reasoning.supported === true : serverCapability
? serverCapability.reasoningEfforts.length > 0
: summary?.supportsReasoning === true
|| profile?.pi?.reasoning === true
|| backend?.reasoning === true,
...(contextWindow ? { contextWindow } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
- ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability
+ ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability || managed
? {
compat: {
...compat,
...profile?.pi?.compat,
...enforcedCompat,
...(serverCapability ? { supportsReasoningEffort: true } : {}),
+ ...(managed ? {
+ supportsReasoningEffort: false,
+ thinkingFormat: managed.reasoning.controlFormat ?? undefined,
+ requiresReasoningContentOnAssistantMessages: managed.reasoning.controlFormat !== null,
+ } : {}),
},
}
: {}),
- ...(serverCapability
+ ...(managed ? {} : serverCapability
? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) }
: profile?.pi?.thinkingLevelMap
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
@@ -498,6 +512,7 @@ export function selectPiProviderModel(
modelId: model.id,
thinkingLevel: modelRef.thinkingLevel,
input: [...model.input],
+ ...(model.managedCapability ? { managedCapability: model.managedCapability } : {}),
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}),
};
diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts
index 57073f5..0e0900e 100644
--- a/electron/coding-runtime/pi/runtime.ts
+++ b/electron/coding-runtime/pi/runtime.ts
@@ -98,6 +98,7 @@ import {
} from './extension-ui-projector';
type RuntimeIdKind = 'run' | 'queue';
+import { buildManagedModelRequest, managedPiThinkingLevel, validateManagedReasoningChoice } from '../../../shared/managed-model-capabilities';
export interface PiConversationRuntimeOptions {
pool: PiWorkerPool;
@@ -306,6 +307,9 @@ export function createPiManagedWorkerOpener(
let extension;
try {
extension = await options.extensionHost.registerWorker({
+ ...(selection.managedCapability ? { managedModelRequest: buildManagedModelRequest(
+ model.modelId, model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability,
+ ) } : {}),
conversationId: input.conversation.conversationId,
generation: input.generation,
projectId: input.conversation.projectId,
@@ -796,6 +800,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
}
this.snapshot(input.conversationId);
+ const managedRequest = await this.prepareManagedRequest(input.conversationId, input.attachments.length > 0);
const images = await this.resolveImages(input.attachments);
const runId = this.id('run');
this.acquireRunBackgroundLease(input.conversationId, runId);
@@ -823,7 +828,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
let ticket;
try {
if (this.extensionHost && generation) {
- await this.extensionHost.bindRun(input.conversationId, generation, runId);
+ await this.extensionHost.bindRun(input.conversationId, generation, runId, managedRequest);
}
ticket = this.pool.startTopLevel({
conversationId: input.conversationId,
@@ -902,10 +907,12 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async validateModel(model: ProductModelRef): Promise {
const selection = await this.resolveModel(model);
+ if (selection.managedCapability) validateManagedReasoningChoice(model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability);
return {
accountId: selection.accountId,
modelId: selection.modelId,
thinkingLevel: model.thinkingLevel,
+ ...(selection.managedCapability ? { reasoningChoice: model.reasoningChoice ?? { mode: 'default' as const } } : {}),
};
}
@@ -924,6 +931,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
accountId: selection.accountId,
modelId: selection.modelId,
thinkingLevel,
+ ...(selection.managedCapability ? { reasoningChoice: { mode: 'default' as const } } : {}),
},
modelResolution: 'resolved',
};
@@ -981,6 +989,16 @@ export class PiConversationRuntime implements CodingConversationRuntime {
true,
);
}
+ const selection = current.model.accountId === 'niancode-user-models' ? await this.resolveModel(current.model) : undefined;
+ if (selection?.managedCapability) {
+ const choice = input.reasoningChoice ?? (input.thinkingLevel === 'off'
+ ? { mode: 'disabled' as const } : { mode: 'enabled' as const, effort: input.thinkingLevel });
+ validateManagedReasoningChoice(choice, selection.managedCapability);
+ await this.pool.request(input.conversationId, { type: 'set_thinking_level', level: managedPiThinkingLevel(choice) });
+ return this.persistEffectiveThinking(input.conversationId, {
+ model: { ...current.model, thinkingLevel: 'off', reasoningChoice: choice }, modelResolution: 'resolved',
+ }, {}, {}, true, true);
+ }
const capabilities = await this.pool.request<{ levels?: unknown }>(
input.conversationId,
{ type: 'get_available_thinking_levels' },
@@ -1025,6 +1043,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
async compact(conversationId: string): Promise {
await this.waitForProjection(conversationId);
this.assertNoUncertainMutation(conversationId);
+ const managedRequest = await this.prepareManagedRequest(conversationId, false);
const runId = this.id('run');
this.acquireRunBackgroundLease(conversationId, runId);
const generation = this.pool.getState(conversationId)?.generation;
@@ -1032,7 +1051,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
let ticket;
try {
if (this.extensionHost && generation) {
- await this.extensionHost.bindRun(conversationId, generation, runId);
+ await this.extensionHost.bindRun(conversationId, generation, runId, managedRequest);
}
ticket = this.pool.startTopLevel({
conversationId,
@@ -1317,6 +1336,7 @@ export class PiConversationRuntime implements CodingConversationRuntime {
if (snapshot.run.runId) {
this.acquireRunBackgroundLease(input.conversationId, snapshot.run.runId);
}
+ await this.prepareManagedRequest(input.conversationId, input.attachments.length > 0);
const images = await this.resolveImages(input.attachments);
const queuePosition = snapshot.queue.items.length + 1;
const queueId = this.id('queue');
@@ -1809,6 +1829,17 @@ export class PiConversationRuntime implements CodingConversationRuntime {
this.states.set(conversationId, createConversationReducerState(projected));
}
+ private async prepareManagedRequest(conversationId: string, hasImages: boolean) {
+ const model = this.snapshot(conversationId).conversation.model.model;
+ if (!model || model.accountId !== 'niancode-user-models') return undefined;
+ const selection = await this.resolveModel(model);
+ if (!selection.managedCapability) return undefined;
+ if (hasImages && !selection.input.includes('image')) {
+ throw new CodingRuntimeContractError('CODING_MODEL_UNAVAILABLE', '该模型尚未确认支持图片输入,请选择支持图片的模型', true);
+ }
+ return buildManagedModelRequest(model.modelId, model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability);
+ }
+
private async persistEffectiveThinking(
conversationId: string,
requested: ConversationModelState,
@@ -1818,6 +1849,19 @@ export class PiConversationRuntime implements CodingConversationRuntime {
forcePersist = false,
): Promise {
if (!requested.model) return clone(requested);
+ const selection = requested.model.accountId === 'niancode-user-models' ? await this.resolveModel(requested.model) : undefined;
+ if (selection?.managedCapability) {
+ const durable: ConversationModelState = {
+ model: { ...requested.model, thinkingLevel: 'off',
+ reasoningChoice: requested.model.reasoningChoice ?? { mode: 'default' } },
+ modelResolution: 'resolved',
+ };
+ const persisted = await this.registry.setModel(conversationId, durable);
+ this.pool.updateConversationModel(conversationId, persisted);
+ const result = { ...persisted, managedCapability: selection.managedCapability };
+ if (replaceSnapshot) this.replaceModel(conversationId, result);
+ return clone(result);
+ }
const effective = effectiveThinkingLevel(stateValue) ?? requested.model.thinkingLevel;
const available = availableThinkingLevels(capabilitiesValue);
if (!available.includes(effective)) available.push(effective);
diff --git a/electron/coding-runtime/pi/session-registry.ts b/electron/coding-runtime/pi/session-registry.ts
index 49eca6a..082165e 100644
--- a/electron/coding-runtime/pi/session-registry.ts
+++ b/electron/coding-runtime/pi/session-registry.ts
@@ -52,7 +52,8 @@ function sameModelState(left: ConversationModelState, right: ConversationModelSt
? left.model === right.model
: left.model.accountId === right.model.accountId
&& left.model.modelId === right.model.modelId
- && left.model.thinkingLevel === right.model.thinkingLevel);
+ && left.model.thinkingLevel === right.model.thinkingLevel
+ && JSON.stringify(left.model.reasoningChoice) === JSON.stringify(right.model.reasoningChoice));
}
export class PiSessionRegistry {
diff --git a/electron/coding-runtime/pi/subagent-child.ts b/electron/coding-runtime/pi/subagent-child.ts
index fb1a631..f343861 100644
--- a/electron/coding-runtime/pi/subagent-child.ts
+++ b/electron/coding-runtime/pi/subagent-child.ts
@@ -1,4 +1,5 @@
import type { ModelSummary, ProviderAccount } from '../../shared/providers/types';
+import { buildManagedModelRequest } from '../../../shared/managed-model-capabilities';
import { readCodingProjectConfigV2 } from '../../coding-projects/project-config';
import type { CodingProjectStore } from '../../coding-projects/project-store';
import type { PublicUsage } from '../contracts';
@@ -224,6 +225,9 @@ export function createPiManagedSubagentChildOpener(
: {}),
});
const extension = await options.extensionHost.registerWorker({
+ ...(selection.managedCapability ? { managedModelRequest: buildManagedModelRequest(
+ agent.model.modelId, agent.model.reasoningChoice ?? { mode: 'default' }, selection.managedCapability,
+ ) } : {}),
conversationId: input.conversationId,
generation: input.workerGeneration,
projectId: input.projectId,
diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts
index dec19e2..814f460 100644
--- a/electron/services/providers/provider-service.ts
+++ b/electron/services/providers/provider-service.ts
@@ -30,6 +30,7 @@ import {
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
import { logger } from '../../utils/logger';
import { normalizeImportedModelCapabilities } from '../../../shared/user-model-config';
+import { normalizeManagedModelCatalog } from '../../../shared/managed-model-capabilities';
function maskApiKey(apiKey: string | null): string | null {
if (!apiKey) return null;
@@ -76,6 +77,8 @@ function normalizeSyncedMetadata(metadata: Record): ProviderAcc
if (worksSquareModelCapabilities) {
result.worksSquareModelCapabilities = worksSquareModelCapabilities;
}
+ const v2 = normalizeManagedModelCatalog(metadata.worksSquareModelCapabilitiesV2);
+ if (v2) result.worksSquareModelCapabilitiesV2 = v2;
return result;
}
diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts
index 6994fbc..dbfd48b 100644
--- a/electron/shared/providers/types.ts
+++ b/electron/shared/providers/types.ts
@@ -136,6 +136,7 @@ export interface ProviderAccount {
resourceUrl?: string;
customModels?: string[];
worksSquareModelCapabilities?: ImportedModelCapabilities;
+ worksSquareModelCapabilitiesV2?: import('../../../shared/managed-model-capabilities').ManagedModelCatalog;
worksSquareCredentialMode?: string;
worksSquareCredentialExpiresAt?: string;
worksSquareOneApiBaseUrl?: string;
diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts
index 699d0cd..1e52995 100644
--- a/shared/coding-conversation-contracts.ts
+++ b/shared/coding-conversation-contracts.ts
@@ -1,4 +1,5 @@
import type { CapabilityResultV1 } from './data-service';
+import type { ManagedReasoningChoice, ManagedModelCapability } from './managed-model-capabilities';
import type { ModelToolDetailsV1 } from './model-tools';
import type { DevicePackageToolDetailsV1 } from './device-packages';
@@ -12,12 +13,14 @@ export interface ProductModelRef {
accountId: string;
modelId: string;
thinkingLevel: ConversationThinkingLevel;
+ reasoningChoice?: ManagedReasoningChoice;
}
export interface ConversationModelState {
model: ProductModelRef | null;
modelResolution: 'resolved' | 'required';
availableThinkingLevels?: ConversationThinkingLevel[];
+ managedCapability?: ManagedModelCapability;
}
export interface PublicUsage {
@@ -400,6 +403,7 @@ export interface SetConversationModelInput {
export interface SetThinkingLevelInput {
conversationId: string;
thinkingLevel: ConversationThinkingLevel;
+ reasoningChoice?: ManagedReasoningChoice;
}
export interface ForkConversationInput {
diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts
index d1f1a70..b3da7c9 100644
--- a/shared/coding-conversation-reducer.ts
+++ b/shared/coding-conversation-reducer.ts
@@ -114,6 +114,8 @@ function isPublicError(value: unknown): value is CodingRuntimePublicError {
&& typeof record.recoverable === 'boolean';
}
+import { parseManagedReasoningChoice } from './managed-model-capabilities';
+
function isModelState(value: unknown): boolean {
const record = asRecord(value);
if (!record || !['resolved', 'required'].includes(String(record.modelResolution))) return false;
@@ -125,6 +127,7 @@ function isModelState(value: unknown): boolean {
&& isNonEmptyString(model.accountId)
&& isNonEmptyString(model.modelId)
&& THINKING_LEVELS.has(String(model.thinkingLevel))
+ && (model.reasoningChoice === undefined || parseManagedReasoningChoice(model.reasoningChoice) !== null)
&& (record.availableThinkingLevels === undefined
|| (Array.isArray(record.availableThinkingLevels)
&& record.availableThinkingLevels.length > 0
diff --git a/shared/managed-model-capabilities.ts b/shared/managed-model-capabilities.ts
new file mode 100644
index 0000000..b58997a
--- /dev/null
+++ b/shared/managed-model-capabilities.ts
@@ -0,0 +1,193 @@
+import type { ImportedModelWebSearchCapability } from './imported-model-profile';
+import { normalizeImportedModelWebSearchCapability } from './user-model-config';
+
+function object(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {};
+}
+function field(value: Record, snake: string, camel: string): unknown {
+ return Object.hasOwn(value, snake) ? value[snake] : value[camel];
+}
+const bool = (v: unknown): boolean | null => typeof v === 'boolean' ? v : null;
+const str = (v: unknown): string | null => typeof v === 'string' && v.trim() ? v : null;
+const num = (v: unknown): number | null => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
+const strings = (v: unknown): string[] | null => Array.isArray(v) && v.every(x => typeof x === 'string' && x.trim())
+ ? [...new Set(v as string[])] : null;
+
+export function normalizeManagedModelCatalog(value: unknown, ids?: readonly string[]): ManagedModelCatalog | undefined {
+ const raw = object(value);
+ if (field(raw, 'schema_version', 'schemaVersion') !== 2) return undefined;
+ const models: ManagedModelCatalog['models'] = {};
+ for (const [id, item] of Object.entries(object(raw.models))) {
+ if (ids && !ids.includes(id)) continue;
+ const p = object(item), r = object(p.reasoning), b = object(r.budget), l = object(p.limits);
+ const format = field(r, 'control_format', 'controlFormat');
+ const status = field(p, 'resolution_status', 'resolutionStatus');
+ const webSearch = normalizeImportedModelWebSearchCapability(field(p, 'web_search', 'webSearch'));
+ models[id] = {
+ inputModalities: strings(field(p, 'input_modalities', 'inputModalities')),
+ outputModalities: strings(field(p, 'output_modalities', 'outputModalities')),
+ reasoning: {
+ supported: bool(r.supported), canDisable: bool(field(r, 'can_disable', 'canDisable')),
+ defaultEnabled: bool(field(r, 'default_enabled', 'defaultEnabled')),
+ effortValues: strings(field(r, 'effort_values', 'effortValues')),
+ defaultEffort: str(field(r, 'default_effort', 'defaultEffort')),
+ controlFormat: format === 'qwen' || format === 'deepseek' ? format : null,
+ budget: r.budget ? {
+ supported: bool(b.supported), minTokens: num(field(b, 'min_tokens', 'minTokens')),
+ maxTokens: num(field(b, 'max_tokens', 'maxTokens')), defaultTokens: num(field(b, 'default_tokens', 'defaultTokens')),
+ exclusiveWithEffort: bool(field(b, 'exclusive_with_effort', 'exclusiveWithEffort')),
+ } : null,
+ },
+ limits: p.limits ? {
+ contextWindow: num(field(l, 'context_window', 'contextWindow')),
+ maxInputTokens: num(field(l, 'max_input_tokens', 'maxInputTokens')),
+ maxOutputTokens: num(field(l, 'max_output_tokens', 'maxOutputTokens')),
+ } : null,
+ resolutionStatus: status === 'ready' || status === 'partial' || status === 'conflict' ? status : 'unknown',
+ issues: Array.isArray(p.issues) ? p.issues.flatMap(item => {
+ const i = object(item); return typeof i.field === 'string' && typeof i.code === 'string' ? [{ field: i.field, code: i.code }] : [];
+ }) : [],
+ updatedAt: str(field(p, 'updated_at', 'updatedAt')),
+ ...(webSearch ? { webSearch } : {}),
+ };
+ }
+ const status = field(raw, 'refresh_status', 'refreshStatus');
+ return { schemaVersion: 2, models, fetchedAt: str(field(raw, 'fetched_at', 'fetchedAt')),
+ refreshStatus: status === 'fresh' || status === 'stale' ? status : 'unavailable' };
+}
+
+export function parseManagedReasoningChoice(value: unknown): ManagedReasoningChoice | null {
+ const v = object(value);
+ if (v.mode === 'default' || v.mode === 'disabled') return { mode: v.mode };
+ if (v.mode === 'enabled' && (v.effort === undefined || typeof v.effort === 'string' && v.effort.trim())) {
+ return { mode: 'enabled', ...(typeof v.effort === 'string' ? { effort: v.effort } : {}) };
+ }
+ return null;
+}
+
+export type ManagedReasoningChoice =
+ | { mode: 'default' }
+ | { mode: 'disabled' }
+ | { mode: 'enabled'; effort?: string };
+
+export interface ManagedModelCapability {
+ inputModalities: string[] | null;
+ outputModalities: string[] | null;
+ reasoning: {
+ supported: boolean | null;
+ canDisable: boolean | null;
+ defaultEnabled: boolean | null;
+ effortValues: string[] | null;
+ defaultEffort: string | null;
+ controlFormat: 'qwen' | 'deepseek' | null;
+ budget: {
+ supported: boolean | null;
+ minTokens: number | null;
+ maxTokens: number | null;
+ defaultTokens: number | null;
+ exclusiveWithEffort: boolean | null;
+ } | null;
+ };
+ limits: {
+ contextWindow: number | null;
+ maxInputTokens: number | null;
+ maxOutputTokens: number | null;
+ } | null;
+ resolutionStatus: 'ready' | 'partial' | 'unknown' | 'conflict';
+ issues: { field: string; code: string }[];
+ updatedAt: string | null;
+ webSearch?: ImportedModelWebSearchCapability;
+}
+
+export interface ManagedModelCatalog {
+ schemaVersion: 2;
+ models: Record;
+ fetchedAt: string | null;
+ refreshStatus: 'fresh' | 'stale' | 'unavailable';
+}
+
+export interface ManagedModelRequest {
+ modelId: string;
+ choice: ManagedReasoningChoice;
+ reasoningFields: Record;
+}
+
+export function unknownManagedModelCapability(): ManagedModelCapability {
+ return {
+ inputModalities: null,
+ outputModalities: null,
+ reasoning: {
+ supported: null, canDisable: null, defaultEnabled: null,
+ effortValues: null, defaultEffort: null, controlFormat: null, budget: null,
+ },
+ limits: null,
+ resolutionStatus: 'unknown',
+ issues: [],
+ updatedAt: null,
+ };
+}
+
+export function managedReasoningOptions(capability: ManagedModelCapability): {
+ value: string; label: string; choice: ManagedReasoningChoice;
+}[] {
+ const options: ReturnType = [
+ { value: 'default', label: '模型默认', choice: { mode: 'default' } },
+ ];
+ const r = capability.reasoning;
+ if (r.supported !== true || !r.controlFormat) return options;
+ if (r.canDisable === true) {
+ options.push({ value: 'disabled', label: '关闭思考', choice: { mode: 'disabled' } });
+ options.push({ value: 'enabled', label: '开启(默认强度)', choice: { mode: 'enabled' } });
+ }
+ for (const effort of (r.canDisable === true || r.defaultEnabled === true) ? r.effortValues ?? [] : []) {
+ options.push({ value: 'effort:' + effort, label: effort, choice: { mode: 'enabled', effort } });
+ }
+ return options;
+}
+
+export function managedReasoningChoiceKey(choice: ManagedReasoningChoice): string {
+ return choice.mode === 'enabled' && choice.effort !== undefined
+ ? 'effort:' + choice.effort : choice.mode;
+}
+
+export function validateManagedReasoningChoice(
+ choice: ManagedReasoningChoice,
+ capability: ManagedModelCapability,
+): void {
+ const key = managedReasoningChoiceKey(choice);
+ if (!managedReasoningOptions(capability).some((option) => option.value === key)) {
+ throw new Error('该模型的思考选项已不可用,请刷新模型配置后重新选择');
+ }
+}
+
+export function buildManagedModelRequest(
+ modelId: string,
+ choice: ManagedReasoningChoice,
+ capability: ManagedModelCapability,
+): ManagedModelRequest {
+ validateManagedReasoningChoice(choice, capability);
+ const fields: Record = {};
+ const r = capability.reasoning;
+ if (choice.mode !== 'default') {
+ if (r.canDisable === true) {
+ if (r.controlFormat === 'qwen') fields.enable_thinking = choice.mode === 'enabled';
+ else fields.thinking = { type: choice.mode === 'enabled' ? 'enabled' : 'disabled' };
+ }
+ if (choice.mode === 'enabled' && choice.effort !== undefined) {
+ fields.reasoning_effort = choice.effort;
+ }
+ }
+ return { modelId, choice: { ...choice }, reasoningFields: fields };
+}
+
+// Pi's fixed enum is an execution detail. The request hook applies the validated
+// native choice; this carrier must never be persisted as the product selection.
+export function managedPiThinkingLevel(choice: ManagedReasoningChoice):
+ 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' {
+ if (choice.mode !== 'enabled') return 'off';
+ switch (choice.effort) {
+ case 'minimal': case 'low': case 'medium': case 'high': case 'xhigh': case 'max':
+ return choice.effort;
+ default: return 'low';
+ }
+}
diff --git a/shared/user-model-config.ts b/shared/user-model-config.ts
index 2d7efd0..0a5ae52 100644
--- a/shared/user-model-config.ts
+++ b/shared/user-model-config.ts
@@ -51,7 +51,7 @@ function normalizeImportedModelCapability(value: unknown): ImportedModelCapabili
};
}
-function normalizeImportedModelWebSearchCapability(
+export function normalizeImportedModelWebSearchCapability(
value: unknown,
): ImportedModelWebSearchCapability | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
diff --git a/src/lib/coding-conversations.ts b/src/lib/coding-conversations.ts
index 108d685..b2b3b1f 100644
--- a/src/lib/coding-conversations.ts
+++ b/src/lib/coding-conversations.ts
@@ -81,10 +81,11 @@ export async function setCodingConversationModel(
export async function setCodingConversationThinking(
conversationId: string,
thinkingLevel: ConversationThinkingLevel,
+ reasoningChoice?: import('../../shared/managed-model-capabilities').ManagedReasoningChoice,
): Promise {
const response = await hostApiFetch<{ model: ConversationModelState }>(
`/api/coding/conversations/${encodeURIComponent(conversationId)}/thinking`,
- { method: 'POST', body: JSON.stringify({ thinkingLevel }) },
+ { method: 'POST', body: JSON.stringify({ thinkingLevel, reasoningChoice }) },
);
return response.model;
}
diff --git a/src/lib/coding-model-options.ts b/src/lib/coding-model-options.ts
index 630cf42..9f1d849 100644
--- a/src/lib/coding-model-options.ts
+++ b/src/lib/coding-model-options.ts
@@ -1,6 +1,7 @@
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
import type { ConversationThinkingLevel, ProductModelRef } from '@/types/coding-conversation';
import { getImportedModelProfile } from '../../shared/imported-model-profile';
+import { unknownManagedModelCapability, type ManagedModelCapability } from '../../shared/managed-model-capabilities';
export interface CodingModelOption {
key: string;
@@ -8,6 +9,7 @@ export interface CodingModelOption {
modelId: string;
label: string;
availableThinkingLevels: ConversationThinkingLevel[] | null;
+ managedCapability?: ManagedModelCapability;
}
const STANDARD_THINKING_LEVELS: readonly ConversationThinkingLevel[] = [
@@ -23,6 +25,7 @@ function availableThinkingLevels(
account: ProviderAccount,
modelId: string,
): ConversationThinkingLevel[] | null {
+ if (account.id === 'niancode-user-models') return [];
const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId];
if (serverCapability) {
return [
@@ -85,6 +88,8 @@ export function buildCodingModelOptions(
modelId,
label: `${account.label}${vendor && vendor !== account.label ? ` · ${vendor}` : ''} / ${modelId}`,
availableThinkingLevels: availableThinkingLevels(account, modelId),
+ ...(account.id === 'niancode-user-models' ? { managedCapability:
+ account.metadata?.worksSquareModelCapabilitiesV2?.models[modelId] ?? unknownManagedModelCapability() } : {}),
});
}
}
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
index 1b9be96..308b4e8 100644
--- a/src/lib/providers.ts
+++ b/src/lib/providers.ts
@@ -128,6 +128,7 @@ export interface ProviderAccount {
resourceUrl?: string;
customModels?: string[];
worksSquareModelCapabilities?: ImportedModelCapabilities;
+ worksSquareModelCapabilitiesV2?: import('../../shared/managed-model-capabilities').ManagedModelCatalog;
worksSquareCredentialMode?: string;
worksSquareCredentialExpiresAt?: string;
};
diff --git a/src/pages/Chat/CodingComposer.tsx b/src/pages/Chat/CodingComposer.tsx
index 1ea73a9..d84fa95 100644
--- a/src/pages/Chat/CodingComposer.tsx
+++ b/src/pages/Chat/CodingComposer.tsx
@@ -1,5 +1,6 @@
import { LoaderCircle, Mic, Plus, RotateCcw, Send, Square, X } from 'lucide-react';
import { useEffect, useRef } from 'react';
+import { useProviderStore } from '@/stores/providers';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
@@ -69,6 +70,12 @@ export function CodingComposer({
onRemoveAttachment,
onRefreshRuntime,
}: CodingComposerProps) {
+ const accounts = useProviderStore(state => state.accounts);
+ const selectedModel = snapshot?.conversation.model.model ?? conversation?.model;
+ const managed = selectedModel?.accountId === 'niancode-user-models';
+ const capability = selectedModel ? accounts.find(account => account.id === selectedModel.accountId)
+ ?.metadata?.worksSquareModelCapabilitiesV2?.models[selectedModel.modelId] : undefined;
+ const acceptsImages = !managed || capability?.inputModalities?.includes('image') === true;
const fileInputRef = useRef(null);
const latestValueRef = useRef(value);
useEffect(() => {
@@ -149,7 +156,7 @@ export function CodingComposer({
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
multiple
- disabled={!editable || submitting}
+ disabled={!editable || submitting || !acceptsImages}
className="sr-only"
data-testid="coding-file-attachment-input"
onChange={(event) => {
@@ -193,7 +200,7 @@ export function CodingComposer({
if (canSend) onSubmit();
}}
onPaste={(event) => {
- if (submitting) return;
+ if (submitting || !acceptsImages) return;
const files = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith('image/'));
if (files.length > 0) onAddFiles(files);
}}
@@ -204,8 +211,9 @@ export function CodingComposer({
type="button"
variant="ghost"
className="h-8 w-8 shrink-0 rounded-full p-0"
- disabled={!editable || submitting}
+ disabled={!editable || submitting || !acceptsImages}
aria-label="添加图片"
+ title={acceptsImages ? '添加图片' : '该模型尚未确认支持图片输入'}
onClick={() => fileInputRef.current?.click()}
>
diff --git a/src/pages/Chat/CodingComposerRuntimeControls.tsx b/src/pages/Chat/CodingComposerRuntimeControls.tsx
index f582905..784a34f 100644
--- a/src/pages/Chat/CodingComposerRuntimeControls.tsx
+++ b/src/pages/Chat/CodingComposerRuntimeControls.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
+import { managedReasoningChoiceKey, managedReasoningOptions } from '../../../shared/managed-model-capabilities';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import {
Check,
@@ -84,12 +85,16 @@ export function CodingComposerRuntimeControls({
const model = snapshot?.conversation.model.model ?? conversation.model ?? null;
const modelValue = model ? codingModelKey(model) : '';
const hasCurrentOption = options.some((option) => option.key === modelValue);
+ const managedCapability = options.find(option => option.key === modelValue)?.managedCapability
+ ?? snapshot?.conversation.model.managedCapability;
+ const managedOptions = managedCapability ? managedReasoningOptions(managedCapability) : undefined;
const thinkingLevel = model?.thinkingLevel ?? 'off';
+ const thinkingValue = managedOptions ? managedReasoningChoiceKey(model?.reasoningChoice ?? { mode: 'default' }) : thinkingLevel;
const availableThinkingLevels = snapshot?.conversation.model.availableThinkingLevels;
- const thinkingOptions = availableThinkingLevels
+ const thinkingOptions = managedOptions ?? (availableThinkingLevels
? THINKING_OPTIONS.filter((option) => availableThinkingLevels.includes(option.value))
- : THINKING_OPTIONS;
- const thinkingIsUnavailable = availableThinkingLevels?.every((level) => level === 'off') ?? false;
+ : THINKING_OPTIONS);
+ const thinkingIsUnavailable = managedOptions ? false : availableThinkingLevels?.every((level) => level === 'off') ?? false;
const runStatus = snapshot?.run.status ?? 'preparing';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const runtimeErrorCode = snapshot?.run.error?.code ?? snapshot?.worker.error?.code;
@@ -106,7 +111,9 @@ export function CodingComposerRuntimeControls({
: actionError;
const runtimeControlsDisabled = Boolean(busyAction) || running;
const modelLabel = model?.modelId ?? '选择模型';
- const currentThinkingLabel = thinkingLabel(thinkingLevel);
+ const currentThinkingLabel = managedOptions
+ ? managedOptions.find(option => option.value === thinkingValue)?.label ?? '请重新选择'
+ : thinkingLabel(thinkingLevel);
const thinkingDisplayLabel = thinkingIsUnavailable ? '不可调' : currentThinkingLabel;
const perform = (key: string, action: () => Promise) => {
@@ -133,6 +140,15 @@ export function CodingComposerRuntimeControls({
};
const selectThinking = (value: string) => {
+ if (managedOptions) {
+ const option = managedOptions.find(option => option.value === value);
+ if (!option || value === thinkingValue) return;
+ perform('thinking', async () => {
+ await setCodingConversationThinking(conversation.id, 'off', option.choice);
+ await onRefresh();
+ });
+ return;
+ }
const selected = value as ConversationThinkingLevel;
if (selected === thinkingLevel) return;
perform('thinking', async () => {
@@ -266,7 +282,7 @@ export function CodingComposerRuntimeControls({
className="z-[101] min-w-44 rounded-[14px] border border-border/80 bg-background p-1.5 text-sm shadow-[0_18px_50px_rgba(26,31,42,0.18)] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1"
aria-label="选择推理强度"
>
-
+
{thinkingOptions.map((option) => (
{model.modelId}
可选思考强度
- {model.availableThinkingLevels === null
+ {model.managedCapability
+ ?
+ {model.managedCapability.inputModalities === null ? '输入能力未知' : model.managedCapability.inputModalities.includes('image') ? '支持图片输入' : '文本输入'}
+ {' · '}{model.managedCapability.reasoning.effortValues?.join(' / ') || (model.managedCapability.reasoning.canDisable ? '支持思考开关' : '模型默认')}
+
+ : model.availableThinkingLevels === null
? 进入对话后可查看
: model.availableThinkingLevels.length === 0
|| model.availableThinkingLevels.every((level) => level === 'off')
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts
index bf45922..0e7d1a1 100644
--- a/tests/e2e/pi-coding-first-chat.spec.ts
+++ b/tests/e2e/pi-coding-first-chat.spec.ts
@@ -81,9 +81,10 @@ async function installCodingFirstChatHost(
electronApp: ElectronApplication,
hostConnection: HostConnection,
featureComplete = false,
+ managedCapabilities = false,
): Promise {
await electronApp.evaluate(async (_, payload) => {
- const { connection, featureComplete } = payload;
+ const { connection, featureComplete, managedCapabilities } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
@@ -116,7 +117,7 @@ async function installCodingFirstChatHost(
lastOpenedAt: now,
};
const configuredModel = {
- accountId: 'account-e2e',
+ accountId: managedCapabilities ? 'niancode-user-models' : 'account-e2e',
modelId: 'model-a',
thinkingLevel: 'off',
};
@@ -516,12 +517,20 @@ async function installCodingFirstChatHost(
}
if (path === '/api/provider-accounts') {
return respond([{
- id: 'account-e2e',
+ id: managedCapabilities ? 'niancode-user-models' : 'account-e2e',
vendorId: 'custom',
label: 'E2E account',
authMode: 'api_key',
model: 'model-a',
fallbackModels: ['model-b'],
+ ...(managedCapabilities ? { metadata: { worksSquareModelCapabilitiesV2: {
+ schemaVersion: 2, fetchedAt: now, refreshStatus: 'fresh', models: {
+ 'model-a': { inputModalities: ['text'], outputModalities: ['text'],
+ reasoning: { supported: true, canDisable: true, defaultEnabled: true,
+ effortValues: ['xhigh', 'medium', 'low'], defaultEffort: 'xhigh', controlFormat: 'qwen', budget: null },
+ limits: null, resolutionStatus: 'ready', issues: [], updatedAt: now },
+ },
+ } } } : {}),
enabled: true,
isDefault: true,
createdAt: now,
@@ -703,7 +712,7 @@ async function installCodingFirstChatHost(
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
- }, { connection: hostConnection, featureComplete });
+ }, { connection: hostConnection, featureComplete, managedCapabilities });
}
async function readState(electronApp: ElectronApplication): Promise<{
@@ -744,6 +753,36 @@ async function settleSnapshot(electronApp: ElectronApplication): Promise {
});
}
+test('managed capabilities expose native xhigh and block unsupported image input', async ({ launchElectronApp }) => {
+ const electronApp = await launchElectronApp({ skipSetup: true });
+ let page = await getStableWindow(electronApp);
+ const connection = await page.evaluate(async () => ({
+ token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
+ baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
+ }));
+ await installCodingFirstChatHost(electronApp, connection, true, true);
+ await settleSnapshot(electronApp);
+ await disableCodingEventSource(page);
+ try {
+ await page.reload();
+ page = await getStableWindow(electronApp);
+ await page.getByTestId('ai-module-option-programming').click();
+ await expect(page.getByTestId('main-layout')).toBeVisible();
+ await page.evaluate(() => { window.location.hash = '/chat'; });
+ await expect(page.getByRole('button', { name: '添加图片', exact: true })).toBeDisabled();
+ const controls = page.getByTestId('coding-composer-runtime-controls');
+ await expect(controls).toContainText('模型默认');
+ await controls.getByRole('button').click();
+ await page.getByRole('menuitem', { name: /推理强度/ }).click();
+ await expect(page.getByRole('menuitemradio', { name: '模型默认', exact: true })).toBeVisible();
+ await expect(page.getByRole('menuitemradio', { name: '关闭思考', exact: true })).toBeVisible();
+ await page.getByRole('menuitemradio', { name: 'xhigh', exact: true }).click();
+ await expect.poll(async () => (await readState(electronApp)).captured.find(
+ request => request.path.endsWith('/thinking') && request.method === 'POST')?.body?.reasoningChoice,
+ ).toEqual({ mode: 'enabled', effort: 'xhigh' });
+ } finally { await releaseSnapshot(electronApp); }
+});
+
test('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({
launchElectronApp,
}) => {
diff --git a/tests/unit/managed-model-capabilities.test.ts b/tests/unit/managed-model-capabilities.test.ts
new file mode 100644
index 0000000..8e915d4
--- /dev/null
+++ b/tests/unit/managed-model-capabilities.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment node
+import { createServer } from 'node:http';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { describe, expect, it, vi } from 'vitest';
+import { streamSimple } from '@earendil-works/pi-ai/api/openai-completions';
+import type { Model } from '@earendil-works/pi-ai';
+import {
+ buildManagedModelRequest, managedPiThinkingLevel, managedReasoningOptions,
+ unknownManagedModelCapability,
+ type ManagedModelCapability, type ManagedReasoningChoice,
+} from '../../shared/managed-model-capabilities';
+import { materializeMakelorePiExtension } from '../../electron/coding-runtime/pi/extensions/makelore-runtime';
+
+function capability(format: 'qwen' | 'deepseek'): ManagedModelCapability {
+ return {
+ ...unknownManagedModelCapability(),
+ inputModalities: ['text', 'image'], outputModalities: ['text'], resolutionStatus: 'ready',
+ reasoning: {
+ supported: true, canDisable: true, defaultEnabled: true,
+ effortValues: ['low', 'medium', 'xhigh', 'max', 'future-native'],
+ defaultEffort: 'low', controlFormat: format, budget: null,
+ },
+ };
+}
+
+describe('managed model reasoning', () => {
+ it('distinguishes unknown, switch-only and always-on capabilities', () => {
+ expect(managedReasoningOptions(unknownManagedModelCapability()).map((x) => x.value)).toEqual(['default']);
+ const profile = capability('qwen');
+ profile.reasoning.effortValues = [];
+ expect(managedReasoningOptions(profile).map((x) => x.value)).toEqual(['default', 'disabled', 'enabled']);
+ profile.reasoning.canDisable = false;
+ profile.reasoning.effortValues = ['max'];
+ expect(managedReasoningOptions(profile).map((x) => x.value)).toEqual(['default', 'effort:max']);
+ expect(buildManagedModelRequest('model', { mode: 'enabled', effort: 'max' }, profile).reasoningFields)
+ .toEqual({ reasoning_effort: 'max' });
+ expect(() => buildManagedModelRequest('model', { mode: 'disabled' }, profile)).toThrow('不可用');
+ expect(() => buildManagedModelRequest('model', { mode: 'enabled', effort: 'high' }, profile)).toThrow('不可用');
+ });
+
+ it.each(['qwen', 'deepseek'] as const)('sends exact %s controls through Pi and the bundled extension', async (format) => {
+ const root = await mkdtemp(path.join(tmpdir(), 'makelore-managed-wire-'));
+ const bodies: Record[] = [];
+ const server = createServer(async (request, response) => {
+ let raw = '';
+ for await (const chunk of request) raw += String(chunk);
+ bodies.push(JSON.parse(raw));
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
+ response.end('data: ' + JSON.stringify({
+ id: 'test-response', object: 'chat.completion.chunk', created: 0, model: 'catalog-model',
+ choices: [{ index: 0, delta: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ }) + '\n\ndata: [DONE]\n\n');
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ try {
+ const address = server.address();
+ if (!address || typeof address === 'string') throw new Error('No test server address');
+ const contextFile = path.join(root, 'worker.json');
+ const profile = capability(format);
+ await writeFile(contextFile, JSON.stringify({ runId: 'test-run', tools: [], allowedToolNames: [] }));
+ const extensionPath = await materializeMakelorePiExtension(root);
+ const extension = await import(/* @vite-ignore */ pathToFileURL(extensionPath).href);
+ const hooks = new Map Promise>();
+ await extension.createMakeloreRuntime({ contextFile })({
+ registerFlag: vi.fn(), registerTool: vi.fn(),
+ on: (name: string, handler: (event: { payload: unknown }) => Promise) => hooks.set(name, handler),
+ });
+ const beforeRequest = hooks.get('before_provider_request');
+ expect(beforeRequest).toBeTypeOf('function');
+ const model: Model<'openai-completions'> = {
+ id: 'catalog-model', name: 'Catalog model', api: 'openai-completions',
+ provider: 'managed-test', baseUrl: 'http://127.0.0.1:' + address.port + '/v1',
+ reasoning: true, input: ['text', 'image'], contextWindow: 4096, maxTokens: 512,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ thinkingLevelMap: { off: 'off', low: 'low', medium: 'medium', xhigh: 'xhigh', max: 'max' },
+ compat: { thinkingFormat: format, supportsReasoningEffort: true },
+ };
+ const choices: ManagedReasoningChoice[] = [
+ { mode: 'default' }, { mode: 'disabled' }, { mode: 'enabled' },
+ ...['low', 'medium', 'xhigh', 'max', 'future-native'].map((effort) => ({ mode: 'enabled' as const, effort })),
+ ];
+ for (const choice of choices) {
+ const prepared = buildManagedModelRequest(model.id, choice, profile);
+ await writeFile(contextFile, JSON.stringify({
+ runId: 'test-run', tools: [], allowedToolNames: [], managedModelRequest: prepared,
+ }));
+ const result = await streamSimple(model, {
+ messages: [{ role: 'user', content: 'test', timestamp: 0 }],
+ }, {
+ apiKey: 'synthetic-key', reasoning: managedPiThinkingLevel(choice),
+ onPayload: (payload) => beforeRequest!({ payload }),
+ }).result();
+ expect(result.stopReason, result.errorMessage).not.toBe('error');
+ const body = bodies.at(-1)!;
+ for (const field of ['thinking', 'enable_thinking', 'reasoning_effort', 'thinking_budget']) {
+ expect(body[field], field + ' for ' + JSON.stringify(choice)).toEqual(prepared.reasoningFields[field]);
+ }
+ expect(body.model).toBe(model.id);
+ expect(body.messages).toEqual([{ role: 'user', content: 'test' }]);
+ }
+ expect(bodies).toHaveLength(choices.length);
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/unit/pi-conversation-runtime.test.ts b/tests/unit/pi-conversation-runtime.test.ts
index 9c492e0..ec5d375 100644
--- a/tests/unit/pi-conversation-runtime.test.ts
+++ b/tests/unit/pi-conversation-runtime.test.ts
@@ -28,6 +28,7 @@ import type {
PiRpcResponse,
} from '../../electron/coding-runtime/pi/rpc-client';
import { PI_084_TEXT_TURN } from '../fixtures/pi-0.84.2-projector-fixtures';
+import { unknownManagedModelCapability, type ManagedModelRequest } from '../../shared/managed-model-capabilities';
const roots: string[] = [];
const NOW = '2026-08-22T15:00:00.000Z';
@@ -152,9 +153,11 @@ class RuntimeFakeWorker implements PiConversationWorker {
}
class TrackingExtensionHost extends PiManagedExtensionHost {
+ managedRequest?: ManagedModelRequest;
readonly runs = new Map();
- override async bindRun(conversationId: string, generation: number, runId: string): Promise {
+ override async bindRun(conversationId: string, generation: number, runId: string, request?: ManagedModelRequest): Promise {
+ this.managedRequest = request;
this.runs.set(conversationId, { generation, runId });
}
@@ -168,6 +171,57 @@ afterEach(async () => {
});
describe('Pi Conversation runtime', () => {
+ it('persists native managed choices and rejects removed effort or unsupported images before prompt', async () => {
+ const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-managed-runtime-'));
+ roots.push(projectPath);
+ const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
+ createId: () => 'project-managed', now: () => NOW,
+ });
+ await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
+ const model = { accountId: 'niancode-user-models', modelId: 'unfamiliar-model', thinkingLevel: 'high' as const };
+ await createCodingProjectAgent(projectPath, {
+ id: 'agent-a', avatarId: 'avatar-01', roleName: 'Implementer', name: 'Agent A',
+ model, modelResolution: 'resolved',
+ responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
+ }, { now: NOW });
+ const store = createCodingConversationStore(projectPath);
+ const conversation = await store.create({ agentId: 'agent-a', title: 'Managed', model, modelResolution: 'resolved' });
+ let worker: RuntimeFakeWorker;
+ const pool = new PiWorkerPool({ openWorker: async ({generation}) => {
+ worker = new RuntimeFakeWorker('managed-worker', generation);
+ return { worker, session: { piSessionId: 'session', sessionKey: 'key' } };
+ } });
+ const capability = unknownManagedModelCapability();
+ capability.inputModalities = ['text'];
+ capability.reasoning = { supported: true, canDisable: true, defaultEnabled: true,
+ controlFormat: 'qwen', effortValues: ['xhigh', 'future-native'], defaultEffort: 'xhigh', budget: null };
+ const host = new TrackingExtensionHost();
+ const runtime = new PiConversationRuntime({
+ pool, registry: new PiSessionRegistry({ projectStore }), extensionHost: host,
+ resolveImages: async () => [],
+ resolveModel: async candidate => ({ ...candidate, input: ['text'],
+ runtimeProviderId: 'managed', managedCapability: structuredClone(capability) }),
+ });
+ try {
+ await runtime.prepare({ conversationId: conversation.id, projectId: 'project-managed', agentId: 'agent-a',
+ title: conversation.title, model: { model, modelResolution: 'resolved' } });
+ expect((await runtime.getSnapshot(conversation.id)).conversation.model.model?.reasoningChoice).toEqual({ mode: 'default' });
+ await runtime.setThinking({ conversationId: conversation.id, thinkingLevel: 'off',
+ reasoningChoice: { mode: 'enabled', effort: 'future-native' } });
+ expect((await store.get(conversation.id))?.model?.reasoningChoice).toEqual({ mode: 'enabled', effort: 'future-native' });
+ capability.reasoning.effortValues = ['xhigh'];
+ const prompt = { conversationId: conversation.id, clientRequestId: 'request-managed', mode: 'prompt' as const, text: 'Hello', attachments: [] };
+ await expect(runtime.prompt(prompt)).rejects.toThrow('思考选项已不可用');
+ expect(worker!.requests.filter(r => r.type === 'prompt')).toHaveLength(0);
+ await runtime.setThinking({ conversationId: conversation.id, thinkingLevel: 'off', reasoningChoice: { mode: 'default' } });
+ await expect(runtime.prompt({ ...prompt, attachments: [{ attachmentId: 'image' }] })).rejects.toThrow('图片输入');
+ await runtime.prompt(prompt);
+ expect(host.managedRequest).toEqual({ modelId: 'unfamiliar-model', choice: { mode: 'default' }, reasoningFields: {} });
+ for (const event of PI_084_TEXT_TURN.events) worker!.emit(structuredClone(event));
+ await runtime.getSnapshot(conversation.id);
+ } finally { await runtime.shutdown(); }
+ });
+
it('separates RPC acceptance from settle and changes only the target Conversation model', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-runtime-'));
roots.push(projectPath);
diff --git a/tests/unit/pi-provider-config.test.ts b/tests/unit/pi-provider-config.test.ts
index b21dc42..e7d68e3 100644
--- a/tests/unit/pi-provider-config.test.ts
+++ b/tests/unit/pi-provider-config.test.ts
@@ -1,3 +1,4 @@
+import { normalizeManagedModelCatalog } from '../../shared/managed-model-capabilities';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -107,23 +108,10 @@ describe('Pi Provider catalog', () => {
'X-Works-Square-AI-Token',
]);
expect(descriptor.models[0]).toMatchObject({
- id: 'qwen3.6-plus',
- input: ['text', 'image'],
- reasoning: true,
- contextWindow: 1_000_000,
- maxOutputTokens: 65_536,
- thinkingLevelMap: {
- minimal: null,
- low: null,
- medium: null,
- high: 'high',
- },
- compat: {
- thinkingFormat: 'qwen',
- supportsDeveloperRole: false,
- supportsReasoningEffort: false,
- supportsStore: false,
- },
+ id: 'qwen3.6-plus', input: ['text'], reasoning: false,
+ contextWindow: 1_000_000, maxOutputTokens: 65_536,
+ managedCapability: { resolutionStatus: 'unknown' },
+ compat: { supportsDeveloperRole: false, supportsReasoningEffort: false, supportsStore: false },
});
expect(catalog.modelsFile.providers[descriptor.runtimeProviderId]?.models[0]?.compat)
.toMatchObject({ supportsDeveloperRole: false });
@@ -170,48 +158,6 @@ describe('Pi Provider catalog', () => {
expect(descriptor.models[0]?.compat?.supportsDeveloperRole).toBe(false);
});
- it('projects Qwen3.8 Max reasoning effort levels into Pi models.json', () => {
- const provider = account({
- id: 'niancode-user-models',
- vendorId: 'custom',
- apiProtocol: 'openai-completions',
- baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1',
- model: 'qwen3.8-max',
- metadata: {
- worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
- customModels: ['qwen3.8-max'],
- },
- });
-
- const catalog = buildPiProviderCatalog({ accounts: [provider] });
- const descriptor = catalog.descriptors[0]!.models[0]!;
- const written = catalog.modelsFile.providers[resolvePiRuntimeProviderId(provider.id)]!.models[0]!;
-
- expect(descriptor).toMatchObject({
- id: 'qwen3.8-max',
- input: ['text', 'image'],
- reasoning: true,
- contextWindow: 1_000_000,
- maxOutputTokens: 131_072,
- thinkingLevelMap: {
- minimal: null,
- low: 'low',
- medium: 'medium',
- high: 'xhigh',
- },
- compat: {
- thinkingFormat: 'qwen',
- supportsDeveloperRole: false,
- supportsReasoningEffort: true,
- supportsStore: false,
- },
- });
- expect(written).toMatchObject({
- reasoning: true,
- thinkingLevelMap: descriptor.thinkingLevelMap,
- compat: descriptor.compat,
- });
- });
it('uses account-scoped model capability metadata and rejects unavailable models', () => {
const provider = account({ id: 'account-with-vision', model: 'vision-model' });
@@ -244,165 +190,40 @@ describe('Pi Provider catalog', () => {
})).toThrowError(PiProviderConfigError);
});
- it('projects the managed DeepSeek capability contract into Pi models.json', () => {
+
+
+
+
+ it.each(['qwen', 'deepseek'] as const)('uses v2 %s capabilities for unfamiliar models and native effort values', format => {
const provider = account({
- id: 'niancode-user-models',
- vendorId: 'custom',
- apiProtocol: 'openai-completions',
- baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1',
- model: 'deepseek/deepseek-v4-pro',
+ id: 'niancode-user-models', vendorId: 'custom', apiProtocol: 'openai-completions',
+ model: 'future-model', baseUrl: 'https://gateway.test/v1',
metadata: {
- worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
- customModels: ['deepseek/deepseek-v4-pro'],
+ worksSquareModelCapabilitiesV2: normalizeManagedModelCatalog({
+ schema_version: 2, models: { 'future-model': {
+ input_modalities: ['text', 'image'], output_modalities: ['text'],
+ reasoning: { supported: true, can_disable: true, default_enabled: true,
+ control_format: format, effort_values: ['low', 'medium', 'xhigh', 'max', 'future'] },
+ limits: { context_window: 123456, max_output_tokens: 1234 }, resolution_status: 'ready',
+ } },
+ }),
},
});
-
const catalog = buildPiProviderCatalog({ accounts: [provider] });
const descriptor = catalog.descriptors[0]!.models[0]!;
- const written = catalog.modelsFile.providers[resolvePiRuntimeProviderId(provider.id)]!.models[0]!;
-
expect(descriptor).toMatchObject({
- id: 'deepseek-v4-pro',
- reasoning: true,
- contextWindow: 1_000_000,
- maxOutputTokens: 384_000,
- compat: {
- thinkingFormat: 'deepseek',
- requiresReasoningContentOnAssistantMessages: true,
- },
- thinkingLevelMap: {
- minimal: null,
- low: 'low',
- medium: null,
- high: 'high',
- max: 'max',
- },
- });
- expect(written).toMatchObject({
- reasoning: true,
- thinkingLevelMap: descriptor.thinkingLevelMap,
- compat: descriptor.compat,
- });
- });
-
- it('uses server reasoning capabilities while retaining local model metadata', () => {
- const provider = account({
- id: 'niancode-user-models',
- vendorId: 'custom',
- apiProtocol: 'openai-completions',
- baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1',
- model: 'qwen3.8-max',
- metadata: {
- worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
- customModels: ['qwen3.8-max'],
- worksSquareModelCapabilities: {
- 'qwen3.8-max': {
- reasoningEfforts: ['low'],
- reasoningCanDisable: false,
- },
- },
- },
- });
-
- const descriptor = buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!.models[0]!;
-
- expect(descriptor).toMatchObject({
- id: 'qwen3.8-max',
- input: ['text', 'image'],
- reasoning: true,
- contextWindow: 1_000_000,
- maxOutputTokens: 131_072,
- thinkingLevelMap: {
- off: null,
- minimal: null,
- low: 'low',
- medium: null,
- high: null,
- max: null,
- },
- compat: {
- thinkingFormat: 'qwen',
- supportsDeveloperRole: false,
- supportsReasoningEffort: true,
- supportsStore: false,
- },
- });
- });
-
- it('enables reasoning-effort serialization for a server model without a local profile', () => {
- const provider = account({
- id: 'niancode-user-models',
- vendorId: 'custom',
- apiProtocol: 'openai-completions',
- baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1',
- model: 'future-reasoning-model',
- metadata: {
- worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
- customModels: ['future-reasoning-model'],
- worksSquareModelCapabilities: {
- 'future-reasoning-model': {
- reasoningEfforts: ['low', 'high', 'max'],
- reasoningCanDisable: true,
- },
- },
- },
- });
-
- const descriptor = buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!.models[0]!;
-
- expect(descriptor).toMatchObject({
- id: 'future-reasoning-model',
- reasoning: true,
- compat: {
- supportsDeveloperRole: false,
- supportsReasoningEffort: true,
- },
- thinkingLevelMap: {
- minimal: null,
- low: 'low',
- medium: null,
- high: 'high',
- max: 'max',
- },
- });
- expect(descriptor.thinkingLevelMap).not.toHaveProperty('off');
- });
-
- it('lets an explicit empty server effort list override a locally reasoning model', () => {
- const provider = account({
- id: 'niancode-user-models',
- vendorId: 'custom',
- apiProtocol: 'openai-completions',
- baseUrl: 'http://127.0.0.1:54321/api/ai-proxy/v1',
- model: 'deepseek-v4-pro',
- metadata: {
- worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
- customModels: ['deepseek-v4-pro'],
- worksSquareModelCapabilities: {
- 'deepseek-v4-pro': {
- reasoningEfforts: [],
- reasoningCanDisable: false,
- },
- },
- },
- });
-
- const descriptor = buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!.models[0]!;
-
- expect(descriptor.reasoning).toBe(false);
- expect(descriptor.thinkingLevelMap).toEqual({
- off: null,
- minimal: null,
- low: null,
- medium: null,
- high: null,
- max: null,
- });
- expect(descriptor.compat).toMatchObject({
- thinkingFormat: 'deepseek',
- supportsReasoningEffort: true,
- requiresReasoningContentOnAssistantMessages: true,
+ input: ['text', 'image'], reasoning: true, contextWindow: 123456, maxOutputTokens: 1234,
+ compat: { thinkingFormat: format, supportsReasoningEffort: false, requiresReasoningContentOnAssistantMessages: true },
+ managedCapability: { reasoning: { effortValues: ['low', 'medium', 'xhigh', 'max', 'future'] } },
});
+ expect(descriptor.thinkingLevelMap).toBeUndefined();
+ expect(catalog.modelsFile.providers[resolvePiRuntimeProviderId(provider.id)]!.models[0])
+ .not.toHaveProperty('managedCapability');
+ const profile = provider.metadata!.worksSquareModelCapabilitiesV2!.models['future-model']!;
+ profile.inputModalities = ['text'];
+ profile.reasoning.supported = false;
+ expect(buildPiProviderCatalog({ accounts: [provider] }).descriptors[0]!.models[0])
+ .toMatchObject({ input: ['text'], reasoning: false });
});
it('serializes DeepSeek off without sending a reasoning effort', async () => {
diff --git a/tests/unit/provider-routes.test.ts b/tests/unit/provider-routes.test.ts
index 12ba377..3ae664c 100644
--- a/tests/unit/provider-routes.test.ts
+++ b/tests/unit/provider-routes.test.ts
@@ -120,6 +120,21 @@ describe('provider host api routes', () => {
});
});
+ it('preserves exact v2 public aliases and native effort values on import', () => {
+ const config = normalizeImportedUserModelConfig({
+ base_url: 'https://gateway.test/v1', api_key: 'test', models: ['deepseek/public-model'],
+ model_capabilities_v2: { schema_version: 2, models: {
+ 'deepseek/public-model': { input_modalities: ['text', 'image'],
+ reasoning: { supported: true, can_disable: true, effort_values: ['medium', 'xhigh', 'new-native'], control_format: 'qwen' } },
+ 'not-authorized': { input_modalities: ['image'] },
+ } },
+ });
+ expect(config.models).toEqual(['deepseek/public-model']);
+ expect(Object.keys(config.modelCapabilitiesV2!.models)).toEqual(['deepseek/public-model']);
+ expect(config.modelCapabilitiesV2!.models['deepseek/public-model']!.reasoning.effortValues)
+ .toEqual(['medium', 'xhigh', 'new-native']);
+ });
+
it('persists server capabilities and invalidates the runtime when they change', async () => {
const existing = account({
id: 'niancode-user-models',
@@ -152,9 +167,13 @@ describe('provider host api routes', () => {
reasoning_can_disable: true,
},
},
+ model_capabilities_v2: { schema_version: 2, models: { 'deepseek/deepseek-v4-pro': {
+ input_modalities: ['text'], reasoning: { supported: true, can_disable: true, effort_values: ['xhigh'] },
+ } } },
}), { status: 200, headers: { 'content-type': 'application/json' } }));
const imported = await importCurrentUserModelConfig(context, 'access-token');
+ expect(imported.account.metadata?.worksSquareModelCapabilitiesV2?.models['deepseek/deepseek-v4-pro']?.reasoning.effortValues).toEqual(['xhigh']);
expect(imported.account.metadata?.worksSquareModelCapabilities).toEqual({
'deepseek-v4-pro': {