diff --git a/deepseek-no-think-proxy.mjs b/deepseek-no-think-proxy.mjs index 5db1b28..2310248 100644 --- a/deepseek-no-think-proxy.mjs +++ b/deepseek-no-think-proxy.mjs @@ -6,6 +6,7 @@ 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 }, @@ -256,6 +257,8 @@ export function createDeepseekNoThinkProxy({ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, + contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION, + deepseekThinking: 'disabled', upstream: normalizedUpstream, moonshotUpstream: normalizedMoonshotUpstream, })); diff --git a/deepseek-no-think-proxy.test.mjs b/deepseek-no-think-proxy.test.mjs index 72a3481..356ebef 100644 --- a/deepseek-no-think-proxy.test.mjs +++ b/deepseek-no-think-proxy.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + DEEPSEEK_COMPAT_CONTRACT_VERSION, decodedUpstreamResponseHeaders, deepseekDisableThinkingEnabled, flattenLocalJsonSchemaRefs, @@ -10,6 +11,7 @@ import { resolveDeepseekNoThinkProxyBaseUrl, resolveMoonshotCompatProxyBaseUrl, sanitizeMoonshotToolSchemas, + startDeepseekNoThinkProxy, } from './deepseek-no-think-proxy.mjs'; test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () => { @@ -168,3 +170,22 @@ test('decodedUpstreamResponseHeaders drops stale compression metadata', () => { 'content-type': 'text/event-stream', }); }); + +test('compatibility proxy health identifies the enforced production contract', async (t) => { + const server = await startDeepseekNoThinkProxy({ + host: '127.0.0.1', + port: 0, + logger: { info() {}, warn() {}, error() {} }, + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const address = server.address(); + const response = await fetch(`http://127.0.0.1:${address.port}/health`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + ok: true, + contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION, + deepseekThinking: 'disabled', + upstream: 'https://api.deepseek.com', + moonshotUpstream: 'https://api.moonshot.cn', + }); +}); diff --git a/docs/release-canary-103.md b/docs/release-canary-103.md index 9942c3c..3153e4c 100644 --- a/docs/release-canary-103.md +++ b/docs/release-canary-103.md @@ -11,6 +11,7 @@ Portal 的首个生产动作必须是用户级灰度,不能直接整包替换 | 灰度边缘隧道 | `105 127.0.0.1:19082` | 反向 SSH 到 103 的 18082;不复用已退役的 19081 链路 | | 候选 Portal | `127.0.0.1:18081` | 独立、无源码 runtime;使用被动运行时模式 | | 候选 goosed | `127.0.0.1:18015` | 独立容器并挂载候选 runtime,不重建稳定 9 个容器 | +| DeepSeek 兼容代理 | `103 0.0.0.0:18036` | 仅供宿主机和候选容器使用;不接入 105 或公网入口 | 候选 Portal 通过 `MEMIND_PORTAL_H5_ROOT` 共享经审计的持久数据和数据库,但 `MEMIND_RUNTIME_ROLE=candidate` 会禁止重复启动 MindSpace、Plaza、提醒、订阅续期和 @@ -23,6 +24,18 @@ Agent 恢复扫描等单例后台任务。 `Failed to add extension` 和宿主机绝对路径的 `MODULE_NOT_FOUND`,应先核对候选 进程环境与 `/opt/portal` 容器挂载,禁止把宿主机文件复制进容器临时修补。 +DeepSeek V4 的工具轮次还必须经过候选 runtime 中的 +`deepseek-no-think-proxy.mjs`。候选启动脚本必须强制 +`MEMIND_DEEPSEEK_DISABLE_THINKING=1`,并把 goosed 自定义 provider 指向 +`http://host.docker.internal:18036/v1`。该代理通过独立 LaunchAgent 管理; +发布必须同时验证 `/health` 的兼容契约和候选容器到该地址的连通性。灰度路由器把 +该健康地址纳入候选健康闭包,代理掉线时必须自动回落稳定 Portal。 + +若日志出现 +`The reasoning_content in the thinking mode must be passed back to the API.`, +说明 DeepSeek 请求绕过了兼容契约。禁止只在线补环境变量:必须检查 artifact、 +LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于同一 release。 + ## 身份和回落规则 - `john`:按数据库中的唯一用户名或用户 ID 匹配。 @@ -32,6 +45,7 @@ Agent 恢复扫描等单例后台任务。 - 微信回调通过 `FromUserName` 和 `h5_user_wechat_identities` 只读解析到绑定账号,再用唯一账号标识匹配。 - 未命中、无身份、过期/撤销会话、数据库异常:走稳定版本。 - 候选 `/api/status` 不健康:自动走稳定版本。 +- DeepSeek 兼容代理不健康:自动走稳定版本。 - SSE、附件、公开页和普通 API 均由同一反向代理转发;微信 XML body 必须原样保留。 ## 发布入口 @@ -49,12 +63,13 @@ bash scripts/release-portal-canary-prod.sh --yes 2. 执行 103 只读预检。 3. 上传经过验证的候选包。 4. 创建稳定 live 全量备份、持久目录备份和 105 两份活动 nginx 配置备份,并校验完整性。 -5. 启动独立候选 goosed、候选 Portal 和灰度路由器。 -6. 验证不可变身份配置、稳定/候选健康和路由器诊断。 -7. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。 -8. 短暂阻止新 Agent Run,排空活动任务。 -9. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。 -10. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。 +5. 启动独立候选 goosed 和 DeepSeek 兼容代理,验证兼容契约及容器连通性。 +6. 启动候选 Portal 和灰度路由器。 +7. 验证不可变身份配置、稳定/候选/依赖健康和路由器诊断。 +8. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。 +9. 短暂阻止新 Agent Run,排空活动任务。 +10. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。 +11. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。 任一步失败都会把 105 nginx 恢复到稳定 `58.38.22.103:8081`,停止灰度隧道和候选服务, 并保留候选目录与备份证据。活动 nginx 配置变更只能由已提交的发布脚本执行,禁止在线手改。 @@ -68,7 +83,7 @@ bash scripts/rollback-portal-canary-prod.sh --yes 回滚只执行以下动作: - 把 105 两份活动 nginx 上游恢复到稳定 Portal `58.38.22.103:8081` 并验证 reload。 -- 停止灰度隧道、灰度路由器、候选 Portal 和候选 goosed。 +- 停止灰度隧道、灰度路由器、候选 Portal、候选 goosed 和 DeepSeek 兼容代理。 - 清除发布排水标记。 - 验证稳定 Portal 和 105 HTTPS 入口健康。 diff --git a/docs/发包必看.md b/docs/发包必看.md index c596076..96a446e 100644 --- a/docs/发包必看.md +++ b/docs/发包必看.md @@ -210,6 +210,37 @@ Provider 'custom_deepseek' is not configured 注意:DeepSeek API key / provider secret 是运行配置,不应该烘焙进镜像。 +### 4.7 DeepSeek 工具轮次兼容必须与生产拓扑一致 + +DeepSeek V4 思考模式在工具调用后的下一轮要求客户端回传上一轮 +`reasoning_content`。当前 goosed 工具链不能保证完整回传,因此生产候选必须使用 +artifact 内的 `deepseek-no-think-proxy.mjs`,由独立 LaunchAgent 在 `18036` +启动,并强制候选 Portal 设置: + +```bash +MEMIND_DEEPSEEK_DISABLE_THINKING=1 +MEMIND_DEEPSEEK_NO_THINK_PORT=18036 +MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal +``` + +发布不能只看 Portal `/api/status`。还必须验证: + +- `http://127.0.0.1:18036/health` 返回兼容契约版本和 + `deepseekThinking=disabled`; +- `goosed-prod-canary` 能访问 + `http://host.docker.internal:18036/health`; +- 灰度路由器把该地址纳入候选健康闭包; +- 失败清理和回滚会停止对应 LaunchAgent。 + +Gate 本地栈和生产候选必须使用同一组开关与进程依赖。若出现: + +```text +The reasoning_content in the thinking mode must be passed back to the API. +``` + +说明实际生产请求绕过了兼容契约;即使场景 Gate 全部通过,也必须阻断发布并排查 +环境漂移。 + ## 5. goosed-prod 镜像更新必看 当前已验证镜像: diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index c02ac1e..c7597a3 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -800,13 +800,13 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a ); }); -test('syncProfileToGoosed routes local DeepSeek through the no-thinking proxy', async () => { +test('syncProfileToGoosed routes explicitly protected production DeepSeek through the compatibility 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; + process.env.MEMIND_RUNTIME_PROFILE = 'production'; + process.env.MEMIND_DEEPSEEK_DISABLE_THINKING = '1'; delete process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL; process.env.MEMIND_GOOSED_HOST_GATEWAY = 'host.lima.internal'; diff --git a/memind-canary-proxy.mjs b/memind-canary-proxy.mjs index 353011d..c1e9e84 100644 --- a/memind-canary-proxy.mjs +++ b/memind-canary-proxy.mjs @@ -244,9 +244,43 @@ function appendForwardedFor(current, remoteAddress) { return values.join(', '); } +function dependencyHealthRequest(target, timeoutMs) { + return new Promise((resolve) => { + const request = http.request( + { + hostname: target.hostname, + port: target.port, + method: 'GET', + path: `${target.pathname}${target.search}`, + headers: { connection: 'close' }, + }, + (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode === 200)); + }, + ); + request.setTimeout(timeoutMs, () => request.destroy()); + request.once('error', () => resolve(false)); + request.end(); + }); +} + +function normalizeLoopbackHealthTarget(value, label) { + const target = new URL(String(value ?? '')); + if ( + target.protocol !== 'http:' + || !['127.0.0.1', 'localhost', '::1'].includes(target.hostname) + ) { + throw new Error(`${label} must be a loopback http URL`); + } + target.hash = ''; + return target; +} + export function createMemindCanaryProxy({ stableTarget, candidateTarget, + candidateHealthDependencies = [], policy, identityResolver, diagnosticSecret, @@ -256,6 +290,8 @@ export function createMemindCanaryProxy({ } = {}) { const stable = normalizeLoopbackTarget(stableTarget, 'stable target'); const candidate = normalizeLoopbackTarget(candidateTarget, 'candidate target'); + const candidateDependencies = candidateHealthDependencies.map((value, index) => + normalizeLoopbackHealthTarget(value, `candidate health dependency ${index + 1}`)); if (!policy) throw new Error('canary proxy requires a routing policy'); if (!identityResolver) throw new Error('canary proxy requires an identity resolver'); if (!diagnosticSecret) throw new Error('canary proxy requires a diagnostic secret'); @@ -269,8 +305,13 @@ export function createMemindCanaryProxy({ return healthCache.healthy; } if (!healthPromise) { - healthPromise = healthRequest(candidate, candidateHealthTimeoutMs, 'candidate') - .then((healthy) => { + healthPromise = Promise.all([ + healthRequest(candidate, candidateHealthTimeoutMs, 'candidate'), + ...candidateDependencies.map((target) => + dependencyHealthRequest(target, candidateHealthTimeoutMs)), + ]) + .then((results) => { + const healthy = results.every(Boolean); healthCache = { checkedAt: Date.now(), healthy }; return healthy; }) @@ -539,6 +580,9 @@ async function main() { stableTarget: process.env.MEMIND_CANARY_STABLE_URL || 'http://127.0.0.1:8081', candidateTarget: process.env.MEMIND_CANARY_CANDIDATE_URL || 'http://127.0.0.1:18081', + candidateHealthDependencies: csvValues( + process.env.MEMIND_CANARY_CANDIDATE_HEALTH_URLS, + ), policy, identityResolver, diagnosticSecret: process.env.MEMIND_CANARY_DIAGNOSTIC_SECRET, diff --git a/memind-canary-proxy.test.mjs b/memind-canary-proxy.test.mjs index 60af70d..99c7ef3 100644 --- a/memind-canary-proxy.test.mjs +++ b/memind-canary-proxy.test.mjs @@ -287,6 +287,54 @@ test('identity lookup errors fail closed to stable', async (t) => { assert.equal(response.headers['x-memind-runtime-route'], 'stable'); }); +test('candidate health fails closed when its DeepSeek compatibility dependency is down', async (t) => { + const stableServer = upstream('stable'); + const candidateServer = upstream('candidate'); + let dependencyHealthy = true; + const dependencyServer = http.createServer((req, res) => { + res.writeHead(req.url === '/health' && dependencyHealthy ? 200 : 503); + res.end(); + }); + const stableUrl = await listen(stableServer); + const candidateUrl = await listen(candidateServer); + const dependencyUrl = await listen(dependencyServer); + const proxy = createMemindCanaryProxy({ + stableTarget: stableUrl, + candidateTarget: candidateUrl, + candidateHealthDependencies: [`${dependencyUrl}/health`], + policy: createCanaryPolicy({ usernames: ['john'] }), + identityResolver: { + async fromSessionToken() { + return { id: 'user-john', username: 'john' }; + }, + async fromWechatOpenid() { + return null; + }, + }, + diagnosticSecret: 'diagnostic-secret', + candidateHealthTtlMs: 0, + }); + const proxyUrl = await listen(proxy.server); + t.after(async () => { + await close(proxy.server); + await close(stableServer); + await close(candidateServer); + await close(dependencyServer); + }); + + const candidate = await request(proxyUrl, { + headers: { cookie: 'tkmind_user_session=john-token' }, + }); + assert.equal(candidate.headers['x-memind-runtime-route'], 'candidate'); + + dependencyHealthy = false; + const fallback = await request(proxyUrl, { + headers: { cookie: 'tkmind_user_session=john-token' }, + }); + assert.equal(fallback.body, 'stable:'); + assert.equal(fallback.headers['x-memind-runtime-route'], 'stable'); +}); + test('candidate health requires the explicit candidate runtime role', async (t) => { const stableServer = upstream('stable'); const wrongRoleServer = upstream('stable'); diff --git a/release-gate/artifact.mjs b/release-gate/artifact.mjs index 9682e51..095386f 100644 --- a/release-gate/artifact.mjs +++ b/release-gate/artifact.mjs @@ -6,6 +6,7 @@ import path from 'node:path'; export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([ 'server.mjs', 'memind-canary-proxy.mjs', + 'deepseek-no-think-proxy.mjs', 'wechat-mp.bundle.mjs', 'mindspace-sandbox-mcp.mjs', 'tkmind-search-mcp.mjs', @@ -16,6 +17,7 @@ export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([ 'scripts/run-memind-portal-prod.sh', 'scripts/run-memind-portal-candidate.sh', 'scripts/run-memind-canary-proxy-prod.sh', + 'scripts/run-deepseek-compat-proxy-candidate.sh', 'scripts/goosed-canary.compose.yml', 'scripts/check-mindspace-public-links.mjs', 'scripts/load-env.mjs', diff --git a/release-gate/deepseek-production-parity.test.mjs b/release-gate/deepseek-production-parity.test.mjs new file mode 100644 index 0000000..d75271b --- /dev/null +++ b/release-gate/deepseek-production-parity.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { startDeepseekNoThinkProxy } from '../deepseek-no-think-proxy.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); + +test('sanitized production tool-round replay reaches DeepSeek with thinking disabled', async (t) => { + const forwarded = []; + const proxy = await startDeepseekNoThinkProxy({ + host: '127.0.0.1', + port: 0, + logger: { info() {}, warn() {}, error() {} }, + fetchImpl: async (_url, init) => { + forwarded.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'synthetic result' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + t.after(() => new Promise((resolve) => proxy.close(resolve))); + const address = proxy.address(); + + const response = await fetch( + `http://127.0.0.1:${address.port}/v1/chat/completions`, + { + method: 'POST', + headers: { + authorization: 'Bearer synthetic-key', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-pro', + thinking: { type: 'enabled' }, + messages: [ + { role: 'user', content: 'Use the synthetic filesystem tool.' }, + { role: 'assistant', tool_calls: [{ id: 'call-1', type: 'function' }] }, + { role: 'tool', tool_call_id: 'call-1', content: 'synthetic file list' }, + ], + }), + }, + ); + + assert.equal(response.status, 200); + assert.equal(forwarded.length, 1); + assert.deepEqual(forwarded[0].thinking, { type: 'disabled' }); + assert.equal(forwarded[0].messages[2].role, 'tool'); +}); + +test('Gate, artifact, candidate routing and rollback share one compatibility contract', async () => { + const [ + builder, + localStack, + candidateRunner, + compatRunner, + canaryRelease, + canaryRollback, + ] = await Promise.all([ + fs.readFile(path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), 'utf8'), + fs.readFile(path.join(ROOT, 'release-gate', 'local-stack.mjs'), 'utf8'), + fs.readFile(path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh'), 'utf8'), + fs.readFile( + path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'), + 'utf8', + ), + fs.readFile(path.join(ROOT, 'scripts', 'release-portal-canary-prod.sh'), 'utf8'), + fs.readFile(path.join(ROOT, 'scripts', 'rollback-portal-canary-prod.sh'), 'utf8'), + ]); + + assert.match(builder, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/); + assert.match( + localStack, + /path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/, + ); + assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/); + assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/); + assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/); + assert.match(compatRunner, /export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1/); + assert.match(canaryRelease, /run-deepseek-compat-proxy-candidate\.sh/); + assert.match(canaryRelease, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/); + assert.match(canaryRelease, /host\.docker\.internal:\$\{DEEPSEEK_COMPAT_PORT\}\/health/); + assert.match(canaryRelease, /bootout.*DEEPSEEK_COMPAT_LABEL/); + assert.match(canaryRollback, /bootout.*DEEPSEEK_COMPAT_LABEL/); +}); diff --git a/release-gate/local-stack.mjs b/release-gate/local-stack.mjs index ca195c1..c57eac8 100644 --- a/release-gate/local-stack.mjs +++ b/release-gate/local-stack.mjs @@ -380,7 +380,7 @@ export async function createLocalGateStack({ deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a'); deepseekProxyChild = spawn( process.execPath, - [path.join(root, 'deepseek-no-think-proxy.mjs')], + [path.join(resolvedPortalRoot, 'deepseek-no-think-proxy.mjs')], { cwd: root, env: { diff --git a/release-gate/release-script.test.mjs b/release-gate/release-script.test.mjs index 8c71424..c0b12b8 100644 --- a/release-gate/release-script.test.mjs +++ b/release-gate/release-script.test.mjs @@ -63,6 +63,16 @@ test('runtime builder materializes and declares required Linux ARM64 native pack assert.match(source, /缺少原生 \.node 文件/); }); +test('runtime builder bundles the DeepSeek compatibility dependency into the artifact', async () => { + const source = await fs.readFile( + path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), + 'utf8', + ); + assert.match(source, /bundleDeepseekCompatProxy/); + assert.match(source, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/); + assert.match(source, /await bundleDeepseekCompatProxy\(\)/); +}); + test('packaged runtime gate isolates persistent roots and rejects artifact mutation', async () => { const localStackSource = await fs.readFile( path.join(ROOT, 'release-gate', 'local-stack.mjs'), @@ -70,7 +80,10 @@ test('packaged runtime gate isolates persistent roots and rejects artifact mutat ); assert.match(localStackSource, /MEMIND_PORTAL_H5_ROOT: sandboxRoot/); assert.match(localStackSource, /MEMIND_DEEPSEEK_DISABLE_THINKING: '1'/); - assert.match(localStackSource, /deepseek-no-think-proxy\.mjs/); + assert.match( + localStackSource, + /path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/, + ); assert.match(localStackSource, /MEMIND_ORCHESTRATOR_MODE: 'shadow'/); assert.match(localStackSource, /MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1'/); assert.match(localStackSource, /H5_USERS_ROOT: usersRoot/); @@ -119,6 +132,9 @@ test('production canary keeps stable 8081 live and switches only after verified const edgeBackup = source.indexOf('Create and verify the active 105 nginx routing backup'); const goosedStart = source.indexOf('Start an isolated goosed candidate on 18015'); const candidateStart = source.indexOf('Start the passive candidate Portal on 18081'); + const deepseekCompatStart = source.indexOf( + 'Start the DeepSeek tool-round compatibility proxy on 18036', + ); const proxyStart = source.indexOf('Start the fail-closed identity router on 18082'); const tunnelStart = source.indexOf('Start the isolated 105 reverse tunnel on 19082'); const edgeSwitch = source.indexOf( @@ -129,7 +145,8 @@ test('production canary keeps stable 8081 live and switches only after verified assert.ok(persistBackup > fullBackup); assert.ok(edgeBackup > persistBackup); assert.ok(goosedStart > edgeBackup); - assert.ok(candidateStart > goosedStart); + assert.ok(deepseekCompatStart > goosedStart); + assert.ok(candidateStart > deepseekCompatStart); assert.ok(proxyStart > candidateStart); assert.ok(tunnelStart > proxyStart); assert.ok(edgeSwitch > tunnelStart); @@ -140,6 +157,12 @@ test('production canary keeps stable 8081 live and switches only after verified assert.match(source, /nginx -t/); assert.match(source, /CANARY_PROXY_PORT=18082/); assert.match(source, /CANARY_TUNNEL_REMOTE_PORT=19082/); + assert.match(source, /DEEPSEEK_COMPAT_PORT=18036/); + assert.match(source, /deepseek-no-think-proxy\.mjs/); + assert.match(source, /run-deepseek-compat-proxy-candidate\.sh/); + assert.match(source, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/); + assert.match(source, /deepseek_compat_healthy/); + assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/); assert.match( source, /edge_ssh\(\) \{\s*#.*\n\s*#.*\n\s*ssh -n -o BatchMode=yes/, @@ -176,6 +199,11 @@ test('candidate runner overrides stable host MCP paths with container-visible pa [ 'GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node', 'GOOSED_MCP_SERVER_PATH=/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs', + 'MEMIND_DEEPSEEK_DISABLE_THINKING=0', + 'MEMIND_DEEPSEEK_NO_THINK_PORT=9999', + 'MEMIND_DEEPSEEK_NO_THINK_BASE_URL=http://wrong.invalid/v1', + 'MEMIND_DEEPSEEK_NO_THINK_HOST=wrong.invalid', + 'MEMIND_GOOSED_HOST_GATEWAY=wrong.invalid', '', ].join('\n'), ); @@ -186,6 +214,11 @@ test('candidate runner overrides stable host MCP paths with container-visible pa 'printf "cwd=%s\\n" "$PWD"', 'printf "mcp_node=%s\\n" "$GOOSED_MCP_NODE_PATH"', 'printf "mcp_server=%s\\n" "$GOOSED_MCP_SERVER_PATH"', + 'printf "deepseek_disable=%s\\n" "$MEMIND_DEEPSEEK_DISABLE_THINKING"', + 'printf "deepseek_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"', + 'printf "deepseek_gateway=%s\\n" "$MEMIND_GOOSED_HOST_GATEWAY"', + 'printf "deepseek_base=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_BASE_URL-unset}"', + 'printf "deepseek_host=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_HOST-unset}"', 'printf "entrypoint=%s\\n" "$1"', '', ].join('\n'), @@ -215,9 +248,78 @@ test('candidate runner overrides stable host MCP paths with container-visible pa result.stdout, /^mcp_server=\/opt\/portal\/mindspace-sandbox-mcp\.mjs$/m, ); + assert.match(result.stdout, /^deepseek_disable=1$/m); + assert.match(result.stdout, /^deepseek_port=18036$/m); + assert.match(result.stdout, /^deepseek_gateway=host\.docker\.internal$/m); + assert.match(result.stdout, /^deepseek_base=unset$/m); + assert.match(result.stdout, /^deepseek_host=unset$/m); assert.match(result.stdout, new RegExp(`^entrypoint=${ROOT}/server\\.mjs$`, 'm')); }); +test('canary rollback stops the DeepSeek compatibility process with the other canary services', async () => { + const source = await fs.readFile(CANARY_ROLLBACK, 'utf8'); + assert.match( + source, + /DEEPSEEK_COMPAT_LABEL="cn\.tkmind\.memind-deepseek-compat-candidate"/, + ); + assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/); + assert.match(source, /docker rm -f goosed-prod-canary/); +}); + +test('DeepSeek compatibility runner preserves stable upstream config but enforces candidate controls', async (t) => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-deepseek-runner-')); + t.after(() => fs.rm(tempRoot, { recursive: true, force: true })); + + const stableRoot = path.join(tempRoot, 'stable'); + const fakeNode = path.join(tempRoot, 'node'); + await fs.mkdir(stableRoot); + await fs.writeFile( + path.join(stableRoot, '.env'), + [ + 'DEEPSEEK_API_BASE_URL=https://synthetic-upstream.invalid/v1', + 'MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=0', + 'MEMIND_DEEPSEEK_NO_THINK_PORT=9999', + '', + ].join('\n'), + ); + await fs.writeFile( + fakeNode, + [ + '#!/usr/bin/env bash', + 'printf "proxy_entrypoint=%s\\n" "$MEMIND_DEEPSEEK_PROXY_ENTRYPOINT"', + 'printf "proxy_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"', + 'printf "upstream=%s\\n" "$DEEPSEEK_API_BASE_URL"', + 'printf "entrypoint=%s\\n" "$1"', + '', + ].join('\n'), + { mode: 0o755 }, + ); + + const result = spawnSync( + 'bash', + [path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh')], + { + cwd: '/', + encoding: 'utf8', + env: { + ...process.env, + MEMIND_CANARY_STABLE_ROOT: stableRoot, + MEMIND_CANARY_DEEPSEEK_PROXY_PORT: '18036', + NODE_BIN: fakeNode, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^proxy_entrypoint=1$/m); + assert.match(result.stdout, /^proxy_port=18036$/m); + assert.match(result.stdout, /^upstream=https:\/\/synthetic-upstream\.invalid\/v1$/m); + assert.match( + result.stdout, + new RegExp(`^entrypoint=${ROOT}/deepseek-no-think-proxy\\.mjs$`, 'm'), + ); +}); + test('canary release and rollback remote shells remain syntactically valid', async () => { const releaseSource = await fs.readFile(CANARY_RELEASE, 'utf8'); const rollbackSource = await fs.readFile(CANARY_ROLLBACK, 'utf8'); diff --git a/scenarios/production-regressions/agent-run/PRC-REL-002.json b/scenarios/production-regressions/agent-run/PRC-REL-002.json new file mode 100644 index 0000000..8c4b0e4 --- /dev/null +++ b/scenarios/production-regressions/agent-run/PRC-REL-002.json @@ -0,0 +1,16 @@ +{ + "id": "PRC-REL-002", + "scenario_ids": ["REL-06", "REL-09", "REL-11"], + "failure_signature": "gate-production-drift-deepseek-thinking-tool-round-reasoning-content-replay-rejected", + "synthetic_intent": "验证工具调用后的合成 DeepSeek 会话经过生产候选兼容链路,禁用思考模式,并在兼容依赖失效时让灰度流量回落稳定版本。", + "source_batch_id": "incident-20260726-deepseek-tool-round", + "evidence_digest": "97171fe1d94e0297cb0b932cf1ff18e8d1760519300f7308b9f8cc4e9517e022", + "contains_personal_data": false, + "privacy_review": { + "reviewed_by": "release-gate-maintainers", + "reviewed_at": "2026-07-26T23:55:00+08:00" + }, + "automation": { + "command": ["node", "--test", "release-gate/deepseek-production-parity.test.mjs"] + } +} diff --git a/scenarios/production-regressions/manifests/active.json b/scenarios/production-regressions/manifests/active.json index eb49393..71c33e6 100644 --- a/scenarios/production-regressions/manifests/active.json +++ b/scenarios/production-regressions/manifests/active.json @@ -1,6 +1,6 @@ { - "fixture_ids": ["PRC-REL-001"], + "fixture_ids": ["PRC-REL-001", "PRC-REL-002"], "reviewed_by": "release-gate-maintainers", "reviewed_at": "2026-07-26T12:00:00+08:00", - "notes": "仅收录已脱敏的 runtime 启动回归签名;不包含聊天原文、用户身份或生产地址。" + "notes": "仅收录已脱敏的 runtime 启动和生产配置同构回归签名;不包含聊天原文、用户身份或生产地址。" } diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index aef8117..f84b73b 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -156,6 +156,26 @@ async function bundleCanaryProxy() { await run(esbuildBin, args); } +async function bundleDeepseekCompatProxy() { + if (!(await exists(esbuildBin))) { + throw new Error(`未找到 esbuild: ${esbuildBin}`); + } + console.log('==> 打包 DeepSeek 工具轮次兼容代理'); + const args = [ + 'deepseek-no-think-proxy.mjs', + '--bundle', + '--platform=node', + '--format=esm', + `--target=${runtimeNodeTarget}`, + '--outfile=.runtime/portal/deepseek-no-think-proxy.mjs', + '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', + ]; + for (const pkg of externalPackages) { + args.push(`--external:${pkg}`); + } + await run(esbuildBin, args); +} + async function bundleSandboxMcp() { if (!(await exists(esbuildBin))) { throw new Error(`未找到 esbuild: ${esbuildBin}`); @@ -409,6 +429,7 @@ async function writeMetadata() { for (const scriptName of [ 'run-memind-portal-candidate.sh', 'run-memind-canary-proxy-prod.sh', + 'run-deepseek-compat-proxy-candidate.sh', ]) { const source = path.join(root, 'scripts', scriptName); if (!(await exists(source))) { @@ -527,6 +548,7 @@ async function writeMetadata() { ' tkmind-excel-mcp.mjs (esbuild bundle; required when Excel Analyst is enabled)', ' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)', ' memind-canary-proxy.mjs (immutable-identity stable/candidate router)', + ' deepseek-no-think-proxy.mjs (DeepSeek V4 tool-round compatibility proxy)', '', 'Post-deploy validation (scripts/check-mindspace-public-links.mjs):', ' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.', @@ -598,6 +620,7 @@ async function main() { await copyRuntimeAssets(); await bundleServer(); await bundleCanaryProxy(); + await bundleDeepseekCompatProxy(); await bundleWechatMp(); await bundleSandboxMcp(); await bundleMindSearchMcp(); @@ -611,6 +634,10 @@ async function main() { await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-candidate.sh'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-canary-proxy-prod.sh'), 0o755); + await fs.chmod( + path.join(runtimeRoot, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'), + 0o755, + ); await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755); diff --git a/scripts/release-portal-canary-prod.sh b/scripts/release-portal-canary-prod.sh index 6c24f81..c06b4e7 100755 --- a/scripts/release-portal-canary-prod.sh +++ b/scripts/release-portal-canary-prod.sh @@ -13,9 +13,11 @@ RUNTIME_ROOT="${ROOT}/.runtime/portal" PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate" CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy" CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel" +DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate" EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}" CANARY_PROXY_PORT=18082 CANARY_TUNNEL_REMOTE_PORT=19082 +DEEPSEEK_COMPAT_PORT=18036 EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf" EDGE_WECHAT_CONFIG="/etc/nginx/conf.d/wechat.m.tkmind.cn.conf" CANARY_USERNAMES="john" @@ -46,6 +48,7 @@ Deploys a candidate Portal beside the stable 103 runtime: canary router 127.0.0.1:18082 candidate Portal 127.0.0.1:18081 candidate goosed 127.0.0.1:18015 + DeepSeek compat 0.0.0.0:18036 (host/container only) The stable runtime is not replaced. A dedicated reverse tunnel exposes the router only to 105 at 127.0.0.1:19082. The committed release workflow updates @@ -123,6 +126,7 @@ say "Run release source guards" required_runtime_paths=( server.mjs memind-canary-proxy.mjs + deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs @@ -131,6 +135,7 @@ required_runtime_paths=( package.json scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh + scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/memind-portal-tunnel.sh ) @@ -155,7 +160,7 @@ printf '%s %s\n' "${bundle_sha}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}" echo "git_branch=${branch}" echo "artifact_tree=.runtime/portal" echo "artifact_bundle_sha256=${bundle_sha}" - echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015" + echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036" echo "canary_usernames=${CANARY_USERNAMES}" echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}" echo "manual_environment_changes=none" @@ -174,6 +179,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \ && test -f '${GOOSED_DIR}/docker-compose.prod.yml' \ && curl -fsS http://127.0.0.1:8081/api/status >/dev/null \ && ! lsof -nP -iTCP:${CANARY_PROXY_PORT} -sTCP:LISTEN >/dev/null 2>&1 \ + && ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \ && ! lsof -nP -iTCP:18081 -sTCP:LISTEN >/dev/null 2>&1 \ && ! lsof -nP -iTCP:18015 -sTCP:LISTEN >/dev/null 2>&1 \ && /opt/homebrew/bin/docker inspect goosed-prod-1 >/dev/null \ @@ -214,9 +220,11 @@ ssh -o BatchMode=yes "${HOST}" \ PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \ CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \ CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \ + DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \ EDGE_HOST='${EDGE_HOST}' \ CANARY_PROXY_PORT='${CANARY_PROXY_PORT}' \ CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \ + DEEPSEEK_COMPAT_PORT='${DEEPSEEK_COMPAT_PORT}' \ EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \ EDGE_WECHAT_CONFIG='${EDGE_WECHAT_CONFIG}' \ CANARY_USERNAMES='${CANARY_USERNAMES}' \ @@ -234,6 +242,7 @@ PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before-canary.t CANDIDATE_PLIST="${HOME}/Library/LaunchAgents/${PORTAL_CANDIDATE_LABEL}.plist" PROXY_PLIST="${HOME}/Library/LaunchAgents/${CANARY_PROXY_LABEL}.plist" CANARY_TUNNEL_PLIST="${HOME}/Library/LaunchAgents/${CANARY_TUNNEL_LABEL}.plist" +DEEPSEEK_COMPAT_PLIST="${HOME}/Library/LaunchAgents/${DEEPSEEK_COMPAT_LABEL}.plist" SECRET_FILE="${HOME}/.config/memind/canary-router.secret" DOCKER_BIN="/opt/homebrew/bin/docker" GOOSED_COMPOSE="${GOOSED_DIR}/docker-compose.prod.yml" @@ -260,6 +269,22 @@ candidate_healthy() { return 1 } +deepseek_compat_healthy() { + curl -fsS --max-time 5 "http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health" \ + | /opt/homebrew/opt/node@24/bin/node --input-type=module -e ' + let input = ""; + process.stdin.on("data", (chunk) => { input += chunk; }); + process.stdin.on("end", () => { + const status = JSON.parse(input); + if ( + status.ok !== true + || status.contractVersion !== 1 + || status.deepseekThinking !== "disabled" + ) process.exit(1); + }); + ' +} + edge_ssh() { # This remote release script itself arrives on stdin. Ordinary nested SSH # commands must not consume the unparsed remainder of that script. @@ -326,6 +351,7 @@ stop_candidate_services() { launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true + launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true "${DOCKER_BIN}" rm -f goosed-prod-canary >/dev/null 2>&1 || true } @@ -405,7 +431,8 @@ tar -xzf "${BUNDLE}" -C "${CANDIDATE_DIR}" cp "${MANIFEST}" "${CANDIDATE_DIR}/.release-manifest.txt" chmod 755 \ "${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh" \ - "${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh" + "${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh" \ + "${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh" say "Stop an older canary without touching stable Portal 8081" restore_edge_to_stable @@ -434,6 +461,39 @@ done "${DOCKER_BIN}" exec goosed-prod-canary \ sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' +say "Start the DeepSeek tool-round compatibility proxy on 18036" +cat > "${DEEPSEEK_COMPAT_PLIST}" < + + + + Label${DEEPSEEK_COMPAT_LABEL} + ProgramArguments + ${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh + EnvironmentVariables + + MEMIND_CANARY_STABLE_ROOT${STABLE_DIR} + MEMIND_CANARY_DEEPSEEK_PROXY_PORT${DEEPSEEK_COMPAT_PORT} + + RunAtLoad + KeepAlive + ThrottleInterval5 + StandardOutPath${HOME}/Library/Logs/memind-deepseek-compat-candidate.log + StandardErrorPath${HOME}/Library/Logs/memind-deepseek-compat-candidate.log + + +EOF +launchctl bootstrap "${LAUNCHD_GUI}" "${DEEPSEEK_COMPAT_PLIST}" >/dev/null +launchctl kickstart -k "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null +for _ in $(seq 1 30); do + deepseek_compat_healthy && break + sleep 1 +done +deepseek_compat_healthy +"${DOCKER_BIN}" exec goosed-prod-canary \ + sh -lc "curl -fsS --max-time 5 http://host.docker.internal:${DEEPSEEK_COMPAT_PORT}/health \ + | grep -q '\"deepseekThinking\":\"disabled\"'" + say "Start the passive candidate Portal on 18081" cat > "${CANDIDATE_PLIST}" < @@ -449,6 +509,7 @@ cat > "${CANDIDATE_PLIST}" <MEMIND_CANARY_RELEASE_ID${RELEASE_ID} MEMIND_CANARY_CANDIDATE_PORT18081 MEMIND_CANARY_GOOSED_URLhttps://127.0.0.1:18015 + MEMIND_CANARY_DEEPSEEK_PROXY_PORT${DEEPSEEK_COMPAT_PORT} RunAtLoad KeepAlive @@ -500,6 +561,7 @@ cat > "${PROXY_PLIST}" <MEMIND_CANARY_PROXY_PORT${CANARY_PROXY_PORT} MEMIND_RELEASE_CANARY_USERNAMES${CANARY_USERNAMES} MEMIND_RELEASE_CANARY_WECHAT_USER_IDS${CANARY_WECHAT_USER_IDS} + MEMIND_CANARY_CANDIDATE_HEALTH_URLShttp://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health RunAtLoad KeepAlive @@ -675,6 +737,7 @@ printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PRO printf 'edge_tunnel=http://127.0.0.1:%s/api/status\n' "${CANARY_TUNNEL_REMOTE_PORT}" printf 'candidate_health=http://127.0.0.1:18081/api/status\n' printf 'candidate_goosed=https://127.0.0.1:18015/status\n' +printf 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}" REMOTE_SCRIPT say "103 canary release completed" diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index c97ff02..5ef95f8 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -166,7 +166,7 @@ fi verify_runtime_artifact() { local missing=0 - for required in server.mjs memind-canary-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do + for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 missing=1 diff --git a/scripts/rollback-portal-canary-prod.sh b/scripts/rollback-portal-canary-prod.sh index 6979a2a..df226ec 100755 --- a/scripts/rollback-portal-canary-prod.sh +++ b/scripts/rollback-portal-canary-prod.sh @@ -6,6 +6,7 @@ STABLE_DIR="${STUDIO_REMOTE_ROOT:-/Users/john/Project}/Memind" PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate" CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy" CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel" +DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate" EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}" CANARY_TUNNEL_REMOTE_PORT=19082 EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf" @@ -30,6 +31,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \ PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \ CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \ CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \ + DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \ EDGE_HOST='${EDGE_HOST}' \ CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \ EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \ @@ -69,6 +71,7 @@ EDGE_ROLLBACK launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true +launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true /opt/homebrew/bin/docker rm -f goosed-prod-canary >/dev/null 2>&1 || true rm -f "${STABLE_DIR}/.release-drain" diff --git a/scripts/run-deepseek-compat-proxy-candidate.sh b/scripts/run-deepseek-compat-proxy-candidate.sh new file mode 100755 index 0000000..ed4641c --- /dev/null +++ b/scripts/run-deepseek-compat-proxy-candidate.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STABLE_ROOT="${MEMIND_CANARY_STABLE_ROOT:-/Users/john/Project/Memind}" + +if [[ ! -d "${STABLE_ROOT}" || ! -f "${STABLE_ROOT}/.env" ]]; then + echo "[deepseek-compat] stable runtime environment is unavailable: ${STABLE_ROOT}" >&2 + exit 1 +fi + +cd "${ROOT}" + +set -a +# shellcheck disable=SC1091 +source "${STABLE_ROOT}/.env" +set +a + +export NODE_ENV=production +export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1 +export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}" + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node)" +fi + +exec "${NODE_BIN}" "${ROOT}/deepseek-no-think-proxy.mjs" diff --git a/scripts/run-memind-portal-candidate.sh b/scripts/run-memind-portal-candidate.sh index e53c9af..a566144 100755 --- a/scripts/run-memind-portal-candidate.sh +++ b/scripts/run-memind-portal-candidate.sh @@ -33,6 +33,13 @@ export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}" # mounted at /opt/portal. Never inherit host-only MCP paths from the stable .env. export GOOSED_MCP_NODE_PATH=/usr/local/bin/node export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs +# DeepSeek V4 tool rounds must use the same compatibility contract exercised +# by the release Gate. Stable .env values cannot disable or redirect it. +export MEMIND_DEEPSEEK_DISABLE_THINKING=1 +export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}" +export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal +unset MEMIND_DEEPSEEK_NO_THINK_BASE_URL +unset MEMIND_DEEPSEEK_NO_THINK_HOST unset TKMIND_API_TARGET_1 NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"