Files
memind/deepseek-no-think-proxy.mjs
john fb6865638a
Memind CI / Test, build, and release guards (pull_request) Successful in 1m32s
fix: align DeepSeek canary tool rounds with gate
2026-07-26 23:49:32 +08:00

409 lines
13 KiB
JavaScript

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';
export const DEEPSEEK_COMPAT_CONTRACT_VERSION = 1;
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.docker.internal',
).trim() || 'host.docker.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.docker.internal',
).trim() || 'host.docker.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?.type === 'disabled') {
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,
contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION,
deepseekThinking: 'disabled',
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.env.MEMIND_DEEPSEEK_PROXY_ENTRYPOINT === '1'
&&
process.argv[1]
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]),
);
if (isMain) {
startDeepseekNoThinkProxy().catch((err) => {
console.error(err);
process.exit(1);
});
}