fix: unblock local news page generation
This commit is contained in:
+42
-1
@@ -516,6 +516,7 @@ export function createAgentRunGateway({
|
||||
observeWorkflowValidation = null,
|
||||
isSessionExternallyBusy = null,
|
||||
validateRunDeliverables = null,
|
||||
cancelSessionOnRetry = null,
|
||||
quiesceSessionOnTerminal = null,
|
||||
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
||||
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
||||
@@ -613,6 +614,35 @@ export function createAgentRunGateway({
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRetrySession(runId, {
|
||||
userId,
|
||||
sessionId,
|
||||
requestId = null,
|
||||
}) {
|
||||
if (!sessionId || typeof cancelSessionOnRetry !== 'function') return;
|
||||
try {
|
||||
const result = await cancelSessionOnRetry({
|
||||
runId,
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
});
|
||||
await appendEvent(runId, 'session_request_cancelled_for_retry', {
|
||||
sessionId,
|
||||
cancellation: result ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
await appendEvent(runId, 'session_retry_cancellation_failed', {
|
||||
sessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}).catch(() => {});
|
||||
console.warn(
|
||||
`[AgentRun] failed to cancel session ${sessionId} before retry:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendExecutionPlanEvent(input, source) {
|
||||
const executionPlan = source?.executionPlan;
|
||||
if (!executionPlan?.dryRun) return;
|
||||
@@ -1563,7 +1593,10 @@ export function createAgentRunGateway({
|
||||
} catch (err) {
|
||||
if (
|
||||
!replacedPoisonedSession
|
||||
&& err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED'
|
||||
&& (
|
||||
err?.code === 'SESSION_TOOL_HISTORY_FRESH_SESSION_REQUIRED'
|
||||
|| err?.code === 'SESSION_REASONING_CONTENT_POISONED'
|
||||
)
|
||||
) {
|
||||
const previousSessionId = sessionId;
|
||||
const repairedConversation = Array.isArray(err?.repairedConversation)
|
||||
@@ -1585,6 +1618,7 @@ export function createAgentRunGateway({
|
||||
await appendEvent(runId, 'poisoned_session_replaced', {
|
||||
previousSessionId,
|
||||
sessionId,
|
||||
reason: err?.code ?? null,
|
||||
repairedMessageCount: repairedConversation.length,
|
||||
persistedMessageCount: persisted.saved,
|
||||
});
|
||||
@@ -1844,6 +1878,13 @@ export function createAgentRunGateway({
|
||||
await appendEvent(runId, 'timeout', { timeoutMs: runTimeoutMs });
|
||||
}
|
||||
const retryable = !timedOut && err?.retryable !== false && nextAttempt < retryDelaysMs.length;
|
||||
if (retryable) {
|
||||
await cancelRetrySession(runId, {
|
||||
userId: row.user_id,
|
||||
sessionId: recoverySessionId,
|
||||
requestId: row.request_id ?? null,
|
||||
});
|
||||
}
|
||||
await markRun(runId, retryable ? 'retryable' : 'failed', {
|
||||
error_message: message,
|
||||
completed_at: retryable ? null : nowMs(),
|
||||
|
||||
@@ -2652,6 +2652,7 @@ test('agent run fails non-retryably when tool gateway artifact validation fails'
|
||||
|
||||
test('agent run retries transient failures and then becomes terminal', async () => {
|
||||
const pool = createFakePool();
|
||||
const retryCancellations = [];
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {},
|
||||
@@ -2663,6 +2664,10 @@ test('agent run retries transient failures and then becomes terminal', async ()
|
||||
throw new Error('upstream unavailable');
|
||||
},
|
||||
},
|
||||
cancelSessionOnRetry: async (input) => {
|
||||
retryCancellations.push(input);
|
||||
return { cancelled: true, skipped: false };
|
||||
},
|
||||
retryDelaysMs: [0, 0],
|
||||
});
|
||||
|
||||
@@ -2674,6 +2679,22 @@ test('agent run retries transient failures and then becomes terminal', async ()
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'failed');
|
||||
assert.equal(pool.runs.get(run.id).attempts, 2);
|
||||
assert.match(pool.runs.get(run.id).error_message, /upstream unavailable/);
|
||||
assert.deepEqual(retryCancellations, [
|
||||
{
|
||||
runId: run.id,
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
requestId: 'req-1',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
pool.events.some(
|
||||
(event) =>
|
||||
event.runId === run.id
|
||||
&& event.eventType === 'session_request_cancelled_for_retry',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('agent run queue limits concurrent execution', async () => {
|
||||
|
||||
@@ -46,7 +46,10 @@ export function shouldKeepStreamingAfterRunError(status, message = '', code = ''
|
||||
// the session SSE is still the source of truth, so recover from the session
|
||||
// instead of showing a terminal error in the chat composer.
|
||||
const text = `${String(code ?? '')} ${String(message ?? '')}`.toLowerCase();
|
||||
if (String(code ?? '').trim() === 'SESSION_RUN_CONFLICT') return false;
|
||||
const normalizedCode = String(code ?? '').trim();
|
||||
if (normalizedCode === 'SESSION_RUN_CONFLICT' || normalizedCode === 'AGENT_RUN_FAILED') {
|
||||
return false;
|
||||
}
|
||||
return text.includes('session already has an active request')
|
||||
|| text.includes('active request. cancel it first');
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ test('only ambiguous transport failures keep the chat streaming', () => {
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepStreamingAfterRunError(
|
||||
undefined,
|
||||
'Session already has an active request. Cancel it first.',
|
||||
'AGENT_RUN_FAILED',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldKeepStreamingAfterRunError(undefined, '后台任务失败'), false);
|
||||
});
|
||||
|
||||
|
||||
+11
-3
@@ -261,16 +261,24 @@ export function coercePageGenerationSkill(classification, text, { grantedSkills
|
||||
if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification;
|
||||
|
||||
const shouldUsePublishSkill =
|
||||
!classification.suggestedSkill || classification.suggestedSkill === 'product-campaign-page';
|
||||
!classification.suggestedSkill
|
||||
|| classification.suggestedSkill === 'product-campaign-page'
|
||||
|| classification.suggestedSkill === 'web';
|
||||
if (!shouldUsePublishSkill) return classification;
|
||||
|
||||
const reasonSuffix = classification.suggestedSkill === 'product-campaign-page'
|
||||
? '(内容页意图,改用 static-page-publish)'
|
||||
: '(内容页生成意图)';
|
||||
: classification.suggestedSkill === 'web'
|
||||
? '(实时资料整理后交付 static-page-publish 页面)'
|
||||
: '(内容页生成意图)';
|
||||
const realtimeBrief = classification.suggestedSkill === 'web'
|
||||
? `${String(classification.agentBrief ?? '').trim()}\n`
|
||||
: '';
|
||||
return {
|
||||
...classification,
|
||||
suggestedSkill: 'static-page-publish',
|
||||
agentBrief: '生成或更新 MindSpace 公开内容页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
|
||||
agentBrief:
|
||||
`${realtimeBrief}生成或更新 MindSpace 公开内容页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。`,
|
||||
reason: `${classification.reason}${reasonSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,6 +232,42 @@ test('coercePageGenerationSkill replaces product-campaign-page for content pages
|
||||
assert.match(coerced.reason, /static-page-publish/);
|
||||
});
|
||||
|
||||
test('coercePageGenerationSkill preserves realtime research while making the page the deliverable', () => {
|
||||
const coerced = coercePageGenerationSkill(
|
||||
{
|
||||
route: CHAT_INTENT_ROUTE.AGENT,
|
||||
suggestedSkill: 'web',
|
||||
reason: '需要搜索实时资料(实时查询强制 web)',
|
||||
agentBrief: '先搜索并核对今天的新闻来源。',
|
||||
confidence: 1,
|
||||
source: 'rule',
|
||||
},
|
||||
'帮我整理今天的新闻做成页面',
|
||||
{ grantedSkills: ['web', 'static-page-publish'] },
|
||||
);
|
||||
assert.equal(coerced.suggestedSkill, 'static-page-publish');
|
||||
assert.match(coerced.agentBrief, /先搜索并核对/);
|
||||
assert.match(coerced.agentBrief, /公开内容页面/);
|
||||
assert.match(coerced.reason, /实时资料整理后交付/);
|
||||
});
|
||||
|
||||
test('createChatIntentRouter keeps static-page-publish for forced realtime page tasks', async () => {
|
||||
const router = createChatIntentRouter();
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我整理今天的新闻做成页面' }],
|
||||
metadata: { displayText: '帮我整理今天的新闻做成页面' },
|
||||
},
|
||||
forceDeepReasoning: true,
|
||||
grantedSkills: ['web', 'static-page-publish'],
|
||||
});
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result.suggestedSkill, 'static-page-publish');
|
||||
assert.match(result.agentBrief, /tkmind_search/);
|
||||
assert.match(result.agentBrief, /公开内容页面/);
|
||||
});
|
||||
|
||||
test('classifyWithRules routes themed article requests to agent orchestration', () => {
|
||||
const result = classifyWithRules({
|
||||
text: '帮我写个主题文章',
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
|
||||
export const DEFAULT_DEEPSEEK_NO_THINK_PORT = 18036;
|
||||
export const DEFAULT_DEEPSEEK_UPSTREAM = 'https://api.deepseek.com';
|
||||
export const DEFAULT_MOONSHOT_UPSTREAM = 'https://api.moonshot.cn';
|
||||
|
||||
const insecureDispatcher = new Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
function envFlag(value, defaultValue = false) {
|
||||
if (value == null || String(value).trim() === '') return defaultValue;
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function deepseekDisableThinkingEnabled(env = process.env) {
|
||||
const raw = env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
if (raw != null && String(raw).trim() !== '') {
|
||||
return envFlag(raw, false);
|
||||
}
|
||||
const runtimeProfile = String(env.MEMIND_RUNTIME_PROFILE ?? '').trim().toLowerCase();
|
||||
return runtimeProfile === 'local' || runtimeProfile === 'split-service';
|
||||
}
|
||||
|
||||
export function moonshotToolSchemaCompatEnabled(env = process.env) {
|
||||
const raw = env.MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT;
|
||||
if (raw != null && String(raw).trim() !== '') {
|
||||
return envFlag(raw, false);
|
||||
}
|
||||
const runtimeProfile = String(env.MEMIND_RUNTIME_PROFILE ?? '').trim().toLowerCase();
|
||||
return runtimeProfile === 'local' || runtimeProfile === 'split-service';
|
||||
}
|
||||
|
||||
export function resolveDeepseekNoThinkListenPort(env = process.env) {
|
||||
const port = Number(env.MEMIND_DEEPSEEK_NO_THINK_PORT ?? DEFAULT_DEEPSEEK_NO_THINK_PORT);
|
||||
return Number.isFinite(port) && port > 0 ? port : DEFAULT_DEEPSEEK_NO_THINK_PORT;
|
||||
}
|
||||
|
||||
export function resolveDeepseekNoThinkProxyBaseUrl(env = process.env) {
|
||||
const explicit = String(env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL ?? '').trim();
|
||||
if (explicit) return explicit.replace(/\/$/, '');
|
||||
const host = String(
|
||||
env.MEMIND_DEEPSEEK_NO_THINK_HOST
|
||||
?? env.MEMIND_GOOSED_HOST_GATEWAY
|
||||
?? 'host.lima.internal',
|
||||
).trim() || 'host.lima.internal';
|
||||
const port = resolveDeepseekNoThinkListenPort(env);
|
||||
return `http://${host}:${port}/v1`;
|
||||
}
|
||||
|
||||
export function resolveMoonshotCompatProxyBaseUrl(env = process.env) {
|
||||
const explicit = String(env.MEMIND_MOONSHOT_COMPAT_BASE_URL ?? '').trim();
|
||||
if (explicit) return explicit.replace(/\/$/, '');
|
||||
const host = String(
|
||||
env.MEMIND_DEEPSEEK_NO_THINK_HOST
|
||||
?? env.MEMIND_GOOSED_HOST_GATEWAY
|
||||
?? 'host.lima.internal',
|
||||
).trim() || 'host.lima.internal';
|
||||
const port = resolveDeepseekNoThinkListenPort(env);
|
||||
return `http://${host}:${port}/moonshot/v1`;
|
||||
}
|
||||
|
||||
export function resolveDeepseekUpstreamBase(env = process.env) {
|
||||
const raw = String(
|
||||
env.MEMIND_DEEPSEEK_UPSTREAM
|
||||
?? env.DEEPSEEK_API_BASE_URL
|
||||
?? DEFAULT_DEEPSEEK_UPSTREAM,
|
||||
).trim().replace(/\/$/, '');
|
||||
return raw.replace(/\/v1$/i, '') || DEFAULT_DEEPSEEK_UPSTREAM;
|
||||
}
|
||||
|
||||
export function resolveMoonshotUpstreamBase(env = process.env) {
|
||||
const raw = String(
|
||||
env.MEMIND_MOONSHOT_UPSTREAM
|
||||
?? DEFAULT_MOONSHOT_UPSTREAM,
|
||||
).trim().replace(/\/$/, '');
|
||||
return raw.replace(/\/v1$/i, '') || DEFAULT_MOONSHOT_UPSTREAM;
|
||||
}
|
||||
|
||||
export function isMoonshotApiUrl(value) {
|
||||
try {
|
||||
const hostname = new URL(String(value ?? '')).hostname.toLowerCase();
|
||||
return hostname === 'api.moonshot.cn' || hostname === 'api.moonshot.ai';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek V4 defaults to thinking mode. Tool rounds then require reasoning_content
|
||||
* to be replayed; goosed can drop it and fail with HTTP 400. Force-disable thinking
|
||||
* unless the caller already set an explicit thinking object.
|
||||
*/
|
||||
export function injectDeepseekThinkingDisabled(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { body, injected: false };
|
||||
}
|
||||
if (body.thinking && typeof body.thinking === 'object') {
|
||||
return { body, injected: false };
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
...body,
|
||||
thinking: { type: 'disabled' },
|
||||
},
|
||||
injected: true,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJsonPointerToken(value) {
|
||||
return String(value ?? '').replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
|
||||
function resolveLocalSchemaRef(root, ref) {
|
||||
if (!String(ref ?? '').startsWith('#/')) return null;
|
||||
const tokens = String(ref)
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map(decodeJsonPointerToken);
|
||||
let current = root;
|
||||
for (const token of tokens) {
|
||||
if (!current || typeof current !== 'object' || !(token in current)) return null;
|
||||
current = current[token];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moonshot rejects otherwise valid local JSON Schema references with
|
||||
* "detected infinite recursion". Inline local references before forwarding
|
||||
* tools so Kimi receives an equivalent, self-contained parameter schema.
|
||||
*/
|
||||
export function flattenLocalJsonSchemaRefs(schema) {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
||||
const root = schema;
|
||||
|
||||
function visit(value, activeRefs = new Set()) {
|
||||
if (Array.isArray(value)) return value.map((item) => visit(item, activeRefs));
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const ref = typeof value.$ref === 'string' ? value.$ref : '';
|
||||
if (ref.startsWith('#/')) {
|
||||
const target = resolveLocalSchemaRef(root, ref);
|
||||
const siblings = Object.fromEntries(
|
||||
Object.entries(value).filter(([key]) => key !== '$ref'),
|
||||
);
|
||||
if (!target || activeRefs.has(ref)) {
|
||||
return visit({
|
||||
type: 'object',
|
||||
description: String(
|
||||
siblings.description ?? 'Recursive schema value',
|
||||
),
|
||||
}, activeRefs);
|
||||
}
|
||||
const nextRefs = new Set(activeRefs);
|
||||
nextRefs.add(ref);
|
||||
const resolved = visit(target, nextRefs);
|
||||
return {
|
||||
...(resolved && typeof resolved === 'object' && !Array.isArray(resolved)
|
||||
? resolved
|
||||
: {}),
|
||||
...visit(siblings, activeRefs),
|
||||
};
|
||||
}
|
||||
|
||||
const output = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === '$schema' || key === '$defs' || key === 'definitions') continue;
|
||||
output[key] = visit(child, activeRefs);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return visit(schema);
|
||||
}
|
||||
|
||||
export function sanitizeMoonshotToolSchemas(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body) || !Array.isArray(body.tools)) {
|
||||
return { body, sanitized: 0 };
|
||||
}
|
||||
let sanitized = 0;
|
||||
const tools = body.tools.map((tool) => {
|
||||
const parameters = tool?.function?.parameters;
|
||||
if (!parameters || typeof parameters !== 'object') return tool;
|
||||
const serialized = JSON.stringify(parameters);
|
||||
if (!serialized.includes('"$ref"') && !serialized.includes('"$defs"')) return tool;
|
||||
sanitized += 1;
|
||||
return {
|
||||
...tool,
|
||||
function: {
|
||||
...tool.function,
|
||||
parameters: flattenLocalJsonSchemaRefs(parameters),
|
||||
},
|
||||
};
|
||||
});
|
||||
return sanitized > 0
|
||||
? { body: { ...body, tools }, sanitized }
|
||||
: { body, sanitized: 0 };
|
||||
}
|
||||
|
||||
function readRequestBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(upstreamBase, reqUrl) {
|
||||
const incoming = new URL(reqUrl || '/', 'http://127.0.0.1');
|
||||
let pathname = incoming.pathname || '/';
|
||||
// Accept both /v1/chat/completions and /chat/completions from OpenAI-compatible clients.
|
||||
if (!pathname.startsWith('/v1/') && pathname !== '/v1') {
|
||||
pathname = pathname === '/' ? '/v1' : `/v1${pathname}`;
|
||||
}
|
||||
return `${upstreamBase}${pathname}${incoming.search}`;
|
||||
}
|
||||
|
||||
export function decodedUpstreamResponseHeaders(headers) {
|
||||
const responseHeaders = {};
|
||||
headers?.forEach?.((value, key) => {
|
||||
const normalized = String(key).toLowerCase();
|
||||
// undici transparently decodes gzip/br but keeps the upstream encoding
|
||||
// headers. Forwarding them makes the downstream client decode plaintext
|
||||
// again and silently lose the SSE stream.
|
||||
if (
|
||||
normalized === 'transfer-encoding'
|
||||
|| normalized === 'content-encoding'
|
||||
|| normalized === 'content-length'
|
||||
|| normalized === 'connection'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
return responseHeaders;
|
||||
}
|
||||
|
||||
export function createDeepseekNoThinkProxy({
|
||||
upstreamBase = resolveDeepseekUpstreamBase(),
|
||||
moonshotUpstreamBase = resolveMoonshotUpstreamBase(),
|
||||
fetchImpl = undiciFetch,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const normalizedUpstream = String(upstreamBase || DEFAULT_DEEPSEEK_UPSTREAM).replace(/\/$/, '');
|
||||
const normalizedMoonshotUpstream = String(
|
||||
moonshotUpstreamBase || DEFAULT_MOONSHOT_UPSTREAM,
|
||||
).replace(/\/$/, '');
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method === 'GET' && (req.url === '/health' || req.url === '/healthz')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
upstream: normalizedUpstream,
|
||||
moonshotUpstream: normalizedMoonshotUpstream,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const isMoonshot = incoming.pathname === '/moonshot'
|
||||
|| incoming.pathname.startsWith('/moonshot/');
|
||||
const routedRequestUrl = isMoonshot
|
||||
? `${incoming.pathname.slice('/moonshot'.length) || '/'}${incoming.search}`
|
||||
: req.url;
|
||||
const upstreamUrl = buildUpstreamUrl(
|
||||
isMoonshot ? normalizedMoonshotUpstream : normalizedUpstream,
|
||||
routedRequestUrl,
|
||||
);
|
||||
const headers = { ...req.headers };
|
||||
delete headers.host;
|
||||
delete headers['content-length'];
|
||||
|
||||
let body;
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
const raw = await readRequestBody(req);
|
||||
if (raw.length > 0 && /chat\/completions/i.test(upstreamUrl)) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw.toString('utf8'));
|
||||
if (isMoonshot) {
|
||||
const schemaResult = sanitizeMoonshotToolSchemas(parsed);
|
||||
const thinkingResult = injectDeepseekThinkingDisabled(schemaResult.body);
|
||||
body = Buffer.from(JSON.stringify(thinkingResult.body), 'utf8');
|
||||
if (schemaResult.sanitized > 0) {
|
||||
logger.info?.(
|
||||
`[moonshot-compat] inlined local refs in ${schemaResult.sanitized} tool schema(s) model=${String(parsed?.model ?? '')}`,
|
||||
);
|
||||
}
|
||||
if (thinkingResult.injected) {
|
||||
logger.info?.(
|
||||
`[moonshot-compat] injected thinking.disabled model=${String(parsed?.model ?? '')}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const { body: next, injected } = injectDeepseekThinkingDisabled(parsed);
|
||||
body = Buffer.from(JSON.stringify(next), 'utf8');
|
||||
if (injected) {
|
||||
logger.info?.(
|
||||
`[deepseek-no-think] injected thinking.disabled model=${String(parsed?.model ?? '')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
body = raw;
|
||||
}
|
||||
} else {
|
||||
body = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = await fetchImpl(upstreamUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
dispatcher: upstreamUrl.startsWith('https://') ? insecureDispatcher : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = decodedUpstreamResponseHeaders(upstream.headers);
|
||||
res.writeHead(upstream.status, responseHeaders);
|
||||
if (!upstream.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const reader = upstream.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(Buffer.from(value));
|
||||
}
|
||||
res.end();
|
||||
} catch (err) {
|
||||
logger.warn?.('[deepseek-no-think] stream error', err instanceof Error ? err.message : err);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, upstreamBase: normalizedUpstream };
|
||||
}
|
||||
|
||||
export function startDeepseekNoThinkProxy({
|
||||
port = resolveDeepseekNoThinkListenPort(),
|
||||
host = '0.0.0.0',
|
||||
upstreamBase = resolveDeepseekUpstreamBase(),
|
||||
moonshotUpstreamBase = resolveMoonshotUpstreamBase(),
|
||||
fetchImpl = undiciFetch,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const proxy = createDeepseekNoThinkProxy({
|
||||
upstreamBase,
|
||||
moonshotUpstreamBase,
|
||||
fetchImpl,
|
||||
logger,
|
||||
});
|
||||
const server = http.createServer((req, res) => {
|
||||
proxy.handle(req, res).catch((err) => {
|
||||
logger.error?.('[deepseek-no-think] request failed', err instanceof Error ? err.message : err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
res.end(JSON.stringify({
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : 'deepseek no-think proxy failed',
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
logger.info?.(
|
||||
`[llm-compat-proxy] listening on http://${host}:${port} -> ${proxy.upstreamBase}`,
|
||||
);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const isMain = Boolean(
|
||||
process.argv[1]
|
||||
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]),
|
||||
);
|
||||
if (isMain) {
|
||||
startDeepseekNoThinkProxy().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
decodedUpstreamResponseHeaders,
|
||||
deepseekDisableThinkingEnabled,
|
||||
flattenLocalJsonSchemaRefs,
|
||||
injectDeepseekThinkingDisabled,
|
||||
isMoonshotApiUrl,
|
||||
moonshotToolSchemaCompatEnabled,
|
||||
resolveDeepseekNoThinkProxyBaseUrl,
|
||||
resolveMoonshotCompatProxyBaseUrl,
|
||||
sanitizeMoonshotToolSchemas,
|
||||
} from './deepseek-no-think-proxy.mjs';
|
||||
|
||||
test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () => {
|
||||
const { body, injected } = injectDeepseekThinkingDisabled({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
assert.equal(injected, true);
|
||||
assert.deepEqual(body.thinking, { type: 'disabled' });
|
||||
assert.equal(body.model, 'deepseek-v4-flash');
|
||||
});
|
||||
|
||||
test('injectDeepseekThinkingDisabled preserves explicit thinking config', () => {
|
||||
const { body, injected } = injectDeepseekThinkingDisabled({
|
||||
model: 'deepseek-v4-pro',
|
||||
thinking: { type: 'enabled' },
|
||||
});
|
||||
assert.equal(injected, false);
|
||||
assert.deepEqual(body.thinking, { type: 'enabled' });
|
||||
});
|
||||
|
||||
test('deepseekDisableThinkingEnabled defaults on for local runtime profile', () => {
|
||||
assert.equal(deepseekDisableThinkingEnabled({ MEMIND_RUNTIME_PROFILE: 'local' }), true);
|
||||
assert.equal(deepseekDisableThinkingEnabled({ MEMIND_RUNTIME_PROFILE: 'split-service' }), true);
|
||||
assert.equal(deepseekDisableThinkingEnabled({ MEMIND_RUNTIME_PROFILE: 'production' }), false);
|
||||
assert.equal(
|
||||
deepseekDisableThinkingEnabled({
|
||||
MEMIND_RUNTIME_PROFILE: 'local',
|
||||
MEMIND_DEEPSEEK_DISABLE_THINKING: '0',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
deepseekDisableThinkingEnabled({
|
||||
MEMIND_RUNTIME_PROFILE: 'production',
|
||||
MEMIND_DEEPSEEK_DISABLE_THINKING: '1',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('moonshotToolSchemaCompatEnabled defaults on for local runtime profiles', () => {
|
||||
assert.equal(moonshotToolSchemaCompatEnabled({ MEMIND_RUNTIME_PROFILE: 'local' }), true);
|
||||
assert.equal(moonshotToolSchemaCompatEnabled({ MEMIND_RUNTIME_PROFILE: 'split-service' }), true);
|
||||
assert.equal(moonshotToolSchemaCompatEnabled({ MEMIND_RUNTIME_PROFILE: 'production' }), false);
|
||||
assert.equal(
|
||||
moonshotToolSchemaCompatEnabled({
|
||||
MEMIND_RUNTIME_PROFILE: 'local',
|
||||
MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT: '0',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDeepseekNoThinkProxyBaseUrl uses host gateway defaults', () => {
|
||||
assert.equal(
|
||||
resolveDeepseekNoThinkProxyBaseUrl({}),
|
||||
'http://host.lima.internal:18036/v1',
|
||||
);
|
||||
assert.equal(
|
||||
resolveDeepseekNoThinkProxyBaseUrl({
|
||||
MEMIND_DEEPSEEK_NO_THINK_BASE_URL: 'http://10.0.0.2:9/v1/',
|
||||
}),
|
||||
'http://10.0.0.2:9/v1',
|
||||
);
|
||||
});
|
||||
|
||||
test('Moonshot compat routing recognizes official endpoints and uses a distinct path', () => {
|
||||
assert.equal(isMoonshotApiUrl('https://api.moonshot.cn/v1'), true);
|
||||
assert.equal(isMoonshotApiUrl('https://api.moonshot.ai/v1/'), true);
|
||||
assert.equal(isMoonshotApiUrl('https://api.deepseek.com/v1'), false);
|
||||
assert.equal(
|
||||
resolveMoonshotCompatProxyBaseUrl({}),
|
||||
'http://host.lima.internal:18036/moonshot/v1',
|
||||
);
|
||||
});
|
||||
|
||||
test('flattenLocalJsonSchemaRefs inlines local definitions without mutating the source', () => {
|
||||
const source = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
description: 'command override',
|
||||
$ref: '#/$defs/CacheCommand',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
$defs: {
|
||||
CacheCommand: {
|
||||
type: 'string',
|
||||
oneOf: [{ const: 'list' }, { const: 'clear' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const flattened = flattenLocalJsonSchemaRefs(source);
|
||||
assert.equal(JSON.stringify(flattened).includes('$ref'), false);
|
||||
assert.equal(JSON.stringify(flattened).includes('$defs'), false);
|
||||
assert.equal(flattened.properties.command.type, 'string');
|
||||
assert.equal(flattened.properties.command.description, 'command override');
|
||||
assert.equal(source.properties.command.$ref, '#/$defs/CacheCommand');
|
||||
});
|
||||
|
||||
test('sanitizeMoonshotToolSchemas only rewrites function schemas with local refs', () => {
|
||||
const plain = {
|
||||
type: 'function',
|
||||
function: { name: 'plain', parameters: { type: 'object' } },
|
||||
};
|
||||
const body = {
|
||||
model: 'kimi-k2.6',
|
||||
tools: [
|
||||
plain,
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'cache',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { $ref: '#/$defs/Command' } },
|
||||
$defs: { Command: { type: 'string', enum: ['list', 'clear'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = sanitizeMoonshotToolSchemas(body);
|
||||
assert.equal(result.sanitized, 1);
|
||||
assert.equal(result.body.tools[0], plain);
|
||||
assert.deepEqual(
|
||||
result.body.tools[1].function.parameters.properties.command,
|
||||
{ type: 'string', enum: ['list', 'clear'] },
|
||||
);
|
||||
});
|
||||
|
||||
test('Moonshot requests can reuse thinking.disabled injection', () => {
|
||||
const schemaResult = sanitizeMoonshotToolSchemas({
|
||||
model: 'kimi-k2.6',
|
||||
tools: [],
|
||||
});
|
||||
const thinkingResult = injectDeepseekThinkingDisabled(schemaResult.body);
|
||||
assert.equal(thinkingResult.injected, true);
|
||||
assert.deepEqual(thinkingResult.body.thinking, { type: 'disabled' });
|
||||
});
|
||||
|
||||
test('decodedUpstreamResponseHeaders drops stale compression metadata', () => {
|
||||
const headers = new Headers({
|
||||
'content-type': 'text/event-stream',
|
||||
'content-encoding': 'br',
|
||||
'content-length': '123',
|
||||
'transfer-encoding': 'chunked',
|
||||
connection: 'keep-alive',
|
||||
'cache-control': 'no-cache',
|
||||
});
|
||||
assert.deepEqual(decodedUpstreamResponseHeaders(headers), {
|
||||
'cache-control': 'no-cache',
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
});
|
||||
+84
-7
@@ -4,6 +4,15 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
deepseekDisableThinkingEnabled,
|
||||
isMoonshotApiUrl,
|
||||
moonshotToolSchemaCompatEnabled,
|
||||
resolveDeepseekNoThinkProxyBaseUrl,
|
||||
resolveMoonshotCompatProxyBaseUrl,
|
||||
} from './deepseek-no-think-proxy.mjs';
|
||||
|
||||
export const MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID = 'custom_memind_deepseek_no_think';
|
||||
|
||||
export const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
|
||||
@@ -765,6 +774,9 @@ async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetch
|
||||
requires_auth: true,
|
||||
...(profile.basePath ? { base_path: profile.basePath } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
...(typeof profile.preservesThinking === 'boolean'
|
||||
? { preserves_thinking: profile.preservesThinking }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (profile.goosedProviderId) {
|
||||
@@ -775,11 +787,12 @@ async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetch
|
||||
{ method: 'PUT', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream.ok) {
|
||||
if (upstream.ok) return profile.goosedProviderId;
|
||||
// First-time ensure: fixed ids may 404 until created.
|
||||
if (upstream.status !== 404) {
|
||||
const text = await upstream.text().catch(() => '');
|
||||
throw new Error(`更新 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
|
||||
}
|
||||
return profile.goosedProviderId;
|
||||
}
|
||||
|
||||
const upstream = await goosedApiFetch(
|
||||
@@ -794,7 +807,51 @@ async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetch
|
||||
throw new Error(`创建 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
|
||||
}
|
||||
const data = await upstream.json();
|
||||
return data.provider_name;
|
||||
return data.provider_name ?? profile.goosedProviderId;
|
||||
}
|
||||
|
||||
async function syncDeepseekNoThinkProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
||||
const catalogItem = catalogById.custom_deepseek;
|
||||
const models = [
|
||||
...new Set([
|
||||
...(Array.isArray(catalogItem?.models) ? catalogItem.models : []),
|
||||
profile.defaultModel,
|
||||
...(Array.isArray(profile.models) ? profile.models : []),
|
||||
].filter(Boolean).map((item) => String(item).trim()).filter(Boolean)),
|
||||
];
|
||||
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
{
|
||||
name: 'memind_deepseek_no_think',
|
||||
goosedProviderId: MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID,
|
||||
apiUrl: resolveDeepseekNoThinkProxyBaseUrl(),
|
||||
apiKey: profile.apiKey,
|
||||
models,
|
||||
defaultModel: profile.defaultModel,
|
||||
engine: 'openai',
|
||||
preservesThinking: false,
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
// Keep builtin secret for direct DeepSeek callers / admin tests.
|
||||
if (catalogItem?.apiKeyEnv) {
|
||||
await writeGoosedConfig(
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
catalogItem.apiKeyEnv,
|
||||
profile.apiKey,
|
||||
true,
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_PROVIDER', goosedProviderId, false, fetchImpl);
|
||||
await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_MODEL', profile.defaultModel, false, fetchImpl);
|
||||
await writeGoosedConfig(apiTarget, apiSecret, 'TKMIND_PROVIDER', goosedProviderId, false, fetchImpl);
|
||||
await writeGoosedConfig(apiTarget, apiSecret, 'TKMIND_MODEL', profile.defaultModel, false, fetchImpl);
|
||||
// Avoid DeepSeek thinking mode when the proxy is unavailable for a turn.
|
||||
await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_THINKING_EFFORT', 'off', false, fetchImpl);
|
||||
return goosedProviderId;
|
||||
}
|
||||
|
||||
async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) {
|
||||
@@ -813,6 +870,12 @@ async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProvider
|
||||
}
|
||||
|
||||
async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
||||
if (
|
||||
profile.providerId === 'custom_deepseek'
|
||||
&& deepseekDisableThinkingEnabled()
|
||||
) {
|
||||
return syncDeepseekNoThinkProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
|
||||
}
|
||||
const catalogItem = catalogById[profile.providerId];
|
||||
if (!catalogItem?.apiKeyEnv) {
|
||||
throw new Error(`未知内置 provider: ${profile.providerId}`);
|
||||
@@ -858,13 +921,22 @@ async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchIm
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch) {
|
||||
if (profile.providerKind === 'custom') {
|
||||
const goosedProfile = (
|
||||
moonshotToolSchemaCompatEnabled()
|
||||
&& isMoonshotApiUrl(profile.apiUrl)
|
||||
)
|
||||
? {
|
||||
...profile,
|
||||
apiUrl: resolveMoonshotCompatProxyBaseUrl(),
|
||||
preservesThinking: false,
|
||||
}
|
||||
: profile;
|
||||
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
profile,
|
||||
goosedProfile,
|
||||
fetchImpl,
|
||||
);
|
||||
await writeGoosedConfig(
|
||||
@@ -902,8 +974,13 @@ export async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchIm
|
||||
return goosedProviderId;
|
||||
}
|
||||
|
||||
await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
|
||||
return profile.providerId;
|
||||
const syncedProviderId = await syncBuiltinProfileToGoosed(
|
||||
apiTarget,
|
||||
apiSecret,
|
||||
profile,
|
||||
fetchImpl,
|
||||
);
|
||||
return syncedProviderId || profile.providerId;
|
||||
}
|
||||
|
||||
function isCustomPayload(payload) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createLlmProviderService,
|
||||
CUSTOM_PROVIDER_ID,
|
||||
MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID,
|
||||
buildExecutorLaunchPlan,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
@@ -753,6 +754,136 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a
|
||||
);
|
||||
});
|
||||
|
||||
test('syncProfileToGoosed routes local DeepSeek through the no-thinking proxy', async () => {
|
||||
const previousProfile = process.env.MEMIND_RUNTIME_PROFILE;
|
||||
const previousDisableThinking = process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
const previousProxyBase = process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
const previousGateway = process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'local';
|
||||
delete process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
delete process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
process.env.MEMIND_GOOSED_HOST_GATEWAY = 'host.lima.internal';
|
||||
|
||||
try {
|
||||
const calls = [];
|
||||
const mockFetch = async (url, init = {}) => {
|
||||
const href = String(url);
|
||||
const body = init.body ? JSON.parse(init.body) : null;
|
||||
calls.push({ href, method: init.method ?? 'GET', body });
|
||||
if (
|
||||
init.method === 'PUT'
|
||||
&& href.includes(`/config/custom-providers/${MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID}`)
|
||||
) {
|
||||
return { ok: false, status: 404, text: async () => '' };
|
||||
}
|
||||
if (init.method === 'POST' && href.endsWith('/config/custom-providers')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ provider_name: MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID }),
|
||||
text: async () => '',
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => '' };
|
||||
};
|
||||
|
||||
const providerId = await syncProfileToGoosed(
|
||||
'https://127.0.0.1:18006',
|
||||
'secret',
|
||||
{
|
||||
providerKind: 'builtin',
|
||||
providerId: 'custom_deepseek',
|
||||
defaultModel: 'deepseek-v4-pro',
|
||||
apiKey: 'sk-test',
|
||||
},
|
||||
mockFetch,
|
||||
);
|
||||
|
||||
assert.equal(providerId, MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID);
|
||||
const createCall = calls.find(
|
||||
(call) => call.method === 'POST' && call.href.endsWith('/config/custom-providers'),
|
||||
);
|
||||
assert.equal(createCall?.body?.api_url, 'http://host.lima.internal:18036/v1');
|
||||
assert.equal(createCall?.body?.preserves_thinking, false);
|
||||
const providerWrite = calls.find(
|
||||
(call) => call.href.includes('/config/upsert') && call.body?.key === 'GOOSE_PROVIDER',
|
||||
);
|
||||
assert.equal(providerWrite?.body?.value, MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID);
|
||||
} finally {
|
||||
if (previousProfile == null) delete process.env.MEMIND_RUNTIME_PROFILE;
|
||||
else process.env.MEMIND_RUNTIME_PROFILE = previousProfile;
|
||||
if (previousDisableThinking == null) delete process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
else process.env.MEMIND_DEEPSEEK_DISABLE_THINKING = previousDisableThinking;
|
||||
if (previousProxyBase == null) delete process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
else process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL = previousProxyBase;
|
||||
if (previousGateway == null) delete process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
else process.env.MEMIND_GOOSED_HOST_GATEWAY = previousGateway;
|
||||
}
|
||||
});
|
||||
|
||||
test('syncProfileToGoosed routes local Moonshot profiles through schema compat proxy', async () => {
|
||||
const previousProfile = process.env.MEMIND_RUNTIME_PROFILE;
|
||||
const previousCompat = process.env.MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT;
|
||||
const previousBase = process.env.MEMIND_MOONSHOT_COMPAT_BASE_URL;
|
||||
const previousGateway = process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'split-service';
|
||||
delete process.env.MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT;
|
||||
delete process.env.MEMIND_MOONSHOT_COMPAT_BASE_URL;
|
||||
process.env.MEMIND_GOOSED_HOST_GATEWAY = 'host.lima.internal';
|
||||
|
||||
try {
|
||||
const calls = [];
|
||||
const mockFetch = async (url, init = {}) => {
|
||||
const href = String(url);
|
||||
const body = init.body ? JSON.parse(init.body) : null;
|
||||
calls.push({ href, method: init.method ?? 'GET', body });
|
||||
if (
|
||||
init.method === 'PUT'
|
||||
&& href.includes('/config/custom-providers/custom_kimi')
|
||||
) {
|
||||
return { ok: true, status: 200, text: async () => '' };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => '' };
|
||||
};
|
||||
|
||||
const providerId = await syncProfileToGoosed(
|
||||
'https://127.0.0.1:18006',
|
||||
'secret',
|
||||
{
|
||||
providerKind: 'custom',
|
||||
providerId: 'custom_kimi',
|
||||
goosedProviderId: 'custom_kimi',
|
||||
name: 'kimi',
|
||||
apiUrl: 'https://api.moonshot.cn/v1',
|
||||
apiKey: 'sk-test',
|
||||
models: ['kimi-k2.6', 'kimi-k3'],
|
||||
defaultModel: 'kimi-k2.6',
|
||||
engine: 'openai',
|
||||
},
|
||||
mockFetch,
|
||||
);
|
||||
|
||||
assert.equal(providerId, 'custom_kimi');
|
||||
const updateCall = calls.find(
|
||||
(call) => call.method === 'PUT'
|
||||
&& call.href.includes('/config/custom-providers/custom_kimi'),
|
||||
);
|
||||
assert.equal(
|
||||
updateCall?.body?.api_url,
|
||||
'http://host.lima.internal:18036/moonshot/v1',
|
||||
);
|
||||
assert.equal(updateCall?.body?.preserves_thinking, false);
|
||||
} finally {
|
||||
if (previousProfile == null) delete process.env.MEMIND_RUNTIME_PROFILE;
|
||||
else process.env.MEMIND_RUNTIME_PROFILE = previousProfile;
|
||||
if (previousCompat == null) delete process.env.MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT;
|
||||
else process.env.MEMIND_MOONSHOT_TOOL_SCHEMA_COMPAT = previousCompat;
|
||||
if (previousBase == null) delete process.env.MEMIND_MOONSHOT_COMPAT_BASE_URL;
|
||||
else process.env.MEMIND_MOONSHOT_COMPAT_BASE_URL = previousBase;
|
||||
if (previousGateway == null) delete process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
else process.env.MEMIND_GOOSED_HOST_GATEWAY = previousGateway;
|
||||
}
|
||||
});
|
||||
|
||||
test('syncSelectedToGoosed writes selected provider to every configured target', async () => {
|
||||
const encrypted = encryptSecret('sk-test', 'unit-test-secret');
|
||||
const row = {
|
||||
|
||||
+45
-8
@@ -8,14 +8,28 @@ import {
|
||||
describeMemindRuntimeProfile,
|
||||
loadMemindEnvFiles,
|
||||
} from './memind-runtime-profile.mjs';
|
||||
import {
|
||||
deepseekDisableThinkingEnabled,
|
||||
resolveDeepseekNoThinkListenPort,
|
||||
} from '../deepseek-no-think-proxy.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const opsDir = path.join(root, 'ops');
|
||||
|
||||
// Load .env before reading ports — otherwise ADMIN_PORT defaults to 8082 and
|
||||
// split-service MindSpace (also on 8082) gets killed by freePort().
|
||||
loadMemindEnvFiles(root);
|
||||
applyMemindRuntimeProfile({ rootDir: root });
|
||||
console.log(`==> Runtime profile: ${describeMemindRuntimeProfile()}`);
|
||||
|
||||
const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const opsPort = Number(process.env.OPS_PORT ?? 3002);
|
||||
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const deepseekNoThinkEnabled = deepseekDisableThinkingEnabled();
|
||||
const deepseekNoThinkPort = resolveDeepseekNoThinkListenPort();
|
||||
const deepseekNoThinkUrl = `http://127.0.0.1:${deepseekNoThinkPort}`;
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
||||
const localMindSpacePublicBase = `http://127.0.0.1:${vitePort}`;
|
||||
@@ -23,10 +37,6 @@ const plazaPublicBase = (
|
||||
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
loadMemindEnvFiles(root);
|
||||
applyMemindRuntimeProfile({ rootDir: root });
|
||||
console.log(`==> Runtime profile: ${describeMemindRuntimeProfile()}`);
|
||||
|
||||
function freePort(port) {
|
||||
try {
|
||||
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
|
||||
@@ -55,6 +65,7 @@ let server;
|
||||
let admin;
|
||||
let ops;
|
||||
let vite;
|
||||
let deepseekNoThinkProxy;
|
||||
let stopping = false;
|
||||
|
||||
function shutdown(code = 0) {
|
||||
@@ -64,6 +75,7 @@ function shutdown(code = 0) {
|
||||
admin?.kill('SIGTERM');
|
||||
ops?.kill('SIGTERM');
|
||||
vite?.kill('SIGTERM');
|
||||
deepseekNoThinkProxy?.kill('SIGTERM');
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
}
|
||||
|
||||
@@ -105,17 +117,39 @@ const opsEnv = {
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
};
|
||||
|
||||
console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${opsPort}...`);
|
||||
const managedPorts = [
|
||||
portalPort,
|
||||
adminPort,
|
||||
vitePort,
|
||||
opsPort,
|
||||
...(deepseekNoThinkEnabled ? [deepseekNoThinkPort] : []),
|
||||
];
|
||||
console.log(`==> 清理端口 ${managedPorts.join(' / ')}...`);
|
||||
freePort(portalPort);
|
||||
freePort(adminPort);
|
||||
freePort(vitePort);
|
||||
freePort(opsPort);
|
||||
if (deepseekNoThinkEnabled) freePort(deepseekNoThinkPort);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
console.log('==> 启动 portal (server.mjs)...');
|
||||
server = spawnChild('node', [path.join(root, 'server.mjs')], 'portal');
|
||||
|
||||
try {
|
||||
if (deepseekNoThinkEnabled) {
|
||||
console.log('==> 启动 DeepSeek no-thinking proxy...');
|
||||
deepseekNoThinkProxy = spawnChild(
|
||||
'node',
|
||||
[path.join(root, 'deepseek-no-think-proxy.mjs')],
|
||||
'deepseek-no-think',
|
||||
);
|
||||
await waitFor(
|
||||
deepseekNoThinkUrl,
|
||||
async (url) => (await fetch(`${url}/health`)).ok,
|
||||
'DeepSeek no-thinking proxy',
|
||||
);
|
||||
console.log(`==> DeepSeek no-thinking proxy 就绪: ${deepseekNoThinkUrl}`);
|
||||
}
|
||||
|
||||
console.log('==> 启动 portal (server.mjs)...');
|
||||
server = spawnChild('node', [path.join(root, 'server.mjs')], 'portal');
|
||||
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
|
||||
console.log(`==> Portal 就绪: ${portalUrl}`);
|
||||
|
||||
@@ -156,6 +190,9 @@ try {
|
||||
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
|
||||
console.log(` API / Portal ${portalUrl}`);
|
||||
console.log(` memind_adm ${adminUrl}`);
|
||||
if (deepseekNoThinkEnabled) {
|
||||
console.log(` DeepSeek Proxy ${deepseekNoThinkUrl}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('Plaza 已独立出去:请另开终端运行 pnpm dev:plaza 或 pnpm start:plaza');
|
||||
} catch (err) {
|
||||
|
||||
@@ -360,6 +360,27 @@ export function bootstrapPortalGatewayServices({
|
||||
isSessionExternallyBusy: ({ sessionId }) =>
|
||||
isSessionPageDeliveryActive(sessionId),
|
||||
validateRunDeliverables,
|
||||
cancelSessionOnRetry: async ({
|
||||
sessionId,
|
||||
requestId,
|
||||
}) => {
|
||||
if (isDirectChatSessionId(sessionId)) {
|
||||
return { cancelled: false, skipped: true };
|
||||
}
|
||||
const target =
|
||||
await tkmindProxy.resolveTarget(sessionId);
|
||||
const sessionApiFetch = (pathname, init) =>
|
||||
tkmindProxy.apiFetchTo(
|
||||
target,
|
||||
pathname,
|
||||
init,
|
||||
);
|
||||
return cancelSessionActiveRequest(
|
||||
sessionApiFetch,
|
||||
sessionId,
|
||||
requestId,
|
||||
);
|
||||
},
|
||||
quiesceSessionOnTerminal: async ({
|
||||
sessionId,
|
||||
requestId,
|
||||
|
||||
+15
-4
@@ -33,6 +33,19 @@ function messageContentItems(event) {
|
||||
return candidates.find((content) => Array.isArray(content)) ?? [];
|
||||
}
|
||||
|
||||
export function classifySessionProviderErrorMessage(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return 'SESSION_REPLY_ERROR';
|
||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) {
|
||||
return 'SESSION_TOOL_HISTORY_POISONED';
|
||||
}
|
||||
// DeepSeek/Kimi thinking mode: assistant tool-call turns must replay reasoning_content.
|
||||
if (/reasoning_content/i.test(normalized)) {
|
||||
return 'SESSION_REASONING_CONTENT_POISONED';
|
||||
}
|
||||
return 'SESSION_REPLY_ERROR';
|
||||
}
|
||||
|
||||
function providerReplyError(event) {
|
||||
if (event?.type !== 'Message') return null;
|
||||
const text = messageContentItems(event)
|
||||
@@ -46,9 +59,7 @@ function providerReplyError(event) {
|
||||
.replace(/\n\nPlease retry if you think this is a transient or recoverable error\.?\s*$/i, '')
|
||||
.trim();
|
||||
const error = new Error(normalized || 'session reply failed');
|
||||
error.code = /tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)
|
||||
? 'SESSION_TOOL_HISTORY_POISONED'
|
||||
: 'SESSION_REPLY_ERROR';
|
||||
error.code = classifySessionProviderErrorMessage(normalized);
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -162,7 +173,7 @@ export async function consumeSessionEventsUntilFinish(
|
||||
onEvent?.(event);
|
||||
if (event.type === 'Error') {
|
||||
const err = new Error(String(event.error ?? 'session reply failed'));
|
||||
err.code = 'SESSION_REPLY_ERROR';
|
||||
err.code = classifySessionProviderErrorMessage(err.message);
|
||||
err.retryable = false;
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,38 @@ test('consumeSessionEventsUntilFinish records a successful raster generate_image
|
||||
assert.equal(result.toolEvidence.generateImage.jobId, 'job-1');
|
||||
});
|
||||
|
||||
test('consumeSessionEventsUntilFinish surfaces reasoning_content protocol errors before Finish', async () => {
|
||||
const frames = [
|
||||
`data: ${JSON.stringify({
|
||||
type: 'Message',
|
||||
request_id: 'req-1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Ran into this error: Bad request (400): The reasoning_content in the thinking mode must be passed back to the API.\n\nPlease retry if you think this is a transient or recoverable error.',
|
||||
}],
|
||||
},
|
||||
})}\n\n`,
|
||||
'data: {"type":"Finish","request_id":"req-1"}\n\n',
|
||||
];
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 }),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'SESSION_REASONING_CONTENT_POISONED');
|
||||
assert.match(error.message, /reasoning_content/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('consumeSessionEventsUntilFinish surfaces provider tool history errors before Finish', async () => {
|
||||
const frames = [
|
||||
`data: ${JSON.stringify({
|
||||
|
||||
@@ -88,6 +88,20 @@ const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
|
||||
const ACTIVE_REQUEST_MISSING_GRACE_MS = 2500;
|
||||
|
||||
function createAgentRunFailedError(message: string) {
|
||||
const error = new Error(message) as Error & { code: string };
|
||||
error.code = 'AGENT_RUN_FAILED';
|
||||
return error;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
if (error instanceof ApiError) return error.code;
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
return String((error as { code?: unknown }).code ?? '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_';
|
||||
export { INSUFFICIENT_BALANCE_NOTICE };
|
||||
|
||||
@@ -198,7 +212,7 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
return;
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试')));
|
||||
settle(() => reject(createAgentRunFailedError(run.error || '后台任务失败,请稍后重试')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,7 +249,7 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
return;
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试')));
|
||||
settle(() => reject(createAgentRunFailedError(run.error || '后台任务失败,请稍后重试')));
|
||||
return;
|
||||
}
|
||||
settle(() =>
|
||||
@@ -1671,7 +1685,7 @@ export function useTKMindChat(
|
||||
shouldKeepStreamingAfterRunError(
|
||||
undefined,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
err instanceof ApiError ? err.code : '',
|
||||
errorCode(err),
|
||||
)
|
||||
) {
|
||||
// Goose may report its session concurrency guard as a failed run
|
||||
|
||||
+35
-20
@@ -1896,28 +1896,43 @@ export function createTkmindProxy({
|
||||
requestId,
|
||||
timeoutMs,
|
||||
});
|
||||
const replyResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
// Prevent unhandled rejection from killing the Portal process when reply
|
||||
// setup fails before we await Finish (e.g. provider Error frames arrive later).
|
||||
const trackedFinish = finishPromise.then(
|
||||
(value) => ({ ok: true, value }),
|
||||
(error) => ({ ok: false, error }),
|
||||
);
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(text || `发送失败 (${replyResponse.status})`);
|
||||
try {
|
||||
const replyResponse = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
eventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
throw new Error(text || `发送失败 (${replyResponse.status})`);
|
||||
}
|
||||
replyResponse.body?.cancel?.().catch?.(() => {});
|
||||
const finishResult = await trackedFinish;
|
||||
if (!finishResult.ok) throw finishResult.error;
|
||||
const finish = finishResult.value;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
if (tokenState && userAuth.billSessionUsage) {
|
||||
await userAuth
|
||||
.billSessionUsage(userId, sessionId, tokenState, requestId)
|
||||
.catch(() => {});
|
||||
}
|
||||
return { ok: true, ...finish, tokenState };
|
||||
} catch (err) {
|
||||
eventsResponse.body?.cancel?.().catch?.(() => {});
|
||||
await trackedFinish.catch?.(() => {});
|
||||
throw err;
|
||||
}
|
||||
replyResponse.body?.cancel?.().catch?.(() => {});
|
||||
const finish = await finishPromise;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
if (tokenState && userAuth.billSessionUsage) {
|
||||
await userAuth
|
||||
.billSessionUsage(userId, sessionId, tokenState, requestId)
|
||||
.catch(() => {});
|
||||
}
|
||||
return { ok: true, ...finish, tokenState };
|
||||
}
|
||||
|
||||
const requireUser = async (req, res, next) => {
|
||||
|
||||
Reference in New Issue
Block a user