fix: unblock local news page generation

This commit is contained in:
john
2026-07-25 21:55:00 +08:00
parent 793e64bfbc
commit 6b6f9ed01c
16 changed files with 1075 additions and 47 deletions
+42 -1
View File
@@ -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(),
+21
View File
@@ -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 () => {
+4 -1
View File
@@ -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');
}
+8
View File
@@ -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
View File
@@ -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}`,
};
}
+36
View File
@@ -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: '帮我写个主题文章',
+403
View File
@@ -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);
});
}
+170
View File
@@ -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
View File
@@ -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) {
+131
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+32
View File
@@ -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({
+17 -3
View File
@@ -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
View File
@@ -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) => {