@fa-software/semi2-dsh 0.0.10-preview.20260920042705

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/cordis.patch.yml +5 -0
  2. package/lib/client.js +351 -0
  3. package/lib/client.js.map +1 -0
  4. package/lib/codegraph/cli.js +142 -0
  5. package/lib/codegraph/config.js +22 -0
  6. package/lib/codegraph/ensure.js +25 -0
  7. package/lib/codegraph/guide.js +21 -0
  8. package/lib/codegraph/index.js +97 -0
  9. package/lib/codegraph/tools.js +227 -0
  10. package/lib/codegraph/workspace.js +52 -0
  11. package/lib/configuration/compile.js +177 -0
  12. package/lib/configuration/credentials.js +104 -0
  13. package/lib/configuration/generated/graphql.js +1 -0
  14. package/lib/configuration/generated/schema.js +95 -0
  15. package/lib/configuration/graphql.js +193 -0
  16. package/lib/configuration/index.js +29 -0
  17. package/lib/configuration/login.js +96 -0
  18. package/lib/configuration/runtime.js +1100 -0
  19. package/lib/configuration/skill-resources.js +218 -0
  20. package/lib/configuration/skills.js +185 -0
  21. package/lib/configuration/status.js +57 -0
  22. package/lib/configuration/workspace.js +55 -0
  23. package/lib/configuration/workspaces.js +169 -0
  24. package/lib/contract.js +42 -0
  25. package/lib/index.js +14 -0
  26. package/lib/types/codegraph/cli.d.ts +20 -0
  27. package/lib/types/codegraph/config.d.ts +10 -0
  28. package/lib/types/codegraph/ensure.d.ts +3 -0
  29. package/lib/types/codegraph/guide.d.ts +4 -0
  30. package/lib/types/codegraph/index.d.ts +5 -0
  31. package/lib/types/codegraph/tools.d.ts +17 -0
  32. package/lib/types/codegraph/workspace.d.ts +8 -0
  33. package/lib/types/configuration/compile.d.ts +81 -0
  34. package/lib/types/configuration/credentials.d.ts +35 -0
  35. package/lib/types/configuration/generated/graphql.d.ts +100 -0
  36. package/lib/types/configuration/generated/schema.d.ts +1003 -0
  37. package/lib/types/configuration/graphql.d.ts +46 -0
  38. package/lib/types/configuration/index.d.ts +9 -0
  39. package/lib/types/configuration/login.d.ts +22 -0
  40. package/lib/types/configuration/runtime.d.ts +320 -0
  41. package/lib/types/configuration/skill-resources.d.ts +14 -0
  42. package/lib/types/configuration/skills.d.ts +8 -0
  43. package/lib/types/configuration/status.d.ts +9 -0
  44. package/lib/types/configuration/workspace.d.ts +7 -0
  45. package/lib/types/configuration/workspaces.d.ts +96 -0
  46. package/lib/types/contract.d.ts +32 -0
  47. package/lib/types/index.d.ts +11 -0
  48. package/lib/types/web/index.d.ts +2 -0
  49. package/lib/types/web/login.d.ts +2 -0
  50. package/lib/types/web/loopback-bridge.d.ts +8 -0
  51. package/lib/types/web/preview-proxy.d.ts +17 -0
  52. package/lib/types/web/runtime.d.ts +8 -0
  53. package/lib/types/web/welcome-ack.d.ts +33 -0
  54. package/lib/web/index.js +38 -0
  55. package/lib/web/login.js +79 -0
  56. package/lib/web/loopback-bridge.js +12 -0
  57. package/lib/web/preview-proxy.js +201 -0
  58. package/lib/web/runtime.js +19 -0
  59. package/lib/web/welcome-ack.js +51 -0
  60. package/package.json +81 -0
@@ -0,0 +1,97 @@
1
+ import { SEMI2_RUNTIME_SERVICE } from "../contract.js";
2
+ import { createCodegraphRunner } from "./cli.js";
3
+ import { autoIndexEnabled, moduleDisabled, platformOf } from "./config.js";
4
+ import { ensureIndex } from "./ensure.js";
5
+ import { registerGuide } from "./guide.js";
6
+ import { installTools } from "./tools.js";
7
+ import { findGitRoot, findIndexRoot } from "./workspace.js";
8
+ /** apply 期预热等契约的上限:适配器同步 provide;等不到按宿主直跑不预热。 */
9
+ const PREWARM_CONTRACT_TIMEOUT_MS = 5_000;
10
+ const PREWARM_CONTRACT_POLL_MS = 200;
11
+ function makeWarn(ctx) {
12
+ try {
13
+ const logger = ctx.logger('semi2-codegraph');
14
+ return (message) => {
15
+ logger.warn(message);
16
+ };
17
+ }
18
+ catch {
19
+ return () => { };
20
+ }
21
+ }
22
+ /** 自动索引面 + 未索引 + git 仓库才预热;目标保持会话工作区 cwd。 */
23
+ export function prewarmTarget(platform, cwd, env) {
24
+ if (!autoIndexEnabled(platform, env))
25
+ return null;
26
+ if (cwd === undefined || findIndexRoot(cwd) !== undefined)
27
+ return null;
28
+ return findGitRoot(cwd) === undefined ? null : cwd;
29
+ }
30
+ async function waitForPlatform(ctx, signal) {
31
+ const deadline = Date.now() + PREWARM_CONTRACT_TIMEOUT_MS;
32
+ for (;;) {
33
+ signal.throwIfAborted();
34
+ const runtime = ctx.get(SEMI2_RUNTIME_SERVICE, false);
35
+ if (runtime !== undefined)
36
+ return runtime.platform;
37
+ if (Date.now() >= deadline)
38
+ return 'host';
39
+ await new Promise((resolve, reject) => {
40
+ const timer = globalThis.setTimeout(resolve, PREWARM_CONTRACT_POLL_MS);
41
+ signal.addEventListener('abort', () => {
42
+ globalThis.clearTimeout(timer);
43
+ reject(signal.reason instanceof Error ? signal.reason : new Error('codegraph initialization aborted'));
44
+ }, { once: true });
45
+ });
46
+ }
47
+ }
48
+ export function installCodegraph(ctx) {
49
+ if (moduleDisabled(process.env))
50
+ return () => { };
51
+ const warn = makeWarn(ctx);
52
+ const run = createCodegraphRunner(ctx.subprocess);
53
+ const platform = () => platformOf(ctx);
54
+ const noDaemon = () => platform() !== 'host';
55
+ const lifetime = new AbortController();
56
+ const disposers = [
57
+ registerGuide(ctx, warn),
58
+ installTools(ctx, {
59
+ run,
60
+ lifecycleSignal: lifetime.signal,
61
+ autoIndex: () => autoIndexEnabled(platform(), process.env),
62
+ noDaemon,
63
+ warn,
64
+ }),
65
+ ];
66
+ void (async () => {
67
+ const currentPlatform = await waitForPlatform(ctx, lifetime.signal);
68
+ const root = prewarmTarget(currentPlatform, process.cwd(), process.env);
69
+ if (root !== null)
70
+ await ensureIndex(run, root, lifetime.signal);
71
+ })().catch((error) => {
72
+ if (!lifetime.signal.aborted)
73
+ warn(`codegraph prewarm failed: ${error instanceof Error ? error.message : String(error)}`);
74
+ });
75
+ disposers.push(ctx.on('agent/created', ({ agent, signal }) => {
76
+ const root = prewarmTarget(platformOf(ctx), agent.session.header.cwd, process.env);
77
+ if (root === null)
78
+ return undefined;
79
+ const taskSignal = signal === undefined ? lifetime.signal : AbortSignal.any([lifetime.signal, signal]);
80
+ void ensureIndex(run, root, taskSignal).catch((error) => {
81
+ if (!taskSignal.aborted)
82
+ warn(`codegraph auto-index failed: ${error instanceof Error ? error.message : String(error)}`);
83
+ });
84
+ return undefined;
85
+ }));
86
+ return () => {
87
+ lifetime.abort(new Error('codegraph plugin disposed'));
88
+ for (const dispose of disposers) {
89
+ try {
90
+ dispose();
91
+ }
92
+ catch {
93
+ // 回收失败不阻断。
94
+ }
95
+ }
96
+ };
97
+ }
@@ -0,0 +1,227 @@
1
+ import { HarnessError } from '@deepseek-ai/dsh-llm';
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ import { ensureIndex } from "./ensure.js";
4
+ import { ensureGitExclude, findIndexRoot, resolveProjectTarget } from "./workspace.js";
5
+ const PATH_PARAM = {
6
+ type: 'string',
7
+ description: '项目目录。缺省取当前会话 cwd;相对路径按会话 cwd 解析,绝对路径原样使用;不向祖先借用索引。',
8
+ };
9
+ const SOURCE_NOTE = '数据源是本仓库的 .codegraph 结构索引。';
10
+ export class CodegraphToolError extends HarnessError {
11
+ }
12
+ /** argv 文本收窄:参数按 schema 只会是 string/integer,其它形状按缺省处理。 */
13
+ function argText(value, fallback) {
14
+ if (typeof value === 'string')
15
+ return value;
16
+ if (typeof value === 'number')
17
+ return String(value);
18
+ return fallback;
19
+ }
20
+ export function installTools(ctx, deps) {
21
+ const disposers = [];
22
+ const register = (name, description, parameters, argvOf, options = {}) => {
23
+ const tool = defineTool({
24
+ name,
25
+ description,
26
+ parameters: parameters,
27
+ output: {
28
+ schema: {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ properties: {
32
+ path: { type: 'string', required: true },
33
+ text: { type: 'string', required: true },
34
+ },
35
+ },
36
+ render: (_args, value) => [{ type: 'text', text: value.text }],
37
+ presentationMeta: (_args, value) => ({ path: value.path }),
38
+ },
39
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
40
+ isConcurrencySafe: () => options.concurrencySafe === true,
41
+ ...(options.presentCall === undefined ? {} : { presentCall: options.presentCall }),
42
+ execute: async (args, exec) => {
43
+ const sessionCwd = exec.agent?.session?.header?.cwd;
44
+ const given = args.path;
45
+ const cwd = typeof given === 'string' && given !== '' ? given : sessionCwd;
46
+ if (cwd === undefined) {
47
+ throw new CodegraphToolError('CodeGraph needs a project directory. Pass path or call it from an agent session with a workspace cwd.', 'CODEGRAPH_CWD_REQUIRED');
48
+ }
49
+ try {
50
+ const root = resolveProjectTarget(cwd, sessionCwd);
51
+ if (options.init === true && args.force !== true) {
52
+ await ensureIndex(deps.run, root, deps.lifecycleSignal);
53
+ return { path: root, text: 'CodeGraph index is ready.' };
54
+ }
55
+ if (options.query === true && findIndexRoot(root) === undefined) {
56
+ if (!deps.autoIndex()) {
57
+ throw new CodegraphToolError('This workspace has no CodeGraph index. Run codegraph_init once, then retry the query.', 'CODEGRAPH_NOT_INDEXED');
58
+ }
59
+ await ensureIndex(deps.run, root, deps.lifecycleSignal);
60
+ }
61
+ exec.signal?.throwIfAborted();
62
+ const out = await deps.run(argvOf(args, root), {
63
+ cwd: root,
64
+ ...(exec.signal !== undefined ? { signal: exec.signal } : {}),
65
+ noDaemon: deps.noDaemon(),
66
+ });
67
+ if (options.after !== undefined)
68
+ options.after(root);
69
+ return { path: root, text: out };
70
+ }
71
+ catch (error) {
72
+ if (error instanceof CodegraphToolError)
73
+ throw error;
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ deps.warn(`codegraph tool ${name} failed: ${message}`);
76
+ throw new CodegraphToolError(`CodeGraph ${name} failed: ${message}`, 'CODEGRAPH_FAILED', { cause: error });
77
+ }
78
+ },
79
+ });
80
+ disposers.push(ctx.tools.register(tool));
81
+ };
82
+ // ---- 索引维护 ----
83
+ register('codegraph_status', `返回项目 CodeGraph 索引的初始化状态、版本、文件数、节点数、边数和待同步变更。${SOURCE_NOTE}`, { path: PATH_PARAM }, (_args, root) => ['status', '--json', root], {
84
+ timeoutMs: 60_000,
85
+ concurrencySafe: true,
86
+ presentCall: args => ({
87
+ card: 'generic',
88
+ title: 'Check CodeGraph index status',
89
+ kind: 'search',
90
+ ...(typeof args.path === 'string' ? { locations: [{ path: args.path }] } : {}),
91
+ }),
92
+ });
93
+ register('codegraph_init', '在指定项目中初始化 CodeGraph 并建立完整索引;索引完成后返回。', { path: PATH_PARAM, force: { type: 'boolean', description: '路径是家目录/文件系统根时也强制初始化。' } }, (args, root) => (args.force === true ? ['init', '--yes', '--force', root] : ['init', '--yes', root]), {
94
+ timeoutMs: 600_000,
95
+ init: true,
96
+ after: ensureGitExclude,
97
+ presentCall: args => ({
98
+ card: 'generic',
99
+ title: 'Initialize CodeGraph index',
100
+ kind: 'execute',
101
+ ...(typeof args.path === 'string' ? { locations: [{ path: args.path }] } : {}),
102
+ }),
103
+ });
104
+ register('codegraph_sync', '将指定项目的文件变化增量同步到已有 CodeGraph 索引。', { path: PATH_PARAM }, (_args, root) => ['sync', root], {
105
+ timeoutMs: 300_000,
106
+ presentCall: args => ({
107
+ card: 'generic',
108
+ title: 'Sync CodeGraph index',
109
+ kind: 'execute',
110
+ ...(typeof args.path === 'string' ? { locations: [{ path: args.path }] } : {}),
111
+ }),
112
+ });
113
+ register('codegraph_index', '对指定项目执行全量索引;force 为 true 时丢弃已有索引并重建。', { path: PATH_PARAM, force: { type: 'boolean', description: '丢弃旧索引强制重建。' } }, (args, root) => (args.force === true ? ['index', '--force', root] : ['index', root]), { timeoutMs: 600_000 });
114
+ register('codegraph_uninit', '删除指定项目的 .codegraph 索引数据。', {
115
+ path: PATH_PARAM,
116
+ confirm: { type: 'boolean', required: true, description: '是否确认删除索引;必须为 true。' },
117
+ }, (args, root) => {
118
+ if (args.confirm !== true) {
119
+ throw new CodegraphToolError('CodeGraph index removal requires confirm=true.', 'CODEGRAPH_FAILED');
120
+ }
121
+ return ['uninit', '--force', root];
122
+ }, { timeoutMs: 120_000 });
123
+ register('codegraph_unlock', '删除指定项目中阻塞索引操作的陈旧 CodeGraph 锁文件。', { path: PATH_PARAM }, (_args, root) => ['unlock', root], { timeoutMs: 60_000 });
124
+ // ---- 查询与探索 ----
125
+ register('codegraph_explore', `返回与自然语言查询相关的符号源码及其调用或依赖路径,包含动态派发关系。${SOURCE_NOTE}`, {
126
+ query: { type: 'string', required: true, description: '自然语言区域描述或符号/文件名。' },
127
+ path: PATH_PARAM,
128
+ maxFiles: { type: 'integer', description: '最多包含源码的文件数。' },
129
+ }, (args, root) => {
130
+ const argv = ['explore', '--path', root];
131
+ if (args.maxFiles !== undefined)
132
+ argv.push('--max-files', argText(args.maxFiles, ''));
133
+ argv.push(argText(args.query, ''));
134
+ return argv;
135
+ },
136
+ // 自动索引面的首个调用可能内嵌一次 init(容器 2–4 分钟),预算必须盖住两段。
137
+ {
138
+ timeoutMs: 600_000,
139
+ query: true,
140
+ concurrencySafe: true,
141
+ presentCall: args => ({
142
+ card: 'generic',
143
+ title: `Explore code: ${argText(args.query, '')}`,
144
+ kind: 'search',
145
+ ...(typeof args.path === 'string' ? { locations: [{ path: args.path }] } : {}),
146
+ }),
147
+ });
148
+ register('codegraph_query', '按名称或子串搜索索引中的符号,返回类型、签名、文件、行号和匹配得分。', {
149
+ search: { type: 'string', required: true, description: '符号名或部分名。' },
150
+ path: PATH_PARAM,
151
+ limit: { type: 'integer', description: '结果上限(默认 10)。' },
152
+ kind: { type: 'string', description: '按节点类型过滤(function/class/method/interface/…)。' },
153
+ }, (args, root) => {
154
+ const argv = ['query', '--json', '--path', root, '-l', argText(args.limit, '10')];
155
+ if (args.kind !== undefined)
156
+ argv.push('-k', argText(args.kind, ''));
157
+ argv.push(argText(args.search, ''));
158
+ return argv;
159
+ }, { timeoutMs: 120_000, query: true });
160
+ register('codegraph_node', '返回单个符号的源码与调用关系;文件模式返回带行号的文件内容和依赖信息。', {
161
+ name: { type: 'string', required: true, description: '符号名(如 multiply)或文件路径(如 src/math.ts)。' },
162
+ path: PATH_PARAM,
163
+ file: { type: 'boolean', description: '把 name 当文件路径。' },
164
+ offset: { type: 'integer', description: '文件模式:1 起始行。' },
165
+ limit: { type: 'integer', description: '文件模式:最大行数。' },
166
+ symbolsOnly: { type: 'boolean', description: '文件模式:只给符号图与依赖,不含源码。' },
167
+ }, (args, root) => {
168
+ const argv = ['node', '--path', root];
169
+ if (args.file === true)
170
+ argv.push('--file', argText(args.name, ''));
171
+ else
172
+ argv.push(argText(args.name, ''));
173
+ if (args.offset !== undefined)
174
+ argv.push('--offset', argText(args.offset, ''));
175
+ if (args.limit !== undefined)
176
+ argv.push('--limit', argText(args.limit, ''));
177
+ if (args.symbolsOnly === true)
178
+ argv.push('--symbols-only');
179
+ return argv;
180
+ }, { timeoutMs: 120_000, query: true });
181
+ register('codegraph_files', '返回索引中的文件结构、语言和符号数量,可按目录、glob 和深度过滤。', {
182
+ path: PATH_PARAM,
183
+ filter: { type: 'string', description: '只看该目录下。' },
184
+ pattern: { type: 'string', description: 'glob 过滤(如 src/**/*.ts)。' },
185
+ format: { type: 'string', description: '输出:tree / flat / grouped。' },
186
+ maxDepth: { type: 'integer', description: 'tree 格式最大深度。' },
187
+ }, (args, root) => {
188
+ const argv = ['files', '--json', '--path', root];
189
+ if (args.filter !== undefined)
190
+ argv.push('--filter', argText(args.filter, ''));
191
+ if (args.pattern !== undefined)
192
+ argv.push('--pattern', argText(args.pattern, ''));
193
+ if (args.format !== undefined)
194
+ argv.push('--format', argText(args.format, ''));
195
+ if (args.maxDepth !== undefined)
196
+ argv.push('--max-depth', argText(args.maxDepth, ''));
197
+ return argv;
198
+ }, { timeoutMs: 120_000, query: true });
199
+ // ---- 关系查询 ----
200
+ register('codegraph_callers', '返回直接调用指定符号的节点及其名称、类型、文件和行号。', { symbol: { type: 'string', required: true, description: '目标符号。' }, path: PATH_PARAM, limit: { type: 'integer', description: '上限(默认 20)。' } }, (args, root) => ['callers', '--json', '--path', root, '-l', argText(args.limit, '20'), argText(args.symbol, '')], { timeoutMs: 120_000, query: true });
201
+ register('codegraph_callees', '返回指定符号直接调用的节点。', { symbol: { type: 'string', required: true, description: '目标符号。' }, path: PATH_PARAM, limit: { type: 'integer', description: '上限(默认 20)。' } }, (args, root) => ['callees', '--json', '--path', root, '-l', argText(args.limit, '20'), argText(args.symbol, '')], { timeoutMs: 120_000, query: true });
202
+ register('codegraph_impact', '返回从指定符号沿反向依赖关系传播得到的影响节点。', { symbol: { type: 'string', required: true, description: '目标符号。' }, path: PATH_PARAM, depth: { type: 'integer', description: '遍历深度(默认 2)。' } }, (args, root) => ['impact', '--json', '--path', root, '-d', argText(args.depth, '2'), argText(args.symbol, '')], { timeoutMs: 120_000, query: true });
203
+ register('codegraph_affected', '根据变更文件和依赖关系返回可能受影响的测试文件。', {
204
+ files: { type: 'array', required: true, items: { type: 'string' }, description: '变更的源文件路径(相对项目根)。' },
205
+ path: PATH_PARAM,
206
+ depth: { type: 'integer', description: '依赖遍历深度(默认 5)。' },
207
+ filter: { type: 'string', description: '测试文件 glob 覆盖。' },
208
+ }, (args, root) => {
209
+ const argv = ['affected', '--json', '--path', root, '-d', argText(args.depth, '5')];
210
+ if (args.filter !== undefined)
211
+ argv.push('--filter', argText(args.filter, ''));
212
+ if (Array.isArray(args.files))
213
+ for (const file of args.files)
214
+ argv.push(argText(file, ''));
215
+ return argv;
216
+ }, { timeoutMs: 120_000, query: true });
217
+ return () => {
218
+ for (const dispose of disposers) {
219
+ try {
220
+ dispose();
221
+ }
222
+ catch {
223
+ // 回收失败不阻断。
224
+ }
225
+ }
226
+ };
227
+ }
@@ -0,0 +1,52 @@
1
+ // @fa-software/semi2-dsh/codegraph/workspace —— 工作区索引检测与索引卫生(纯函数,可单测)。
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ /** 向上找目录的步数上限:再深的嵌套也不该离开项目树。 */
5
+ const MAX_WALK = 12;
6
+ function walkUp(start, marker) {
7
+ let dir = start;
8
+ for (let i = 0; i < MAX_WALK && dir !== ''; i++) {
9
+ if (marker(dir))
10
+ return dir;
11
+ const parent = dirname(dir);
12
+ if (parent === dir)
13
+ break;
14
+ dir = parent;
15
+ }
16
+ return undefined;
17
+ }
18
+ /** 工作区 cwd 自身已建立索引时返回该目录;不向祖先目录借用索引。 */
19
+ export function findIndexRoot(cwd) {
20
+ return existsSync(join(cwd, '.codegraph', 'codegraph.db')) ? cwd : undefined;
21
+ }
22
+ /** 最近的 git 根:自动索引只在 git 仓库里做,防止在任意目录(如 ~)建索引。 */
23
+ export function findGitRoot(cwd) {
24
+ return walkUp(cwd, dir => existsSync(join(dir, '.git')));
25
+ }
26
+ /** 显式绝对路径原样使用;相对路径按当前会话工作区解析,与 dsh 文件工具一致。 */
27
+ export function resolveProjectTarget(path, sessionCwd) {
28
+ if (isAbsolute(path))
29
+ return path;
30
+ return resolve(sessionCwd ?? process.cwd(), path);
31
+ }
32
+ /** `.codegraph/` 进 `.git/info/exclude`(本地忽略,不动已跟踪文件,`git status` 保持干净);幂等、best-effort。 */
33
+ export function ensureGitExclude(root) {
34
+ try {
35
+ const gitRoot = findGitRoot(root);
36
+ if (gitRoot === undefined)
37
+ return;
38
+ const infoDir = join(gitRoot, '.git', 'info');
39
+ const excludePath = join(infoDir, 'exclude');
40
+ mkdirSync(infoDir, { recursive: true });
41
+ let current = '';
42
+ if (existsSync(excludePath))
43
+ current = readFileSync(excludePath, 'utf8');
44
+ if (!current.split('\n').includes('.codegraph/')) {
45
+ const next = current === '' ? '.codegraph/\n' : current.endsWith('\n') ? `${current}.codegraph/\n` : `${current}\n.codegraph/\n`;
46
+ writeFileSync(excludePath, next, 'utf8');
47
+ }
48
+ }
49
+ catch {
50
+ // 排除写不进不致命:大不了 git status 多一个未跟踪目录。
51
+ }
52
+ }
@@ -0,0 +1,177 @@
1
+ /** 路由键 hex 对人无意义:用 `displayName` 覆盖显示名(`llm-pi-ai` 语义:`displayName ?? 路由键`)。 */
2
+ export const CONSOLE_PROVIDER_DISPLAY_NAME = 'semi2-hub';
3
+ /** 路由键必须留 hex——协议路由与 `agent-default-model` 都引用它。 */
4
+ export function connectionRoute(connectionId) {
5
+ return `semi2-${connectionId.replaceAll('-', '')}`;
6
+ }
7
+ /**
8
+ * 一条连接在 dsh 里的路由键。
9
+ *
10
+ * 预设连接直接用**预设 id**(= dsh 的提供方路由 id):`llm-pi-ai` 只在路由名能对上已装目录时
11
+ * 才复用那份目录条目(compat / headers / cost / thinkingLevelMap),换个名字这些全丢。
12
+ * 自定义连接没有可对应的目录条目,用自铸的 hex 路由 + 显式 `api` / `baseURL`。
13
+ *
14
+ * DEEPSEEK 协议走内置原生适配器(独立于 pi-ai 目录),与预设/自定义无关。
15
+ */
16
+ export function connectionProviderName(connection) {
17
+ if (connection.protocol === 'DEEPSEEK')
18
+ return 'deepseek-official';
19
+ return connection.provider ?? connectionRoute(connection.id);
20
+ }
21
+ /** 密钥的进程 env 名(= dsh credentials 引用名)。 */
22
+ export function connectionApiKeyEnv(connectionId) {
23
+ return `SEMI2_MODEL_KEY_${connectionId.replaceAll('-', '').toUpperCase()}`;
24
+ }
25
+ export function protocolApi(protocol) {
26
+ return protocol === 'ANTHROPIC'
27
+ ? 'anthropic-messages'
28
+ : protocol === 'OPENAI_RESPONSES'
29
+ ? 'openai-responses'
30
+ : 'openai-completions';
31
+ }
32
+ export function modelName(model) {
33
+ return model.displayName ?? model.upstreamId;
34
+ }
35
+ /** 写入 settings 的 profile:预设路由省略 `api` / `baseURL`,其余按需带上。 */
36
+ export function providerProfile(compiled) {
37
+ return {
38
+ ...(compiled.preset ? {} : { api: compiled.api, baseURL: compiled.baseURL }),
39
+ apiKeyEnv: compiled.apiKeyEnv,
40
+ displayName: compiled.displayName,
41
+ models: compiled.models,
42
+ };
43
+ }
44
+ /**
45
+ * 按实际落地的路由表改写选择。路由让位(同名用户 profile)后,编译期算出的
46
+ * 路由键已不是最终值,默认模型与项目 plan/execute 都必须跟着换。
47
+ */
48
+ export function remapSelections(compiled, routes) {
49
+ const rewrite = (selection) => {
50
+ const route = routes.get(selection.provider);
51
+ return route === undefined || route === selection.provider
52
+ ? selection
53
+ : { provider: route, model: selection.model };
54
+ };
55
+ return {
56
+ ...compiled,
57
+ defaultModel: compiled.defaultModel === null ? null : rewrite(compiled.defaultModel),
58
+ selections: new Map([...compiled.selections].map(([id, selection]) => [id, rewrite(selection)])),
59
+ };
60
+ }
61
+ function modality(value) {
62
+ return value === 'IMAGE' ? 'image' : 'text';
63
+ }
64
+ function level(value) {
65
+ return value.toLowerCase();
66
+ }
67
+ /** llm-pi-ai:全 off → `false`(不支持推理档),档位里的 `off` 表示不可用 → null。 */
68
+ function reasoningEfforts(levels) {
69
+ if (levels === null)
70
+ return undefined;
71
+ const lowered = levels.map(level);
72
+ if (lowered.every(entry => entry === 'off'))
73
+ return false;
74
+ return Object.fromEntries(lowered.map(entry => [entry, entry === 'off' ? null : entry]));
75
+ }
76
+ function selectionOf(model) {
77
+ return {
78
+ provider: connectionProviderName(model.connection),
79
+ model: model.upstreamId,
80
+ };
81
+ }
82
+ /**
83
+ * 预设连接的 profile:路由键 = 预设 id,模型清单来自 hub 的内置目录。
84
+ *
85
+ * `preset: true` 让写入侧省略 `api` / `baseURL`:
86
+ * · 不写 `api` —— `llm-pi-ai` 只在 profile 不覆盖线协议时才复用目录 provider,
87
+ * 写死协议会把多协议提供方(openrouter)压成一种;
88
+ * · 不写 `baseURL` —— 一个路由的模型可能分属不同 base(openrouter 的 anthropic 模型在
89
+ * `/api`、其余在 `/api/v1`);hub 的预设地址就是目录地址,交给目录逐模型给。
90
+ * 于是 compat / headers / cost / thinkingLevelMap 全部沿用 dsh 目录里的值。
91
+ */
92
+ function presetProvider(route, connection, models) {
93
+ return {
94
+ preset: true,
95
+ connectionId: connection.id,
96
+ api: protocolApi(connection.protocol),
97
+ baseURL: connection.baseUrl,
98
+ apiKeyEnv: connectionApiKeyEnv(connection.id),
99
+ displayName: route,
100
+ models: models.map(model => ({
101
+ id: model.upstreamId,
102
+ name: modelName(model),
103
+ ...(model.metadata.contextWindow === null
104
+ ? {}
105
+ : { contextWindow: model.metadata.contextWindow }),
106
+ ...(model.metadata.maxTokens === null
107
+ ? {}
108
+ : { maxTokens: model.metadata.maxTokens }),
109
+ ...(model.metadata.input === null
110
+ ? {}
111
+ : { input: model.metadata.input.map(modality) }),
112
+ })),
113
+ };
114
+ }
115
+ export function compileBoot(models, defaultModelId) {
116
+ const providers = {};
117
+ const deepseekModels = [];
118
+ let deepseek = null;
119
+ const selections = new Map();
120
+ const presetConnections = new Map();
121
+ for (const model of models) {
122
+ const { contextWindow, maxTokens, input } = model.metadata;
123
+ selections.set(model.id, selectionOf(model));
124
+ if (model.connection.protocol === 'DEEPSEEK') {
125
+ deepseek ??= {
126
+ apiKeyEnv: connectionApiKeyEnv(model.connection.id),
127
+ baseURL: model.connection.baseUrl,
128
+ models: deepseekModels,
129
+ };
130
+ deepseekModels.push({
131
+ id: model.upstreamId,
132
+ name: modelName(model),
133
+ ...(contextWindow === null ? {} : { contextWindow }),
134
+ ...(maxTokens === null ? {} : { maxTokens }),
135
+ ...(input === null ? {} : { inputModalities: input.map(modality) }),
136
+ });
137
+ continue;
138
+ }
139
+ // 预设连接:按提供方 id 聚合,最后交给目录 provider(hub 保证一个预设只需一条连接)。
140
+ // `?? null` 收口:契约里 provider 可空,hub 未下发该字段时按自定义连接处理。
141
+ const preset = model.connection.provider ?? null;
142
+ if (preset !== null) {
143
+ const bucket = presetConnections.get(preset);
144
+ if (bucket === undefined)
145
+ presetConnections.set(preset, {
146
+ connection: model.connection,
147
+ models: [model],
148
+ });
149
+ else
150
+ bucket.models.push(model);
151
+ continue;
152
+ }
153
+ const route = connectionRoute(model.connection.id);
154
+ const efforts = reasoningEfforts(model.metadata.reasoningLevels);
155
+ const provider = (providers[route] ??= {
156
+ preset: false,
157
+ connectionId: model.connection.id,
158
+ api: protocolApi(model.connection.protocol),
159
+ baseURL: model.connection.baseUrl,
160
+ apiKeyEnv: connectionApiKeyEnv(model.connection.id),
161
+ displayName: CONSOLE_PROVIDER_DISPLAY_NAME,
162
+ models: [],
163
+ });
164
+ provider.models.push({
165
+ id: model.upstreamId,
166
+ name: modelName(model),
167
+ ...(contextWindow === null ? {} : { contextWindow }),
168
+ ...(maxTokens === null ? {} : { maxTokens }),
169
+ ...(input === null ? {} : { input: input.map(modality) }),
170
+ ...(efforts === undefined ? {} : { reasoningEfforts: efforts }),
171
+ });
172
+ }
173
+ for (const [route, bucket] of presetConnections)
174
+ providers[route] = presetProvider(route, bucket.connection, bucket.models);
175
+ const defaultModel = defaultModelId === null ? null : (selections.get(defaultModelId) ?? null);
176
+ return { providers, deepseek, defaultModel, selections };
177
+ }
@@ -0,0 +1,104 @@
1
+ import { credentialRef, } from '@deepseek-ai/dsh-credentials';
2
+ import { ConfigurationError } from './graphql.js';
3
+ export const CONFIG_URL_REF = 'SEMI2_CONFIG_URL';
4
+ export const ACCESS_TOKEN_REF = 'SEMI2_ACCESS_TOKEN';
5
+ const URL_REF = credentialRef(CONFIG_URL_REF);
6
+ const TOKEN_REF = credentialRef(ACCESS_TOKEN_REF);
7
+ function provider(ctx) {
8
+ try {
9
+ return ctx.get('credentials');
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ function text(value) {
16
+ const trimmed = value?.trim();
17
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed;
18
+ }
19
+ async function resolveField(credentials, ref, explicit, fallback) {
20
+ const contractValue = text(explicit);
21
+ if (contractValue !== undefined)
22
+ return { value: contractValue, source: 'runtime' };
23
+ if (credentials !== undefined) {
24
+ const resolved = await credentials.resolve(ref);
25
+ const value = text(resolved?.value);
26
+ return value === undefined
27
+ ? undefined
28
+ : { value, source: resolved?.source ?? 'credentials' };
29
+ }
30
+ const value = text(fallback);
31
+ return value === undefined ? undefined : { value, source: 'env' };
32
+ }
33
+ /** 两条腿齐备才算可连;只有地址时调用方用 `url.value` 预填登录表单。 */
34
+ export function connectionOf(fields) {
35
+ if (fields.url === undefined || fields.token === undefined)
36
+ return null;
37
+ return {
38
+ url: fields.url.value,
39
+ token: fields.token.value,
40
+ source: fields.token.source,
41
+ };
42
+ }
43
+ /** 分别解析地址与令牌(不做齐备性判定):`resolveConnection` 与状态回报共用这条路。 */
44
+ export async function resolveConnectionFields(ctx, runtimeContract, environment = process.env) {
45
+ const credentials = provider(ctx);
46
+ const contract = runtimeContract?.credentials ?? {};
47
+ const [url, token] = await Promise.all([
48
+ resolveField(credentials, URL_REF, contract.configUrl, environment.SEMI2_CONFIG_URL),
49
+ resolveField(credentials, TOKEN_REF, contract.accessToken, environment.SEMI2_ACCESS_TOKEN),
50
+ ]);
51
+ // 启动环境层(`source: env`)的令牌只属 CNB 面:`runtimeFromCnbEnv` 从 secret imports 注入的 env 取值。
52
+ // 宿主 / 容器面认这一层等于自锁——该层只读(dsh 拒绝 set/unset)又排在临时存储之前,设置页会只剩只读状态
53
+ // (没有登录入口),登录写进去的令牌也读不回来(`runtime.ts` 另在启动时把它从进程 env 摘掉)。
54
+ // 地址不是密钥、还是登录表单的预填来源,任何面都保留;契约与登录存储的令牌一律照收。
55
+ const usable = token !== undefined &&
56
+ (token.source !== 'env' || runtimeContract?.platform === 'cnb')
57
+ ? token
58
+ : undefined;
59
+ return {
60
+ ...(url === undefined ? {} : { url }),
61
+ ...(usable === undefined ? {} : { token: usable }),
62
+ };
63
+ }
64
+ /** 解析当前连接凭据;缺任一项返回 null(调用方据此不注入)。 */
65
+ export async function resolveConnection(ctx, runtimeContract, environment = process.env) {
66
+ return connectionOf(await resolveConnectionFields(ctx, runtimeContract, environment));
67
+ }
68
+ function sameUrl(left, right) {
69
+ const normalize = (value) => (value ?? '').trim().replace(/\/+$/, '');
70
+ return normalize(left) === normalize(right);
71
+ }
72
+ /**
73
+ * 部署面常用 env 钉死地址,credentials-local 会拒绝被 env 遮蔽的 `set`:来源一致时跳过写入、
74
+ * 不一致时抛 CREDENTIAL_SOURCE_READONLY(登录不改写部署面钉死的地址)。
75
+ */
76
+ async function storeUrl(credentials, url) {
77
+ const info = await credentials.describe(URL_REF);
78
+ if (info.writable) {
79
+ await credentials.set(URL_REF, url);
80
+ return;
81
+ }
82
+ const current = await credentials.resolve(URL_REF);
83
+ if (!sameUrl(current?.value, url))
84
+ throw new ConfigurationError('CREDENTIAL_SOURCE_READONLY');
85
+ }
86
+ /** 写入登录凭据;env 遮蔽 token 时 dsh 会拒绝——由调用方转成可读错误。 */
87
+ export async function storeConnection(ctx, input) {
88
+ const credentials = provider(ctx);
89
+ if (credentials === undefined)
90
+ throw new Error('CREDENTIAL_STORE_UNAVAILABLE');
91
+ await storeUrl(credentials, input.url);
92
+ await credentials.set(TOKEN_REF, input.token);
93
+ }
94
+ /** 清除登录凭据(不触碰运行面契约/env 提供的凭据)。 */
95
+ export async function clearConnection(ctx) {
96
+ const credentials = provider(ctx);
97
+ if (credentials === undefined)
98
+ return;
99
+ await credentials.unset(TOKEN_REF);
100
+ // URL 非密钥且可能由 env 钉死(unset 会被拒):只清可写来源,避免登出半途失败。
101
+ const info = await credentials.describe(URL_REF);
102
+ if (info.writable)
103
+ await credentials.unset(URL_REF);
104
+ }
@@ -0,0 +1 @@
1
+ export {};