From ae7936174208a1d13cdfd260d5c6f2b70b450b60 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 1 Sep 2026 14:16:18 +0800 Subject: [PATCH] feat: consume server model reasoning capabilities --- ...0901-server-model-capabilities-9e31b6c4.md | 84 +++++++ electron/api/routes/coding-conversations.ts | 4 +- electron/api/routes/providers.ts | 16 +- electron/coding-projects/project-config.ts | 1 + electron/coding-runtime/pi/provider-config.ts | 33 ++- electron/coding-runtime/pi/runtime.ts | 1 + .../services/providers/provider-service.ts | 7 + electron/shared/providers/types.ts | 3 + shared/coding-conversation-contracts.ts | 2 +- shared/coding-conversation-reducer.ts | 2 +- shared/imported-model-profile.ts | 29 ++- shared/user-model-config.ts | 38 ++++ src/lib/providers.ts | 3 + .../Chat/CodingComposerRuntimeControls.tsx | 1 + .../coding-conversation-contracts.test.ts | 14 ++ tests/unit/coding-feature-ui.test.tsx | 55 +++++ tests/unit/coding-projects-schema-v2.test.ts | 13 ++ tests/unit/imported-model-profile.test.ts | 69 ++++++ tests/unit/pi-provider-config.test.ts | 210 +++++++++++++++++- tests/unit/provider-routes.test.ts | 146 +++++++++++- 20 files changed, 711 insertions(+), 20 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260901-server-model-capabilities-9e31b6c4.md diff --git a/.project-docs/30-worklog/tasks/20260901-server-model-capabilities-9e31b6c4.md b/.project-docs/30-worklog/tasks/20260901-server-model-capabilities-9e31b6c4.md new file mode 100644 index 0000000..1e0162b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-server-model-capabilities-9e31b6c4.md @@ -0,0 +1,84 @@ +# Task: Consume server model reasoning capabilities + +## Identity + +- Task ID: 20260901-server-model-capabilities-9e31b6c4 +- Mode: Feature +- Branch: codex/20260901-server-model-capabilities-9e31b6c4-server-model-capabilities +- Worktree: D:\w\makelore-model-capabilities-9e31b6c4 +- Base commit: 850947c092892cb647c4191b6d8bbf37a763e1ad +- Owner: codex +- Status: Ready for integration + +## Scope + +- Consume per-model reasoning capabilities from Works Square model config and persist + them on the imported Main-owned Provider account. +- Make server metadata override the local Pi reasoning map for server-provisioned + models while retaining the local verified profile as an old-server/direct-provider + fallback. +- Add native `max` to the product-neutral thinking-level contract and composer menu so + DeepSeek exposes off/low/high/max without relabeling provider values. +- Keep one-api and Pi `0.84.2` unchanged. + +## Intent And Constraints + +- Electron Main remains the sole Works/model-config, Provider, credential, and Pi-wire + owner. Renderer receives only safe product-neutral levels. +- Strictly normalize the trusted response shape; a missing capability field means an + older server and falls back to the local profile. +- Preserve persisted user choices and all non-Works Provider behavior. +- Do not restore OpenCode, add a second runtime, or expose Provider credentials or raw + response data. +- Feature mode may update only product code, focused tests, and this task record. + +## Plan + +1. Add failing tests for Works capability import, Provider metadata persistence, + server-over-local Pi mapping, and native `max` menu/contract acceptance. +2. Implement the typed response parser, Provider metadata projection, capability-map + precedence, local DeepSeek fallback, and minimal `max` propagation through existing + product validators/UI. +3. Run focused unit tests, typecheck, scoped lint, Vite/Main build, and the project-docs + completion gate. + +## Outcome + +- Parsed and normalized Works Square `model_capabilities`, stored the safe result on + the managed Provider account, removed stale overrides when the optional server field + disappears, and included the metadata in Provider runtime-shape invalidation. +- Server-provided efforts now override the local Pi reasoning/thinking-level map while + preserving verified local modalities, token limits, and wire-format compatibility. + A capability for a future model without a local profile enables standard + `reasoning_effort` serialization automatically. +- Updated the verified DeepSeek fallback to expose only off/low/high/max. Pi sends + `thinking.type=disabled` with no `reasoning_effort` for off, and sends the exact + low/high/max effort for enabled requests. +- Added native `max` to the product contract, snapshot validation, project persistence, + Host route, Pi runtime, and composer UI where it is labelled `最高`. +- Kept Pi at 0.84.2 and made no one-api, dependency, README, or second-runtime change. + +## Verification + +- Focused final Vitest combination: 8 files and 102/102 tests passed, including actual + Pi `streamSimple` payload checks for off and each of low/high/max. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with the repository's existing five warnings and no + errors; a separate scoped ESLint run over every changed TypeScript/TSX file passed. +- `pnpm run build:vite`: passed; only existing Browserslist, dynamic-import, and chunk + size warnings were emitted. +- `git diff --check`: passed. + +## Follow-ups + +- Integrate with the paired Works Square API change. Deployment order is tolerant: + a client talking to an older server uses the corrected local DeepSeek profile, and + older clients ignore the new server response member. +- Verify future server model entries against their provider's exact effort vocabulary + before publishing them; no one-api change is required while it remains transparent. + +## Promotion Candidates + +- Promote the Main-owned capability normalization/precedence rule and the safe + server-to-Provider metadata contract into canonical architecture documentation after + the paired server and client branches are integrated. diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index 499a904..8666664 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -12,7 +12,7 @@ import { } from '../route-utils'; import { decodeRouteId, sendCodingRouteError } from './coding-route-errors'; -const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high']); +const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'max']); function invalidRequest(message: string): never { throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message); @@ -187,7 +187,7 @@ export async function handleCodingConversationRoutes( sendJson(res, 200, { model: await service.setThinking( conversationId, - body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high', + body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max', ), }); return true; diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 62cec1d..e5d5050 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -20,8 +20,10 @@ import { seedWorksSquareAIGatewayCredential } from '../../services/works-square- import { NIANCODE_USER_MODEL_ACCOUNT_ID, NIANCODE_USER_MODEL_ACCOUNT_LABEL, + normalizeImportedModelCapabilities, normalizeImportedUserModelId, } from '../../../shared/user-model-config'; +import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile'; const legacyProviderRoutesWarned = new Set(); @@ -141,6 +143,7 @@ type ImportedUserModelConfig = { credentialMode: string; apiKeyExpiresIn: number | null; models: string[]; + modelCapabilities?: ImportedModelCapabilities; }; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; @@ -157,7 +160,7 @@ class WorksSquareModelConfigError extends Error { } } -function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelConfig { +export function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelConfig { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error('Works Square model config response is invalid'); } @@ -181,6 +184,7 @@ function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelCo const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : NIANCODE_USER_MODEL_ACCOUNT_LABEL; + const modelCapabilities = normalizeImportedModelCapabilities(record.model_capabilities, models); return { label, @@ -189,6 +193,7 @@ function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelCo credentialMode: credentialMode || 'api_key', apiKeyExpiresIn, models, + ...(modelCapabilities ? { modelCapabilities } : {}), }; } @@ -211,9 +216,13 @@ function importedUserModelMetadata( if (useLocalAiProxy) { const metadata = { ...(existing?.metadata ?? {}) }; delete metadata.worksSquareCredentialExpiresAt; + delete metadata.worksSquareModelCapabilities; return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilities + ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } + : {}), worksSquareCredentialMode: WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE, worksSquareOneApiBaseUrl: modelConfig.baseUrl, }; @@ -222,12 +231,16 @@ function importedUserModelMetadata( const metadata = { ...(existing?.metadata ?? {}) }; delete metadata.worksSquareCredentialExpiresAt; delete metadata.worksSquareOneApiBaseUrl; + delete metadata.worksSquareModelCapabilities; const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null ? undefined : new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString(); return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilities + ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } + : {}), worksSquareCredentialMode: modelConfig.credentialMode, ...(credentialExpiresAt ? { worksSquareCredentialExpiresAt: credentialExpiresAt } : {}), }; @@ -295,6 +308,7 @@ function providerAccountRuntimeShape(account: ProviderAccount): unknown { isDefault: account.isDefault, metadata: { customModels: account.metadata?.customModels, + worksSquareModelCapabilities: account.metadata?.worksSquareModelCapabilities, 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 0172f94..c53de72 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -72,6 +72,7 @@ const THINKING_LEVELS = new Set([ 'low', 'medium', 'high', + 'max', ]); function projectConfigPath(projectPath: string): string { diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts index ad6d374..a3ac93f 100644 --- a/electron/coding-runtime/pi/provider-config.ts +++ b/electron/coding-runtime/pi/provider-config.ts @@ -17,7 +17,10 @@ import { normalizeImportedUserModelId, selectUserModelRuntimeAccounts, } from '../../../shared/user-model-config'; -import { getImportedModelProfile } from '../../../shared/imported-model-profile'; +import { + getImportedModelProfile, + thinkingLevelMapForImportedModelCapability, +} from '../../../shared/imported-model-profile'; const PI_ENV_PREFIX = 'MAKELORE_PI'; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; @@ -302,6 +305,7 @@ function modelDescriptor( )); const backend = backendModels.get(modelId); const profile = getImportedModelProfile(modelId); + const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId]; const backendInput = Array.isArray(backend?.input) ? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image') : []; @@ -319,17 +323,28 @@ function modelDescriptor( id: modelId, name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId, input: supportsImage ? ['text', 'image'] : ['text'], - reasoning: summary?.supportsReasoning === true - || profile?.pi?.reasoning === true - || backend?.reasoning === true, + reasoning: serverCapability + ? serverCapability.reasoningEfforts.length > 0 + : summary?.supportsReasoning === true + || profile?.pi?.reasoning === true + || backend?.reasoning === true, ...(contextWindow ? { contextWindow } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}), - ...(compat || profile?.pi?.compat || enforcedCompat - ? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } } - : {}), - ...(profile?.pi?.thinkingLevelMap - ? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } } + ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability + ? { + compat: { + ...compat, + ...profile?.pi?.compat, + ...enforcedCompat, + ...(serverCapability ? { supportsReasoningEffort: true } : {}), + }, + } : {}), + ...(serverCapability + ? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) } + : profile?.pi?.thinkingLevelMap + ? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } } + : {}), }; } diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 56bc7f9..acc69b7 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -459,6 +459,7 @@ const PRODUCT_THINKING_LEVELS = new Set([ 'low', 'medium', 'high', + 'max', ]); function productThinkingLevel(value: unknown): ProductModelRef['thinkingLevel'] | null { diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 1aa53c9..dec19e2 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -29,6 +29,7 @@ import { } from '../../utils/secure-storage'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; +import { normalizeImportedModelCapabilities } from '../../../shared/user-model-config'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; @@ -69,6 +70,12 @@ function normalizeSyncedMetadata(metadata: Record): ProviderAcc result.customModels = customModels; } } + const worksSquareModelCapabilities = normalizeImportedModelCapabilities( + metadata.worksSquareModelCapabilities, + ); + if (worksSquareModelCapabilities) { + result.worksSquareModelCapabilities = worksSquareModelCapabilities; + } return result; } diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 6f196e0..6994fbc 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -1,3 +1,5 @@ +import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile'; + export const PROVIDER_TYPES = [ 'anthropic', 'openai', @@ -133,6 +135,7 @@ export interface ProviderAccount { email?: string; resourceUrl?: string; customModels?: string[]; + worksSquareModelCapabilities?: ImportedModelCapabilities; worksSquareCredentialMode?: string; worksSquareCredentialExpiresAt?: string; worksSquareOneApiBaseUrl?: string; diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 111e0a9..76d8f28 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -2,7 +2,7 @@ import type { CapabilityResultV1 } from './data-service'; export type { CapabilityBillingReceiptV1, CapabilityResultV1 } from './data-service'; -export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; +export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'; export interface ProductModelRef { accountId: string; diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts index 985ba23..efd53f3 100644 --- a/shared/coding-conversation-reducer.ts +++ b/shared/coding-conversation-reducer.ts @@ -54,7 +54,7 @@ const RUN_STATUSES = new Set([ ]); const WORKER_STATUSES = new Set(['stopped', 'starting', 'ready', 'recovering', 'error']); -const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high']); +const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'max']); const ERROR_CODES = new Set([ 'CODING_RUNTIME_START_FAILED', 'CODING_RUNTIME_READY_TIMEOUT', diff --git a/shared/imported-model-profile.ts b/shared/imported-model-profile.ts index f74892d..ce31fa6 100644 --- a/shared/imported-model-profile.ts +++ b/shared/imported-model-profile.ts @@ -1,6 +1,28 @@ export type ImportedModelModality = 'text' | 'audio' | 'image' | 'pdf'; export type ImportedVisionTokenEstimator = 'qwen-32px-grid'; -export type ImportedThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; +export type ImportedThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'; +export const IMPORTED_REASONING_EFFORTS = ['low', 'high', 'max'] as const; +export type ImportedReasoningEffort = (typeof IMPORTED_REASONING_EFFORTS)[number]; + +export interface ImportedModelCapability { + reasoningEfforts: ImportedReasoningEffort[]; + reasoningCanDisable: boolean; +} + +export type ImportedModelCapabilities = Record; + +export function thinkingLevelMapForImportedModelCapability( + capability: ImportedModelCapability, +): Partial> { + return { + ...(capability.reasoningCanDisable ? {} : { off: null }), + minimal: null, + low: capability.reasoningEfforts.includes('low') ? 'low' : null, + medium: null, + high: capability.reasoningEfforts.includes('high') ? 'high' : null, + max: capability.reasoningEfforts.includes('max') ? 'max' : null, + }; +} export interface ImportedPiModelProfile { reasoning: boolean; @@ -62,14 +84,15 @@ export function getImportedModelProfile(rawModelId: string): ImportedModelProfil pi: { reasoning: true, thinkingLevelMap: { - off: null, minimal: null, - low: null, + low: 'low', medium: null, high: 'high', + max: 'max', }, compat: { thinkingFormat: 'deepseek', + supportsReasoningEffort: true, requiresReasoningContentOnAssistantMessages: true, }, }, diff --git a/shared/user-model-config.ts b/shared/user-model-config.ts index 9707b81..dbd4fac 100644 --- a/shared/user-model-config.ts +++ b/shared/user-model-config.ts @@ -1,3 +1,10 @@ +import { + IMPORTED_REASONING_EFFORTS, + type ImportedModelCapabilities, + type ImportedModelCapability, + type ImportedReasoningEffort, +} from './imported-model-profile'; + export const NIANCODE_USER_MODEL_ACCOUNT_ID = 'niancode-user-models'; export const NIANCODE_USER_MODEL_ACCOUNT_LABEL = 'Makelore Models'; @@ -23,6 +30,37 @@ export function normalizeImportedUserModelId(rawModel: string): string { : trimmed; } +function normalizeImportedModelCapability(value: unknown): ImportedModelCapability | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const rawEfforts = record.reasoning_efforts ?? record.reasoningEfforts; + const reasoningCanDisable = record.reasoning_can_disable ?? record.reasoningCanDisable; + if (!Array.isArray(rawEfforts) || typeof reasoningCanDisable !== 'boolean') return null; + const reasoningEfforts = IMPORTED_REASONING_EFFORTS.filter((effort) => ( + rawEfforts.some((candidate) => candidate === effort) + )) as ImportedReasoningEffort[]; + if (reasoningEfforts.length === 0 && rawEfforts.length > 0) return null; + return { reasoningEfforts, reasoningCanDisable }; +} + +export function normalizeImportedModelCapabilities( + value: unknown, + modelIds?: readonly string[], +): ImportedModelCapabilities | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const allowedModelIds = modelIds + ? new Set(modelIds.map(normalizeImportedUserModelId)) + : null; + const result: ImportedModelCapabilities = {}; + for (const [rawModelId, rawCapability] of Object.entries(value as Record)) { + const modelId = normalizeImportedUserModelId(rawModelId); + if (!modelId || (allowedModelIds && !allowedModelIds.has(modelId))) continue; + const capability = normalizeImportedModelCapability(rawCapability); + if (capability && !result[modelId]) result[modelId] = capability; + } + return Object.keys(result).length > 0 ? result : undefined; +} + export function selectUserModelRuntimeAccounts( accounts: T[], ): T[] { diff --git a/src/lib/providers.ts b/src/lib/providers.ts index ab7b81a..1b9be96 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -6,6 +6,8 @@ * layer so TypeScript project boundaries remain stable during the migration. */ +import type { ImportedModelCapabilities } from '../../shared/imported-model-profile'; + export const PROVIDER_TYPES = [ 'anthropic', 'openai', @@ -125,6 +127,7 @@ export interface ProviderAccount { email?: string; resourceUrl?: string; customModels?: string[]; + worksSquareModelCapabilities?: ImportedModelCapabilities; worksSquareCredentialMode?: string; worksSquareCredentialExpiresAt?: string; }; diff --git a/src/pages/Chat/CodingComposerRuntimeControls.tsx b/src/pages/Chat/CodingComposerRuntimeControls.tsx index e051b5b..f582905 100644 --- a/src/pages/Chat/CodingComposerRuntimeControls.tsx +++ b/src/pages/Chat/CodingComposerRuntimeControls.tsx @@ -30,6 +30,7 @@ const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string { value: 'low', label: '低' }, { value: 'medium', label: '中等' }, { value: 'high', label: '高' }, + { value: 'max', label: '最高' }, ]; function thinkingLabel(level: ConversationThinkingLevel): string { diff --git a/tests/unit/coding-conversation-contracts.test.ts b/tests/unit/coding-conversation-contracts.test.ts index 6885255..5af968e 100644 --- a/tests/unit/coding-conversation-contracts.test.ts +++ b/tests/unit/coding-conversation-contracts.test.ts @@ -50,6 +50,20 @@ function envelope( } describe('Conversation product contracts', () => { + it('accepts max in the persisted model and available thinking-level contract', () => { + const snapshot = createProductSnapshot(); + snapshot.conversation.model = { + model: { + accountId: 'account-max', + modelId: 'deepseek-v4-pro', + thinkingLevel: 'max', + }, + modelResolution: 'resolved', + availableThinkingLevels: ['off', 'low', 'high', 'max'], + }; + expect(isConversationSnapshot(snapshot)).toBe(true); + }); + it('accepts bounded capability envelopes for every Data Service operation', () => { const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']); const operations = [ diff --git a/tests/unit/coding-feature-ui.test.tsx b/tests/unit/coding-feature-ui.test.tsx index 729a93c..fbe9e6c 100644 --- a/tests/unit/coding-feature-ui.test.tsx +++ b/tests/unit/coding-feature-ui.test.tsx @@ -528,6 +528,61 @@ describe('PI-130 feature-complete Coding UI', () => { expect(screen.queryByRole('menu', { name: '选择推理强度' })).not.toBeInTheDocument(); }); + it('shows the native max thinking level as 最高', async () => { + const { CodingComposerRuntimeControls } = await import('@/pages/Chat/CodingComposerRuntimeControls'); + const conversation = { + id: 'conversation-max-thinking', + agentId: 'agent-1', + title: 'Max thinking controls', + archivedAt: null, + unread: false, + createdAt: '2026-08-28T00:00:00.000Z', + updatedAt: '2026-08-28T00:00:00.000Z', + model: { accountId: 'account-1', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' as const }, + modelResolution: 'resolved' as const, + }; + const snapshot: ConversationSnapshot = { + schemaVersion: 1, + conversation: { + id: conversation.id, + projectId: 'project-1', + agentId: conversation.agentId, + title: conversation.title, + model: { + model: conversation.model, + modelResolution: 'resolved', + availableThinkingLevels: ['off', 'low', 'high', 'max'], + }, + }, + nodes: [], + run: { status: 'idle' }, + queue: { items: [] }, + context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' }, + pendingInteractions: [], + worker: { status: 'ready', generation: 1 }, + cursor: { workerGeneration: 1, seq: 0 }, + }; + + render( + undefined)} + />, + ); + + const settingsTrigger = screen.getByRole('button', { + name: '模型与思考设置:deepseek-v4-pro,最高', + }); + expect(settingsTrigger).toHaveTextContent('最高'); + fireEvent.pointerDown(settingsTrigger, { button: 0, ctrlKey: false }); + const thinkingRow = await screen.findByRole('menuitem', { name: '推理强度 最高' }); + fireEvent.click(thinkingRow); + expect(screen.getByRole('menuitemradio', { name: '最高' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitemradio', { name: '极简' })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitemradio', { name: '中等' })).not.toBeInTheDocument(); + }); + it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => { interactionApi.abort.mockResolvedValue(undefined); const recover = vi.fn(async () => undefined); diff --git a/tests/unit/coding-projects-schema-v2.test.ts b/tests/unit/coding-projects-schema-v2.test.ts index fa634ee..f6b1a9f 100644 --- a/tests/unit/coding-projects-schema-v2.test.ts +++ b/tests/unit/coding-projects-schema-v2.test.ts @@ -13,6 +13,7 @@ import { atomicWriteJson } from '../../electron/coding-projects/atomic-json'; import { createCodingProjectAgent, createCodingProjectConfigV2, + normalizeProductModelRef, readCodingProjectConfigV2, } from '../../electron/coding-projects/project-config'; import { @@ -54,6 +55,18 @@ afterEach(async () => { }); describe('coding project schema v2', () => { + it('accepts the native max thinking level in a project model reference', () => { + expect(normalizeProductModelRef({ + accountId: 'account-local', + modelId: 'deepseek-v4-pro', + thinkingLevel: 'max', + })).toEqual({ + accountId: 'account-local', + modelId: 'deepseek-v4-pro', + thinkingLevel: 'max', + }); + }); + it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => { const projectPath = await makeProjectPath(); const storage = createMemoryCodingProjectStorage(); diff --git a/tests/unit/imported-model-profile.test.ts b/tests/unit/imported-model-profile.test.ts index 2bccc9b..cbcd129 100644 --- a/tests/unit/imported-model-profile.test.ts +++ b/tests/unit/imported-model-profile.test.ts @@ -3,6 +3,7 @@ import { estimateImportedModelVisionTokens, getImportedModelProfile, } from '../../shared/imported-model-profile'; +import { normalizeImportedModelCapabilities } from '../../shared/user-model-config'; import { getKnownModelCapabilityKind, getModelCapabilityLabel, @@ -10,6 +11,74 @@ import { } from '../../shared/model-capabilities'; describe('getImportedModelProfile', () => { + it('returns native DeepSeek V4 Pro thinking levels without generic placeholders', () => { + expect(getImportedModelProfile('deepseek-v4-pro')).toMatchObject({ + modalities: { + input: ['text'], + output: ['text'], + }, + limit: { + context: 1_000_000, + output: 384_000, + }, + pi: { + reasoning: true, + thinkingLevelMap: { + minimal: null, + low: 'low', + medium: null, + high: 'high', + max: 'max', + }, + compat: { + thinkingFormat: 'deepseek', + supportsReasoningEffort: true, + requiresReasoningContentOnAssistantMessages: true, + }, + }, + }); + }); + + it('normalizes Works model capabilities by normalized model id and supported effort', () => { + expect(normalizeImportedModelCapabilities({ + 'deepseek/deepseek-v4-pro': { + reasoning_efforts: ['max', 'low', 'low', 'medium', 'unsupported'], + reasoning_can_disable: true, + }, + 'qwen3.8-max': { + reasoning_efforts: ['high'], + reasoning_can_disable: false, + }, + 'unknown-model': { + reasoning_efforts: ['low'], + reasoning_can_disable: 'yes', + }, + }, ['deepseek-v4-pro', 'qwen3.8-max'])).toEqual({ + 'deepseek-v4-pro': { + reasoningEfforts: ['low', 'max'], + reasoningCanDisable: true, + }, + 'qwen3.8-max': { + reasoningEfforts: ['high'], + reasoningCanDisable: false, + }, + }); + }); + + it('preserves an explicit empty effort list so the server can disable local reasoning metadata', () => { + expect(normalizeImportedModelCapabilities({ + 'deepseek-v4-pro': { + reasoning_efforts: [], + reasoning_can_disable: false, + }, + }, ['deepseek-v4-pro'])).toEqual({ + 'deepseek-v4-pro': { + reasoningEfforts: [], + reasoningCanDisable: false, + }, + }); + }); + it.each([ 'qwen3.6-plus', 'qwen3.6-plus-2026-04-02', diff --git a/tests/unit/pi-provider-config.test.ts b/tests/unit/pi-provider-config.test.ts index c3714f1..b21dc42 100644 --- a/tests/unit/pi-provider-config.test.ts +++ b/tests/unit/pi-provider-config.test.ts @@ -2,6 +2,8 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, 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 type { ProviderAccount } from '@electron/shared/providers/types'; import { buildPiProviderCatalog, @@ -269,11 +271,11 @@ describe('Pi Provider catalog', () => { requiresReasoningContentOnAssistantMessages: true, }, thinkingLevelMap: { - off: null, minimal: null, - low: null, + low: 'low', medium: null, high: 'high', + max: 'max', }, }); expect(written).toMatchObject({ @@ -283,6 +285,210 @@ describe('Pi Provider catalog', () => { }); }); + 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, + }); + }); + + it('serializes DeepSeek off without sending a reasoning effort', async () => { + const payloads: unknown[] = []; + const model: Model<'openai-completions'> = { + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + api: 'openai-completions', + provider: 'deepseek', + baseUrl: 'https://gateway.test/v1', + reasoning: true, + input: ['text'], + contextWindow: 1_000_000, + maxTokens: 384_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + thinkingLevelMap: { + minimal: null, + low: 'low', + medium: null, + high: 'high', + max: 'max', + }, + compat: { + thinkingFormat: 'deepseek', + supportsReasoningEffort: true, + }, + }; + + const stream = streamSimple(model, { messages: [] }, { + apiKey: 'test-key', + reasoning: 'off', + onPayload(payload) { + payloads.push(payload); + throw new Error('stop before network'); + }, + }); + const result = await stream.result(); + + expect(result.stopReason).toBe('error'); + expect(payloads[0]).toMatchObject({ thinking: { type: 'disabled' } }); + expect(payloads[0]).not.toHaveProperty('reasoning_effort'); + }); + + it.each(['low', 'high', 'max'] as const)('serializes DeepSeek %s with its reasoning effort', async (level) => { + const payloads: unknown[] = []; + const model: Model<'openai-completions'> = { + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + api: 'openai-completions', + provider: 'deepseek', + baseUrl: 'https://gateway.test/v1', + reasoning: true, + input: ['text'], + contextWindow: 1_000_000, + maxTokens: 384_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + thinkingLevelMap: { + minimal: null, + low: 'low', + medium: null, + high: 'high', + max: 'max', + }, + compat: { + thinkingFormat: 'deepseek', + supportsReasoningEffort: true, + }, + }; + + const stream = streamSimple(model, { messages: [] }, { + apiKey: 'test-key', + reasoning: level, + onPayload(payload) { + payloads.push(payload); + throw new Error('stop before network'); + }, + }); + const result = await stream.result(); + + expect(result.stopReason).toBe('error'); + expect(payloads[0]).toMatchObject({ + thinking: { type: 'enabled' }, + reasoning_effort: level, + }); + }); + it('does not replace an existing catalog when model selection is unavailable', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-provider-')); temporaryRoots.push(root); diff --git a/tests/unit/provider-routes.test.ts b/tests/unit/provider-routes.test.ts index 7164a04..12ba377 100644 --- a/tests/unit/provider-routes.test.ts +++ b/tests/unit/provider-routes.test.ts @@ -1,20 +1,30 @@ import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { handleProviderRoutes } from '@electron/api/routes/providers'; +import { + handleProviderRoutes, + importCurrentUserModelConfig, + normalizeImportedUserModelConfig, +} from '@electron/api/routes/providers'; import type { ProviderAccount } from '@electron/shared/providers/types'; const providerServiceMock = vi.hoisted(() => ({ getAccount: vi.fn(), + getAccountApiKey: vi.fn(), getDefaultAccountId: vi.fn(), updateAccount: vi.fn(), setDefaultAccount: vi.fn(), })); +const proxyAwareFetchMock = vi.hoisted(() => vi.fn()); vi.mock('@electron/services/providers/provider-service', () => ({ getProviderService: () => providerServiceMock, })); +vi.mock('@electron/utils/proxy-fetch', () => ({ + proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args), +})); + function createRequest(method: string, body?: unknown): IncomingMessage { const request = new EventEmitter(); Object.assign(request, { @@ -66,6 +76,7 @@ describe('provider host api routes', () => { beforeEach(() => { vi.clearAllMocks(); providerServiceMock.getAccount.mockResolvedValue(account()); + providerServiceMock.getAccountApiKey.mockResolvedValue('stored-key'); providerServiceMock.updateAccount.mockResolvedValue(account({ updatedAt: '2026-08-24T01:00:00.000Z', })); @@ -73,6 +84,139 @@ describe('provider host api routes', () => { providerServiceMock.setDefaultAccount.mockResolvedValue(undefined); }); + it('normalizes optional Works model capabilities before storing the account', () => { + expect(normalizeImportedUserModelConfig({ + label: 'Makelore Models', + base_url: 'https://gateway.test/v1', + api_key: 'gateway-key', + credential_mode: 'works_square_ai_gateway', + models: ['deepseek/deepseek-v4-pro', 'qwen3.8-max'], + model_capabilities: { + 'deepseek/deepseek-v4-pro': { + reasoning_efforts: ['max', 'low', 'medium', 'low'], + reasoning_can_disable: true, + }, + 'qwen3.8-max': { + reasoning_efforts: ['high'], + reasoning_can_disable: false, + }, + 'not-provisioned': { + reasoning_efforts: ['low'], + reasoning_can_disable: true, + }, + }, + })).toMatchObject({ + models: ['deepseek-v4-pro', 'qwen3.8-max'], + modelCapabilities: { + 'deepseek-v4-pro': { + reasoningEfforts: ['low', 'max'], + reasoningCanDisable: true, + }, + 'qwen3.8-max': { + reasoningEfforts: ['high'], + reasoningCanDisable: false, + }, + }, + }); + }); + + it('persists server capabilities and invalidates the runtime when they change', async () => { + const existing = account({ + id: 'niancode-user-models', + vendorId: 'custom', + model: 'deepseek-v4-pro', + metadata: { + customModels: ['deepseek-v4-pro'], + worksSquareModelCapabilities: { + 'deepseek-v4-pro': { + reasoningEfforts: ['high'], + reasoningCanDisable: false, + }, + }, + }, + }); + providerServiceMock.getAccount.mockResolvedValue(existing); + providerServiceMock.updateAccount.mockImplementation(async (_accountId: string, patch: Partial) => ({ + ...existing, + ...patch, + })); + proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ + label: 'Makelore Models', + base_url: 'https://gateway.test/v1', + api_key: 'gateway-key', + credential_mode: 'api_key', + models: ['deepseek/deepseek-v4-pro'], + model_capabilities: { + 'deepseek/deepseek-v4-pro': { + reasoning_efforts: ['low', 'high', 'max'], + reasoning_can_disable: true, + }, + }, + }), { status: 200, headers: { 'content-type': 'application/json' } })); + + const imported = await importCurrentUserModelConfig(context, 'access-token'); + + expect(imported.account.metadata?.worksSquareModelCapabilities).toEqual({ + 'deepseek-v4-pro': { + reasoningEfforts: ['low', 'high', 'max'], + reasoningCanDisable: true, + }, + }); + expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( + 'niancode-user-models', + expect.objectContaining({ + metadata: expect.objectContaining({ + worksSquareModelCapabilities: { + 'deepseek-v4-pro': { + reasoningEfforts: ['low', 'high', 'max'], + reasoningCanDisable: true, + }, + }, + }), + }), + 'gateway-key', + ); + expect(markProviderStale).toHaveBeenCalledTimes(1); + }); + + it('clears a previous server capability override when the optional field is absent', async () => { + const existing = account({ + id: 'niancode-user-models', + vendorId: 'custom', + model: 'deepseek-v4-pro', + metadata: { + customModels: ['deepseek-v4-pro'], + worksSquareModelCapabilities: { + 'deepseek-v4-pro': { + reasoningEfforts: ['max'], + reasoningCanDisable: true, + }, + }, + }, + }); + providerServiceMock.getAccount.mockResolvedValue(existing); + providerServiceMock.updateAccount.mockImplementation(async (_accountId: string, patch: Partial) => ({ + ...existing, + ...patch, + })); + proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ + base_url: 'https://gateway.test/v1', + api_key: 'gateway-key', + models: ['deepseek/deepseek-v4-pro'], + }), { status: 200, headers: { 'content-type': 'application/json' } })); + + const imported = await importCurrentUserModelConfig(context, 'access-token'); + + expect(imported.account.metadata).not.toHaveProperty('worksSquareModelCapabilities'); + expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( + 'niancode-user-models', + expect.objectContaining({ + metadata: expect.not.objectContaining({ worksSquareModelCapabilities: expect.anything() }), + }), + 'gateway-key', + ); + }); + it('marks Pi provider input stale after an account credential update', async () => { const result = createResponse(); const handled = await handleProviderRoutes(