feat: consume server model reasoning capabilities
This commit is contained in:
@@ -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.
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../route-utils';
|
} from '../route-utils';
|
||||||
import { decodeRouteId, sendCodingRouteError } from './coding-route-errors';
|
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 {
|
function invalidRequest(message: string): never {
|
||||||
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message);
|
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message);
|
||||||
@@ -187,7 +187,7 @@ export async function handleCodingConversationRoutes(
|
|||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
model: await service.setThinking(
|
model: await service.setThinking(
|
||||||
conversationId,
|
conversationId,
|
||||||
body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high',
|
body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max',
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import { seedWorksSquareAIGatewayCredential } from '../../services/works-square-
|
|||||||
import {
|
import {
|
||||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
||||||
NIANCODE_USER_MODEL_ACCOUNT_LABEL,
|
NIANCODE_USER_MODEL_ACCOUNT_LABEL,
|
||||||
|
normalizeImportedModelCapabilities,
|
||||||
normalizeImportedUserModelId,
|
normalizeImportedUserModelId,
|
||||||
} from '../../../shared/user-model-config';
|
} from '../../../shared/user-model-config';
|
||||||
|
import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile';
|
||||||
|
|
||||||
const legacyProviderRoutesWarned = new Set<string>();
|
const legacyProviderRoutesWarned = new Set<string>();
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@ type ImportedUserModelConfig = {
|
|||||||
credentialMode: string;
|
credentialMode: string;
|
||||||
apiKeyExpiresIn: number | null;
|
apiKeyExpiresIn: number | null;
|
||||||
models: string[];
|
models: string[];
|
||||||
|
modelCapabilities?: ImportedModelCapabilities;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
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)) {
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
throw new Error('Works Square model config response is invalid');
|
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()
|
const label = typeof record.label === 'string' && record.label.trim()
|
||||||
? record.label.trim()
|
? record.label.trim()
|
||||||
: NIANCODE_USER_MODEL_ACCOUNT_LABEL;
|
: NIANCODE_USER_MODEL_ACCOUNT_LABEL;
|
||||||
|
const modelCapabilities = normalizeImportedModelCapabilities(record.model_capabilities, models);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
@@ -189,6 +193,7 @@ function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelCo
|
|||||||
credentialMode: credentialMode || 'api_key',
|
credentialMode: credentialMode || 'api_key',
|
||||||
apiKeyExpiresIn,
|
apiKeyExpiresIn,
|
||||||
models,
|
models,
|
||||||
|
...(modelCapabilities ? { modelCapabilities } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +216,13 @@ function importedUserModelMetadata(
|
|||||||
if (useLocalAiProxy) {
|
if (useLocalAiProxy) {
|
||||||
const metadata = { ...(existing?.metadata ?? {}) };
|
const metadata = { ...(existing?.metadata ?? {}) };
|
||||||
delete metadata.worksSquareCredentialExpiresAt;
|
delete metadata.worksSquareCredentialExpiresAt;
|
||||||
|
delete metadata.worksSquareModelCapabilities;
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
customModels: modelConfig.models,
|
customModels: modelConfig.models,
|
||||||
|
...(modelConfig.modelCapabilities
|
||||||
|
? { worksSquareModelCapabilities: modelConfig.modelCapabilities }
|
||||||
|
: {}),
|
||||||
worksSquareCredentialMode: WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE,
|
worksSquareCredentialMode: WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE,
|
||||||
worksSquareOneApiBaseUrl: modelConfig.baseUrl,
|
worksSquareOneApiBaseUrl: modelConfig.baseUrl,
|
||||||
};
|
};
|
||||||
@@ -222,12 +231,16 @@ function importedUserModelMetadata(
|
|||||||
const metadata = { ...(existing?.metadata ?? {}) };
|
const metadata = { ...(existing?.metadata ?? {}) };
|
||||||
delete metadata.worksSquareCredentialExpiresAt;
|
delete metadata.worksSquareCredentialExpiresAt;
|
||||||
delete metadata.worksSquareOneApiBaseUrl;
|
delete metadata.worksSquareOneApiBaseUrl;
|
||||||
|
delete metadata.worksSquareModelCapabilities;
|
||||||
const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null
|
const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null
|
||||||
? undefined
|
? undefined
|
||||||
: new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString();
|
: new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString();
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
customModels: modelConfig.models,
|
customModels: modelConfig.models,
|
||||||
|
...(modelConfig.modelCapabilities
|
||||||
|
? { worksSquareModelCapabilities: modelConfig.modelCapabilities }
|
||||||
|
: {}),
|
||||||
worksSquareCredentialMode: modelConfig.credentialMode,
|
worksSquareCredentialMode: modelConfig.credentialMode,
|
||||||
...(credentialExpiresAt ? { worksSquareCredentialExpiresAt: credentialExpiresAt } : {}),
|
...(credentialExpiresAt ? { worksSquareCredentialExpiresAt: credentialExpiresAt } : {}),
|
||||||
};
|
};
|
||||||
@@ -295,6 +308,7 @@ function providerAccountRuntimeShape(account: ProviderAccount): unknown {
|
|||||||
isDefault: account.isDefault,
|
isDefault: account.isDefault,
|
||||||
metadata: {
|
metadata: {
|
||||||
customModels: account.metadata?.customModels,
|
customModels: account.metadata?.customModels,
|
||||||
|
worksSquareModelCapabilities: account.metadata?.worksSquareModelCapabilities,
|
||||||
worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode,
|
worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode,
|
||||||
worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl,
|
worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
|
|||||||
'low',
|
'low',
|
||||||
'medium',
|
'medium',
|
||||||
'high',
|
'high',
|
||||||
|
'max',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function projectConfigPath(projectPath: string): string {
|
function projectConfigPath(projectPath: string): string {
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
normalizeImportedUserModelId,
|
normalizeImportedUserModelId,
|
||||||
selectUserModelRuntimeAccounts,
|
selectUserModelRuntimeAccounts,
|
||||||
} from '../../../shared/user-model-config';
|
} 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 PI_ENV_PREFIX = 'MAKELORE_PI';
|
||||||
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
||||||
@@ -302,6 +305,7 @@ function modelDescriptor(
|
|||||||
));
|
));
|
||||||
const backend = backendModels.get(modelId);
|
const backend = backendModels.get(modelId);
|
||||||
const profile = getImportedModelProfile(modelId);
|
const profile = getImportedModelProfile(modelId);
|
||||||
|
const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId];
|
||||||
const backendInput = Array.isArray(backend?.input)
|
const backendInput = Array.isArray(backend?.input)
|
||||||
? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image')
|
? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image')
|
||||||
: [];
|
: [];
|
||||||
@@ -319,17 +323,28 @@ function modelDescriptor(
|
|||||||
id: modelId,
|
id: modelId,
|
||||||
name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId,
|
name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId,
|
||||||
input: supportsImage ? ['text', 'image'] : ['text'],
|
input: supportsImage ? ['text', 'image'] : ['text'],
|
||||||
reasoning: summary?.supportsReasoning === true
|
reasoning: serverCapability
|
||||||
|| profile?.pi?.reasoning === true
|
? serverCapability.reasoningEfforts.length > 0
|
||||||
|| backend?.reasoning === true,
|
: summary?.supportsReasoning === true
|
||||||
|
|| profile?.pi?.reasoning === true
|
||||||
|
|| backend?.reasoning === true,
|
||||||
...(contextWindow ? { contextWindow } : {}),
|
...(contextWindow ? { contextWindow } : {}),
|
||||||
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
||||||
...(compat || profile?.pi?.compat || enforcedCompat
|
...(compat || profile?.pi?.compat || enforcedCompat || serverCapability
|
||||||
? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } }
|
? {
|
||||||
: {}),
|
compat: {
|
||||||
...(profile?.pi?.thinkingLevelMap
|
...compat,
|
||||||
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
|
...profile?.pi?.compat,
|
||||||
|
...enforcedCompat,
|
||||||
|
...(serverCapability ? { supportsReasoningEffort: true } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(serverCapability
|
||||||
|
? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) }
|
||||||
|
: profile?.pi?.thinkingLevelMap
|
||||||
|
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ const PRODUCT_THINKING_LEVELS = new Set<ProductModelRef['thinkingLevel']>([
|
|||||||
'low',
|
'low',
|
||||||
'medium',
|
'medium',
|
||||||
'high',
|
'high',
|
||||||
|
'max',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function productThinkingLevel(value: unknown): ProductModelRef['thinkingLevel'] | null {
|
function productThinkingLevel(value: unknown): ProductModelRef['thinkingLevel'] | null {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '../../utils/secure-storage';
|
} from '../../utils/secure-storage';
|
||||||
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { normalizeImportedModelCapabilities } from '../../../shared/user-model-config';
|
||||||
|
|
||||||
function maskApiKey(apiKey: string | null): string | null {
|
function maskApiKey(apiKey: string | null): string | null {
|
||||||
if (!apiKey) return null;
|
if (!apiKey) return null;
|
||||||
@@ -69,6 +70,12 @@ function normalizeSyncedMetadata(metadata: Record<string, unknown>): ProviderAcc
|
|||||||
result.customModels = customModels;
|
result.customModels = customModels;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const worksSquareModelCapabilities = normalizeImportedModelCapabilities(
|
||||||
|
metadata.worksSquareModelCapabilities,
|
||||||
|
);
|
||||||
|
if (worksSquareModelCapabilities) {
|
||||||
|
result.worksSquareModelCapabilities = worksSquareModelCapabilities;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile';
|
||||||
|
|
||||||
export const PROVIDER_TYPES = [
|
export const PROVIDER_TYPES = [
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'openai',
|
'openai',
|
||||||
@@ -133,6 +135,7 @@ export interface ProviderAccount {
|
|||||||
email?: string;
|
email?: string;
|
||||||
resourceUrl?: string;
|
resourceUrl?: string;
|
||||||
customModels?: string[];
|
customModels?: string[];
|
||||||
|
worksSquareModelCapabilities?: ImportedModelCapabilities;
|
||||||
worksSquareCredentialMode?: string;
|
worksSquareCredentialMode?: string;
|
||||||
worksSquareCredentialExpiresAt?: string;
|
worksSquareCredentialExpiresAt?: string;
|
||||||
worksSquareOneApiBaseUrl?: string;
|
worksSquareOneApiBaseUrl?: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { CapabilityResultV1 } from './data-service';
|
|||||||
|
|
||||||
export type { CapabilityBillingReceiptV1, 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 {
|
export interface ProductModelRef {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const RUN_STATUSES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const WORKER_STATUSES = new Set(['stopped', 'starting', 'ready', 'recovering', 'error']);
|
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([
|
const ERROR_CODES = new Set([
|
||||||
'CODING_RUNTIME_START_FAILED',
|
'CODING_RUNTIME_START_FAILED',
|
||||||
'CODING_RUNTIME_READY_TIMEOUT',
|
'CODING_RUNTIME_READY_TIMEOUT',
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
export type ImportedModelModality = 'text' | 'audio' | 'image' | 'pdf';
|
export type ImportedModelModality = 'text' | 'audio' | 'image' | 'pdf';
|
||||||
export type ImportedVisionTokenEstimator = 'qwen-32px-grid';
|
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<string, ImportedModelCapability>;
|
||||||
|
|
||||||
|
export function thinkingLevelMapForImportedModelCapability(
|
||||||
|
capability: ImportedModelCapability,
|
||||||
|
): Partial<Record<ImportedThinkingLevel, string | null>> {
|
||||||
|
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 {
|
export interface ImportedPiModelProfile {
|
||||||
reasoning: boolean;
|
reasoning: boolean;
|
||||||
@@ -62,14 +84,15 @@ export function getImportedModelProfile(rawModelId: string): ImportedModelProfil
|
|||||||
pi: {
|
pi: {
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
thinkingLevelMap: {
|
thinkingLevelMap: {
|
||||||
off: null,
|
|
||||||
minimal: null,
|
minimal: null,
|
||||||
low: null,
|
low: 'low',
|
||||||
medium: null,
|
medium: null,
|
||||||
high: 'high',
|
high: 'high',
|
||||||
|
max: 'max',
|
||||||
},
|
},
|
||||||
compat: {
|
compat: {
|
||||||
thinkingFormat: 'deepseek',
|
thinkingFormat: 'deepseek',
|
||||||
|
supportsReasoningEffort: true,
|
||||||
requiresReasoningContentOnAssistantMessages: true,
|
requiresReasoningContentOnAssistantMessages: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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_ID = 'niancode-user-models';
|
||||||
export const NIANCODE_USER_MODEL_ACCOUNT_LABEL = 'Makelore Models';
|
export const NIANCODE_USER_MODEL_ACCOUNT_LABEL = 'Makelore Models';
|
||||||
|
|
||||||
@@ -23,6 +30,37 @@ export function normalizeImportedUserModelId(rawModel: string): string {
|
|||||||
: trimmed;
|
: trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeImportedModelCapability(value: unknown): ImportedModelCapability | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
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<string, unknown>)) {
|
||||||
|
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<T extends NianCodeUserModelAccountLike>(
|
export function selectUserModelRuntimeAccounts<T extends NianCodeUserModelAccountLike>(
|
||||||
accounts: T[],
|
accounts: T[],
|
||||||
): T[] {
|
): T[] {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* layer so TypeScript project boundaries remain stable during the migration.
|
* layer so TypeScript project boundaries remain stable during the migration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ImportedModelCapabilities } from '../../shared/imported-model-profile';
|
||||||
|
|
||||||
export const PROVIDER_TYPES = [
|
export const PROVIDER_TYPES = [
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'openai',
|
'openai',
|
||||||
@@ -125,6 +127,7 @@ export interface ProviderAccount {
|
|||||||
email?: string;
|
email?: string;
|
||||||
resourceUrl?: string;
|
resourceUrl?: string;
|
||||||
customModels?: string[];
|
customModels?: string[];
|
||||||
|
worksSquareModelCapabilities?: ImportedModelCapabilities;
|
||||||
worksSquareCredentialMode?: string;
|
worksSquareCredentialMode?: string;
|
||||||
worksSquareCredentialExpiresAt?: string;
|
worksSquareCredentialExpiresAt?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string
|
|||||||
{ value: 'low', label: '低' },
|
{ value: 'low', label: '低' },
|
||||||
{ value: 'medium', label: '中等' },
|
{ value: 'medium', label: '中等' },
|
||||||
{ value: 'high', label: '高' },
|
{ value: 'high', label: '高' },
|
||||||
|
{ value: 'max', label: '最高' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function thinkingLabel(level: ConversationThinkingLevel): string {
|
function thinkingLabel(level: ConversationThinkingLevel): string {
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ function envelope(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Conversation product contracts', () => {
|
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', () => {
|
it('accepts bounded capability envelopes for every Data Service operation', () => {
|
||||||
const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']);
|
const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']);
|
||||||
const operations = [
|
const operations = [
|
||||||
|
|||||||
@@ -528,6 +528,61 @@ describe('PI-130 feature-complete Coding UI', () => {
|
|||||||
expect(screen.queryByRole('menu', { name: '选择推理强度' })).not.toBeInTheDocument();
|
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(
|
||||||
|
<CodingComposerRuntimeControls
|
||||||
|
conversation={conversation}
|
||||||
|
snapshot={snapshot}
|
||||||
|
onRefresh={vi.fn(async () => 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 () => {
|
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
|
||||||
interactionApi.abort.mockResolvedValue(undefined);
|
interactionApi.abort.mockResolvedValue(undefined);
|
||||||
const recover = vi.fn(async () => undefined);
|
const recover = vi.fn(async () => undefined);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
|
|||||||
import {
|
import {
|
||||||
createCodingProjectAgent,
|
createCodingProjectAgent,
|
||||||
createCodingProjectConfigV2,
|
createCodingProjectConfigV2,
|
||||||
|
normalizeProductModelRef,
|
||||||
readCodingProjectConfigV2,
|
readCodingProjectConfigV2,
|
||||||
} from '../../electron/coding-projects/project-config';
|
} from '../../electron/coding-projects/project-config';
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +55,18 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('coding project schema v2', () => {
|
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 () => {
|
it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => {
|
||||||
const projectPath = await makeProjectPath();
|
const projectPath = await makeProjectPath();
|
||||||
const storage = createMemoryCodingProjectStorage();
|
const storage = createMemoryCodingProjectStorage();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
estimateImportedModelVisionTokens,
|
estimateImportedModelVisionTokens,
|
||||||
getImportedModelProfile,
|
getImportedModelProfile,
|
||||||
} from '../../shared/imported-model-profile';
|
} from '../../shared/imported-model-profile';
|
||||||
|
import { normalizeImportedModelCapabilities } from '../../shared/user-model-config';
|
||||||
import {
|
import {
|
||||||
getKnownModelCapabilityKind,
|
getKnownModelCapabilityKind,
|
||||||
getModelCapabilityLabel,
|
getModelCapabilityLabel,
|
||||||
@@ -10,6 +11,74 @@ import {
|
|||||||
} from '../../shared/model-capabilities';
|
} from '../../shared/model-capabilities';
|
||||||
|
|
||||||
describe('getImportedModelProfile', () => {
|
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([
|
it.each([
|
||||||
'qwen3.6-plus',
|
'qwen3.6-plus',
|
||||||
'qwen3.6-plus-2026-04-02',
|
'qwen3.6-plus-2026-04-02',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 type { ProviderAccount } from '@electron/shared/providers/types';
|
||||||
import {
|
import {
|
||||||
buildPiProviderCatalog,
|
buildPiProviderCatalog,
|
||||||
@@ -269,11 +271,11 @@ describe('Pi Provider catalog', () => {
|
|||||||
requiresReasoningContentOnAssistantMessages: true,
|
requiresReasoningContentOnAssistantMessages: true,
|
||||||
},
|
},
|
||||||
thinkingLevelMap: {
|
thinkingLevelMap: {
|
||||||
off: null,
|
|
||||||
minimal: null,
|
minimal: null,
|
||||||
low: null,
|
low: 'low',
|
||||||
medium: null,
|
medium: null,
|
||||||
high: 'high',
|
high: 'high',
|
||||||
|
max: 'max',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(written).toMatchObject({
|
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 () => {
|
it('does not replace an existing catalog when model selection is unavailable', async () => {
|
||||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-provider-'));
|
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-provider-'));
|
||||||
temporaryRoots.push(root);
|
temporaryRoots.push(root);
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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';
|
import type { ProviderAccount } from '@electron/shared/providers/types';
|
||||||
|
|
||||||
const providerServiceMock = vi.hoisted(() => ({
|
const providerServiceMock = vi.hoisted(() => ({
|
||||||
getAccount: vi.fn(),
|
getAccount: vi.fn(),
|
||||||
|
getAccountApiKey: vi.fn(),
|
||||||
getDefaultAccountId: vi.fn(),
|
getDefaultAccountId: vi.fn(),
|
||||||
updateAccount: vi.fn(),
|
updateAccount: vi.fn(),
|
||||||
setDefaultAccount: vi.fn(),
|
setDefaultAccount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const proxyAwareFetchMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@electron/services/providers/provider-service', () => ({
|
vi.mock('@electron/services/providers/provider-service', () => ({
|
||||||
getProviderService: () => providerServiceMock,
|
getProviderService: () => providerServiceMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@electron/utils/proxy-fetch', () => ({
|
||||||
|
proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
function createRequest(method: string, body?: unknown): IncomingMessage {
|
function createRequest(method: string, body?: unknown): IncomingMessage {
|
||||||
const request = new EventEmitter();
|
const request = new EventEmitter();
|
||||||
Object.assign(request, {
|
Object.assign(request, {
|
||||||
@@ -66,6 +76,7 @@ describe('provider host api routes', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
providerServiceMock.getAccount.mockResolvedValue(account());
|
providerServiceMock.getAccount.mockResolvedValue(account());
|
||||||
|
providerServiceMock.getAccountApiKey.mockResolvedValue('stored-key');
|
||||||
providerServiceMock.updateAccount.mockResolvedValue(account({
|
providerServiceMock.updateAccount.mockResolvedValue(account({
|
||||||
updatedAt: '2026-08-24T01:00:00.000Z',
|
updatedAt: '2026-08-24T01:00:00.000Z',
|
||||||
}));
|
}));
|
||||||
@@ -73,6 +84,139 @@ describe('provider host api routes', () => {
|
|||||||
providerServiceMock.setDefaultAccount.mockResolvedValue(undefined);
|
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<ProviderAccount>) => ({
|
||||||
|
...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<ProviderAccount>) => ({
|
||||||
|
...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 () => {
|
it('marks Pi provider input stale after an account credential update', async () => {
|
||||||
const result = createResponse();
|
const result = createResponse();
|
||||||
const handled = await handleProviderRoutes(
|
const handled = await handleProviderRoutes(
|
||||||
|
|||||||
Reference in New Issue
Block a user