dsh-tap 0.20.0
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.
- package/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- package/scripts/verify-trae-provider.mjs +1451 -0
package/index.js
ADDED
|
@@ -0,0 +1,2306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tap — composition root.
|
|
3
|
+
*
|
|
4
|
+
* Three layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. cordis.patch.yml — static config: the codebuddy provider route on the
|
|
7
|
+
* `llm-pi-ai` row (pointed at the loopback bridge below, so the main
|
|
8
|
+
* chat path shares this module's credential resolution), the default
|
|
9
|
+
* model on `agent-default-model`, the provider pins on the `web` row,
|
|
10
|
+
* and the entry-list `insert` that makes the loader run this module at all.
|
|
11
|
+
* 2. This module — composition + runtime registration. The provider-agnostic
|
|
12
|
+
* machinery lives in core/ (json-store / rotation / usage-meter / bridge);
|
|
13
|
+
* every CodeBuddy gateway fact lives in providers/codebuddy/ (headers,
|
|
14
|
+
* error codes, OAuth flow, catalog/quota dialect, agenttool, images) —
|
|
15
|
+
* adjudicated per-field by docs/rules/*.md. This file wires them together:
|
|
16
|
+
* backs dsh's stock `web_search` / `web_fetch` tools with the gateway's
|
|
17
|
+
* /agenttool endpoints, and runs the loopback stream bridge for tools that
|
|
18
|
+
* need classic non-streaming OpenAI JSON (the gateway is stream-only,
|
|
19
|
+
* error 11101). The bridge owns ALL credential resolution (OAuth or API
|
|
20
|
+
* key) — callers (llm-pi-ai's chat path included) send a sentinel
|
|
21
|
+
* Authorization the bridge replaces per request. It also meters every
|
|
22
|
+
* billed request (usage.credit + cache counters →
|
|
23
|
+
* ~/.dsh/codebuddy-plugin-usage.json) for the settings card.
|
|
24
|
+
* 3. lib/client.js — browser half: a settings card in Settings → 插件配置.
|
|
25
|
+
*
|
|
26
|
+
* 官方缝使用说明(红线:能用 ctx.credentials / ctx.llm / ctx.web 的地方不自建):
|
|
27
|
+
* - ctx.web:已用(搜索/抓取后端经 registerSearchProvider/registerFetchProvider)。
|
|
28
|
+
* - ctx.credentials:不能用——主聊天经 patch 指向本地桥,pi-ai 侧只认静态哨兵
|
|
29
|
+
* Authorization(机制见 docs/pitfalls.md #11);每请求的多 Key 轮询/冷却/failover
|
|
30
|
+
* 与 OAuth 刷新必须在桥内完成,宿主凭据缝无法覆盖这条路径,故凭据解析自建于此。
|
|
31
|
+
* - ctx.llm:未用——桥是传输层代理不是模型提供方,模型清单走 cordis.patch.yml。
|
|
32
|
+
*
|
|
33
|
+
* Config surface (Settings → 插件配置 → CodeBuddy, file-backed in
|
|
34
|
+
* ~/.dsh/codebuddy-plugin.json): login mode (multiple API keys with an
|
|
35
|
+
* active selection, or CodeBuddy browser OAuth), gateway baseURL, search
|
|
36
|
+
* and fetch knobs, and the stream bridge switch/port. Values apply live.
|
|
37
|
+
*
|
|
38
|
+
* Credentials:
|
|
39
|
+
* - api-key mode: the active entry of `apiKeys` (fallback: the legacy
|
|
40
|
+
* `apiKeyEnv` env/credentials.yaml reference).
|
|
41
|
+
* - oauth mode: tokens from the browser handshake (state → login → token
|
|
42
|
+
* polling → account), stored separately in ~/.dsh/codebuddy-plugin-auth.json
|
|
43
|
+
* so the settings GET never carries secrets; access tokens auto-refresh
|
|
44
|
+
* via the refresh token with a single-flight lock.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { chmodSync, readFileSync } from 'node:fs'
|
|
48
|
+
import { homedir } from 'node:os'
|
|
49
|
+
import { join, dirname, basename, isAbsolute, normalize, extname } from 'node:path'
|
|
50
|
+
import { fileURLToPath } from 'node:url'
|
|
51
|
+
import YAML from 'yaml'
|
|
52
|
+
import z from '@deepseek-ai/schemastery'
|
|
53
|
+
|
|
54
|
+
import { readJson, writeJson, writeTextAtomic, resolveEnvKey } from './core/json-store.js'
|
|
55
|
+
import { KeyRotator } from './core/rotation.js'
|
|
56
|
+
import { createUsageMeter } from './core/usage-meter.js'
|
|
57
|
+
import { createBridge } from './core/bridge.js'
|
|
58
|
+
import { createCodeBuddyProvider } from './providers/codebuddy/index.js'
|
|
59
|
+
import { CREDENTIAL_UNAVAILABLE_MESSAGE } from './providers/codebuddy/errors.js'
|
|
60
|
+
import { UNROUTABLE_MODELS } from './providers/codebuddy/catalog.js'
|
|
61
|
+
import { createTraeProvider } from './providers/trae/index.js'
|
|
62
|
+
import { TRAE_CREDENTIAL_UNAVAILABLE_MESSAGE } from './providers/trae/errors.js'
|
|
63
|
+
import { PROVIDER_ID_RE, createOpenAICompatProvider, rebuildAdapterForEntry } from './providers/openai-compat.js'
|
|
64
|
+
import { QODER_CLIENT_ID } from './providers/qoder/oauth.js'
|
|
65
|
+
import { createQoderProvider } from './providers/qoder/index.js'
|
|
66
|
+
import { applyQoderContextVariant } from './providers/qoder/catalog.js'
|
|
67
|
+
import { scanLocalCredentials, readImportCredential } from './local-scan.js'
|
|
68
|
+
import { createHostConfigLayer } from './host-config.js'
|
|
69
|
+
import arkProvider from './providers/ark/index.js'
|
|
70
|
+
import bailianProvider from './providers/bailian/index.js'
|
|
71
|
+
import deepseekProvider from './providers/deepseek/index.js'
|
|
72
|
+
import bigmodelProvider from './providers/bigmodel/index.js'
|
|
73
|
+
import moonshotProvider from './providers/moonshot/index.js'
|
|
74
|
+
import openrouterProvider from './providers/openrouter/index.js'
|
|
75
|
+
import qwenProvider from './providers/qwen/index.js'
|
|
76
|
+
|
|
77
|
+
export const name = 'dsh-tap'
|
|
78
|
+
|
|
79
|
+
/** web providers via ctx.web; the settings route rides ctx.webServer when present. */
|
|
80
|
+
export const inject = ['web']
|
|
81
|
+
|
|
82
|
+
const DSH_HOME = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
83
|
+
const SETTINGS_PATH = join(DSH_HOME, 'codebuddy-plugin.json')
|
|
84
|
+
const AUTH_PATH = join(DSH_HOME, 'codebuddy-plugin-auth.json')
|
|
85
|
+
const TRAE_AUTH_PATH = join(DSH_HOME, 'trae-plugin-auth.json')
|
|
86
|
+
const QODER_AUTH_PATH = join(DSH_HOME, 'qoder-plugin-auth.json')
|
|
87
|
+
const DSH_SETTINGS_PATH = join(DSH_HOME, 'settings.yaml')
|
|
88
|
+
const DSH_CREDENTIALS_PATH = join(DSH_HOME, '.credentials.yaml')
|
|
89
|
+
const PATCH_FILE = join(dirname(fileURLToPath(import.meta.url)), 'cordis.patch.yml')
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Optional settings. Schema defaults apply when neither the composition
|
|
93
|
+
* entry nor the settings file sets a field.
|
|
94
|
+
*/
|
|
95
|
+
export const Config = z.object({
|
|
96
|
+
authMode: z.union([z.const('api-key'), z.const('oauth')]).default('api-key'),
|
|
97
|
+
apiKeyEnv: z.string().role('credential-ref').default('CODEBUDDY_API_KEY'),
|
|
98
|
+
apiKeys: z.array(z.object({
|
|
99
|
+
name: z.string(),
|
|
100
|
+
key: z.string().role('secret'),
|
|
101
|
+
})).default([]),
|
|
102
|
+
activeApiKey: z.string(),
|
|
103
|
+
searchEnabled: z.boolean().default(true),
|
|
104
|
+
baseURL: z.string().default('https://copilot.tencent.com'),
|
|
105
|
+
searchMaxResults: z.number().step(1).min(1).max(20).default(5),
|
|
106
|
+
fetchBodyCap: z.number().step(1000).min(1000).default(200000),
|
|
107
|
+
bridgeEnabled: z.boolean().default(true),
|
|
108
|
+
bridgePort: z.number().step(1).min(1).max(65535).default(3901),
|
|
109
|
+
sessionHeadersEnabled: z.boolean().default(true),
|
|
110
|
+
sessionHeaderFormat: z.union([z.const('openai'), z.const('openrouter')]).default('openai'),
|
|
111
|
+
maxConcurrentPerSession: z.number().step(1).min(1).max(100).default(4),
|
|
112
|
+
// 本地特权面(/dsh-tap/settings)的额外放行 Origin 完整串清单(精确匹配,
|
|
113
|
+
// 非前缀/通配)。宿主壳的页面经壳转发本地请求时,Origin 是壳自己的自定义
|
|
114
|
+
// scheme(host 不等于回环 Host),默认按跨站拒;需要壳内设置卡写入的宿主
|
|
115
|
+
// 把该 scheme 完整串(如壳协议://页面 host)填进来。默认空 = 行为不变。
|
|
116
|
+
localAllowedOrigins: z.array(z.string()).default([]),
|
|
117
|
+
imageGenEnabled: z.boolean().default(true),
|
|
118
|
+
imageGenModel: z.string().default('hunyuan-image-v3.0-art'),
|
|
119
|
+
keyCooldownMs: z.number().step(100).min(100).default(60000),
|
|
120
|
+
// G3 估算档:api-key 模式下用户手填的总额度(credit),卡片显示
|
|
121
|
+
// 「手填总额 − 本插件计量累计」并标注"估算";0 = 未设置。OAuth 模式不用
|
|
122
|
+
// (真实数值来自 /billing/meter/get-user-resource,quota-signals.md R-Q7)。
|
|
123
|
+
quotaTotalManual: z.number().min(0).default(0),
|
|
124
|
+
// G8 逐模型思考强度:{ [modelId]: 档位名 }。桥出站对未显式携带
|
|
125
|
+
// reasoning_effort 的请求按静态清单的 reasoningEfforts 表注入线值;
|
|
126
|
+
// off 的线值是 null(= 省略参数)或无表模型/非法档位都不注入。
|
|
127
|
+
effortByModel: z.dict(z.string(), z.string()).default({}),
|
|
128
|
+
// ---- TraeWork CN(Trae 订阅额度通道,v0.8.x)----
|
|
129
|
+
// 默认关闭:开启后同步本机 state.vscdb 模型目录到选择器(providers.trae),
|
|
130
|
+
// 并在 traeBridgePort 上启动 OpenAI↔Trae 翻译网关;主聊天经 patch 的 trae
|
|
131
|
+
// 路由(哨兵 Authorization)走该网关消耗 Trae 订阅额度。
|
|
132
|
+
traeEnabled: z.boolean().default(false),
|
|
133
|
+
traeAuthBaseURL: z.string().default('https://api.trae.cn'),
|
|
134
|
+
traeChatBaseURL: z.string().default('https://trae-api-cn.mchost.guru'),
|
|
135
|
+
traeLoginHost: z.string().default('https://www.trae.cn'),
|
|
136
|
+
traeBridgePort: z.number().step(1).min(1).max(65535).default(3902),
|
|
137
|
+
// 聊天传输:agent(默认,2026-10-05 起,llm_utils_chat+solo_work_lite——原生
|
|
138
|
+
// tools + 并行调用 + role:tool 回传闭环(历史 assistant.tool_calls 出站键=
|
|
139
|
+
// function_call);模型位钉死 glm-5.2(timing_cost 真值源,改派诚实披露);
|
|
140
|
+
// reasoning_effort 被忽略;耗 IDE 额度池。证据 docs/probes/trae-agent-v3-*.jsonl)
|
|
141
|
+
// | inline(llm_utils_chat+inline_chat——模型恒为账户默认,原生 tools,耗 IDE 池;
|
|
142
|
+
// 2026-08-24 起服务端 3003 故障未愈,docs/diagnosis-trae-3003.md)
|
|
143
|
+
// | remote(chat_sessions——模型选择真实生效,不支持 tools,每请求起云端沙箱
|
|
144
|
+
// agent,耗 work 额度池,providers/trae/remote.js 文件头)。
|
|
145
|
+
// 默认值 inline→agent 的决策(2026-10-05 用户拍板):主聊天恒带 agent 工具表,
|
|
146
|
+
// inline 故障期对主聊天必炸 3003;agent 面工具环可用(模型钉死已诚实披露)。
|
|
147
|
+
traeChatTransport: z.union([z.const('inline'), z.const('remote'), z.const('agent')]).default('agent'),
|
|
148
|
+
// inline 上游首字节护栏(毫秒):边缘/本地代理"收下请求不回应"时快速失败,
|
|
149
|
+
// 避免用户请求无限挂死(2026-08-24 故障取证 docs/diagnosis-trae-3003.md §8)。
|
|
150
|
+
// 仅约束响应头到达前;SSE 长流在头到达后不受影响。
|
|
151
|
+
upstreamFirstByteTimeoutMs: z.number().step(1).min(1000).max(300_000).default(45_000),
|
|
152
|
+
// ---- Qoder CN(第三上游)----
|
|
153
|
+
// 设备流事实见 providers/qoder/oauth.js 文件头与 docs/goals/qoder-cn-provider-design.md。
|
|
154
|
+
// 默认关闭:开启后同步网关模型目录到选择器(providers.qoder),并在
|
|
155
|
+
// qoderBridgePort 上起 OpenAI↔COSY 翻译网关;主聊天经镜像路由(哨兵
|
|
156
|
+
// Authorization)走该网关。qoderClientId 默认官方 prod 值,一般无需改。
|
|
157
|
+
qoderLoginHost: z.string().default('https://qoder.cn'),
|
|
158
|
+
qoderOpenapiBaseURL: z.string().default('https://openapi.qoder.com.cn'),
|
|
159
|
+
qoderClientId: z.string().default(QODER_CLIENT_ID),
|
|
160
|
+
qoderEnabled: z.boolean().default(false),
|
|
161
|
+
qoderBridgePort: z.number().step(1).min(1).max(65535).default(3903),
|
|
162
|
+
// infer 节点(region 发现服务实测 CN = gateway.qoder.com.cn;不是
|
|
163
|
+
// api2-v2.qoder.sh——那个 OpenAI 面对本通道 401,见设计文档 §5b 修订)。
|
|
164
|
+
qoderInferBaseURL: z.string().default('https://gateway.qoder.com.cn'),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/** Field metadata the settings card renders (labels live client-side). */
|
|
168
|
+
export const SETTINGS_FIELDS = [
|
|
169
|
+
{ key: 'authMode', kind: 'select' },
|
|
170
|
+
{ key: 'apiKeyEnv', kind: 'text' },
|
|
171
|
+
{ key: 'apiKeys', kind: 'keys' },
|
|
172
|
+
{ key: 'activeApiKey', kind: 'text' },
|
|
173
|
+
{ key: 'searchEnabled', kind: 'boolean' },
|
|
174
|
+
{ key: 'baseURL', kind: 'text' },
|
|
175
|
+
{ key: 'searchMaxResults', kind: 'number' },
|
|
176
|
+
{ key: 'fetchBodyCap', kind: 'number' },
|
|
177
|
+
{ key: 'bridgeEnabled', kind: 'boolean' },
|
|
178
|
+
{ key: 'bridgePort', kind: 'number' },
|
|
179
|
+
{ key: 'sessionHeadersEnabled', kind: 'boolean' },
|
|
180
|
+
{ key: 'sessionHeaderFormat', kind: 'select' },
|
|
181
|
+
{ key: 'maxConcurrentPerSession', kind: 'number' },
|
|
182
|
+
{ key: 'imageGenEnabled', kind: 'boolean' },
|
|
183
|
+
{ key: 'imageGenModel', kind: 'text' },
|
|
184
|
+
{ key: 'keyCooldownMs', kind: 'number' },
|
|
185
|
+
{ key: 'quotaTotalManual', kind: 'number' },
|
|
186
|
+
{ key: 'effortByModel', kind: 'dict' },
|
|
187
|
+
{ key: 'traeEnabled', kind: 'boolean' },
|
|
188
|
+
{ key: 'traeAuthBaseURL', kind: 'text' },
|
|
189
|
+
{ key: 'traeChatBaseURL', kind: 'text' },
|
|
190
|
+
{ key: 'traeLoginHost', kind: 'text' },
|
|
191
|
+
{ key: 'traeBridgePort', kind: 'number' },
|
|
192
|
+
{ key: 'traeChatTransport', kind: 'select' },
|
|
193
|
+
{ key: 'upstreamFirstByteTimeoutMs', kind: 'number' },
|
|
194
|
+
{ key: 'qoderLoginHost', kind: 'text' },
|
|
195
|
+
{ key: 'qoderOpenapiBaseURL', kind: 'text' },
|
|
196
|
+
{ key: 'qoderClientId', kind: 'text' },
|
|
197
|
+
{ key: 'qoderEnabled', kind: 'boolean' },
|
|
198
|
+
{ key: 'qoderBridgePort', kind: 'number' },
|
|
199
|
+
{ key: 'qoderInferBaseURL', kind: 'text' },
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Hostnames that count as "this machine" for the local-only HTTP surfaces
|
|
204
|
+
* (bridge Host gate, settings-route guard, plaintext-http baseURLs).
|
|
205
|
+
* `::1` (bare) is accepted for raw Host values; a URL-parsed IPv6 hostname
|
|
206
|
+
* keeps its brackets (`[::1]`).
|
|
207
|
+
*/
|
|
208
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
|
|
209
|
+
|
|
210
|
+
/** Parse a Host header (may carry a port) into a bare hostname, or null. */
|
|
211
|
+
function hostHeaderHostname(host) {
|
|
212
|
+
try {
|
|
213
|
+
return new URL(`http://${host}`).hostname
|
|
214
|
+
} catch {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the URL hostname is loopback (plaintext http allowance). */
|
|
220
|
+
function isLoopbackHostname(hostname) {
|
|
221
|
+
return LOOPBACK_HOSTNAMES.has(hostname)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Validate a baseURL candidate before it can reach a provider. */
|
|
225
|
+
function validateBaseURL(value) {
|
|
226
|
+
let parsed
|
|
227
|
+
try {
|
|
228
|
+
parsed = new URL(value)
|
|
229
|
+
} catch {
|
|
230
|
+
throw new Error('baseURL 必须是绝对 http(s) 地址')
|
|
231
|
+
}
|
|
232
|
+
if (parsed.protocol === 'https:') return
|
|
233
|
+
if (parsed.protocol !== 'http:') {
|
|
234
|
+
throw new Error('baseURL 必须使用 http 或 https')
|
|
235
|
+
}
|
|
236
|
+
// 安全审计 [29]:明文 http 仅限回环——本插件的本地桥都绑 127.0.0.1;
|
|
237
|
+
// 指向远程明文端点会把 Bearer 凭据裸奔上网络。https 恒可。
|
|
238
|
+
if (!isLoopbackHostname(parsed.hostname)) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`baseURL 明文 http 仅允许回环地址(127.0.0.1/localhost/::1),收到 ${parsed.hostname}——远程上游必须使用 https`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const readFileLayer = () => readJson(SETTINGS_PATH)
|
|
246
|
+
const writeFileLayer = (section) => writeJson(SETTINGS_PATH, section)
|
|
247
|
+
const readAuth = () => readJson(AUTH_PATH)
|
|
248
|
+
const writeAuth = (v) => writeJson(AUTH_PATH, v)
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// 桥端口按运行时宿主 profile 分流(goal docs/goals/bridge-port-host-split.md)。
|
|
252
|
+
// 共享文件层(codebuddy-plugin.json)被 web/非默认 profile 两进程共读——分流端口
|
|
253
|
+
// 写进去两边读到同一份仍会撞,故分流值**不持久化**,每进程按自身宿主信号现读
|
|
254
|
+
// 现算。默认 profile('web')= schema 默认端口,逐位不变;其余宿主 profile 的
|
|
255
|
+
// trae/qoder 翻译网关分流到 默认+10(3902→3912、3903→3913)。CodeBuddy 桥 3901
|
|
256
|
+
// 本期不动(goal 边界)。
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
const BRIDGE_PORT_DEFAULTS = { bridge: 3901, trae: 3902, qoder: 3903 }
|
|
259
|
+
const HOST_BRIDGE_PORT_OFFSET = 10
|
|
260
|
+
const DEFAULT_PROFILE_DIR_NAME = 'web'
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 宿主信号源(两级,皆现读、永不缓存):
|
|
264
|
+
* ① process.argv 自证——host 进程把 profile 目录作位置参数传入(如
|
|
265
|
+
* …\.dsh\profiles\<name>),启动期即用,零服务就绪时序依赖(首轮
|
|
266
|
+
* applyLive 的 sync*Bridge listen 就靠它,等②就绪再 listen 就晚了——
|
|
267
|
+
* EADDRINUSE 会以默认端口烙进 lastError,重启前不自愈);
|
|
268
|
+
* ② probe().documentPath(host-config.js 已 try/catch 兜底 null)——settings
|
|
269
|
+
* 服务经 ctx.inject 异步注入,首个 resolveNow 时可能未就绪,只作兜底。
|
|
270
|
+
* **永不缓存**:缓存会把启动竞态烙成永久错分;现读 + 就绪后下一次 sync*
|
|
271
|
+
* 端口对账即自愈。返回小写目录名;两级皆不可用 → null。
|
|
272
|
+
*/
|
|
273
|
+
function currentProfileDirName() {
|
|
274
|
+
for (const arg of process.argv) {
|
|
275
|
+
const segs = String(arg).split(/[\\/]/)
|
|
276
|
+
const i = segs.indexOf('profiles')
|
|
277
|
+
if (i >= 0 && segs[i + 1]) return segs[i + 1].toLowerCase()
|
|
278
|
+
}
|
|
279
|
+
const docPath = hostConfig.probe().documentPath
|
|
280
|
+
if (typeof docPath !== 'string' || !docPath) return null
|
|
281
|
+
return basename(dirname(docPath)).toLowerCase()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 三端口有效值的唯一出处。数据驱动:默认 profile('web')或信号不可用 →
|
|
286
|
+
* 偏移 0(= 与既有行为逐位一致);其余宿主 profile → trae/qoder +10。
|
|
287
|
+
* 文件层显式值优先(用户在设置卡显式改过端口就按显式值用)。
|
|
288
|
+
*/
|
|
289
|
+
function resolveBridgePorts(entryLayer, fileLayer, profileDirName) {
|
|
290
|
+
const shift = profileDirName && profileDirName !== DEFAULT_PROFILE_DIR_NAME ? HOST_BRIDGE_PORT_OFFSET : 0
|
|
291
|
+
const valid = (l, k) => typeof l?.[k] === 'number' && l[k] >= 1 && l[k] <= 65535
|
|
292
|
+
// 「显式」判定(两难收口于此):
|
|
293
|
+
// - cordis 给 apply 的 entry 恒含 schema 默认端口(活实例实测 entryPortKeys=
|
|
294
|
+
// [bridgePort,traeBridgePort,traeChatTransport,qoderBridgePort]=Config({}) 的
|
|
295
|
+
// number/union 默认键)——把 entry 无差别当显式,schema 默认会压过分流偏移。
|
|
296
|
+
// - verify-* 套件又靠 entry 传随机空闲口(文件层是独立 tmpdir、无端口键)。
|
|
297
|
+
// 折衷:entry 端口值 **偏离 schema 默认**才算显式(套件随机口 ≠ 默认 → 采信;
|
|
298
|
+
// cordis 的默认 entry = 默认 → 不采信,落到分流/文件层);文件层显式恒采信
|
|
299
|
+
// (用户真实写入,含 3903 这种等于默认的值——那是用户明确要的)。
|
|
300
|
+
const entryExplicit = (k) => valid(entryLayer, k) && entryLayer[k] !== BRIDGE_PORT_DEFAULTS[{ bridgePort: 'bridge', traeBridgePort: 'trae', qoderBridgePort: 'qoder' }[k]]
|
|
301
|
+
const pick = (k, def, portShift) => entryExplicit(k) ? entryLayer[k]
|
|
302
|
+
: valid(fileLayer, k) ? fileLayer[k]
|
|
303
|
+
: def + portShift
|
|
304
|
+
return {
|
|
305
|
+
bridgePort: pick('bridgePort', BRIDGE_PORT_DEFAULTS.bridge, 0),
|
|
306
|
+
traeBridgePort: pick('traeBridgePort', BRIDGE_PORT_DEFAULTS.trae, shift),
|
|
307
|
+
qoderBridgePort: pick('qoderBridgePort', BRIDGE_PORT_DEFAULTS.qoder, shift),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 模块级出口:apply() 内赋值为 resolveNow(镜像函数是模块级、拿不到 apply
|
|
313
|
+
* 闭包里的 resolveNow,故开此出口)。镜像两个 sync*ModelsToDshSettings 经它
|
|
314
|
+
* 解析端口——listen 端口与镜像 baseURL 同源同一次解析,根除双路径漂移。
|
|
315
|
+
* 未赋值(apply 前的理论窗口)回退纯文件层默认值语义(shift=0),不炸。
|
|
316
|
+
*/
|
|
317
|
+
let effectiveSettingsFn = null
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Model management: the patch supplies a static 18-model base; the user's
|
|
321
|
+
// enable/disable state (and catalog additions) live in the settings file as
|
|
322
|
+
// `modelState`, and the effective list is mirrored into ~/.dsh/settings.yaml
|
|
323
|
+
// under llm-pi-ai.providers.codebuddy.models — the settings-driven override
|
|
324
|
+
// the model picker honors on the next request (no restart).
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** Parse the static model profiles from cordis.patch.yml (single source of truth). */
|
|
328
|
+
function readStaticModels() {
|
|
329
|
+
try {
|
|
330
|
+
const rows = YAML.parse(readFileSync(PATCH_FILE, 'utf8'))
|
|
331
|
+
const row = Array.isArray(rows) ? rows.find((r) => r?.id === 'llm-pi-ai') : null
|
|
332
|
+
const models = row?.config?.providers?.codebuddy?.models
|
|
333
|
+
return Array.isArray(models) ? models.filter((m) => typeof m?.id === 'string') : []
|
|
334
|
+
} catch {
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readModelState() {
|
|
340
|
+
const layer = readFileLayer()
|
|
341
|
+
const state = layer.modelState
|
|
342
|
+
if (!state || typeof state !== 'object') return { disabled: {}, extra: {}, overrides: {} }
|
|
343
|
+
return {
|
|
344
|
+
disabled: state.disabled && typeof state.disabled === 'object' && !Array.isArray(state.disabled)
|
|
345
|
+
? state.disabled : {},
|
|
346
|
+
extra: state.extra && typeof state.extra === 'object' && !Array.isArray(state.extra)
|
|
347
|
+
? state.extra : {},
|
|
348
|
+
// G5:每模型上下文/输出上限覆盖值 { [id]: { contextWindow?, maxTokens? } }。
|
|
349
|
+
overrides: state.overrides && typeof state.overrides === 'object' && !Array.isArray(state.overrides)
|
|
350
|
+
? state.overrides : {},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 安全审计 [22]:POST 供给的模型 id 会成为对象键(state.extra/overrides[id])
|
|
356
|
+
* 并镜像进 settings.yaml 的映射键。Object.prototype 关键段一律拒收——
|
|
357
|
+
* `extra['__proto__'] = <object>` 是真实原型污染写入;含这些关键段的
|
|
358
|
+
* “子路径”形式(a.constructor、x[__proto__] 等)同样拒绝。
|
|
359
|
+
*/
|
|
360
|
+
function assertSafeModelId(id) {
|
|
361
|
+
const unsafe = /(?:^|[.[\]])+(?:__proto__|constructor|prototype)(?:$|[.[\]])+/.test(id)
|
|
362
|
+
if (unsafe) {
|
|
363
|
+
throw new Error(`非法模型 id(保留键 ${JSON.stringify(id)} 拒绝写入)`)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Effective models = static base + enabled catalog extras − disabled ids. */
|
|
368
|
+
/**
|
|
369
|
+
* G4 动态目录:启动时(及设置卡手动刷新)从 /v3/config 同步的模型清单。
|
|
370
|
+
* null = 未同步或同步失败(离线兜底 = 静态清单,computeEffectiveModels 回落)。
|
|
371
|
+
* 实例状态:模块作用域每插件实例一份(踩坑 #20 纪律同 rotator/meter)。
|
|
372
|
+
*/
|
|
373
|
+
let dynamicCatalog = null // { profiles: [...], fetchedAt, count }
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 思考档位的规范顺序(选择器/卡片展示用;pi-ai 自己按 THINKING_LEVELS 排,
|
|
377
|
+
* 目录声明的键序不必与之一致)。
|
|
378
|
+
*/
|
|
379
|
+
const EFFORT_TIER_ORDER = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
380
|
+
const effortRank = (level) => {
|
|
381
|
+
const i = EFFORT_TIER_ORDER.indexOf(level)
|
|
382
|
+
return i === -1 ? EFFORT_TIER_ORDER.length : i
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 目录声明的"关思考"线值——**实测为 null(没有这样的拼写)**,故
|
|
387
|
+
* `canDisableThinking:true` 的模型不出 off 档(不臆造线值)。依据
|
|
388
|
+
* docs/probes/codebuddy-efforts-{disable,offconfirm}-2026-09-22.json:
|
|
389
|
+
* 这类模型**省略参数照常思考**(与 defaultEffort 一致),显式
|
|
390
|
+
* off/disabled/auto 被 200 接受但推理量不变,minimal/none 跨模型不一致
|
|
391
|
+
* (glm-5.3-flash≈0 / kimi-k2.8-preview≈140,基线≈1k)。哪天定论了真正的
|
|
392
|
+
* 关思考线值,把这里改成该拼写即可(表里会自动多出 off 档)。
|
|
393
|
+
*/
|
|
394
|
+
const CATALOG_OFF_WIRE = null
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 目录思考强度声明 → 档位表(键 = 档位名,值 = 出站线值;null = 省略参数)。
|
|
398
|
+
* 只有带 `supportedEfforts` 的新形态声明才产出表——legacy 形态
|
|
399
|
+
* ({"effort":"high"})只声明默认档,不构成能力清单,档位表继续由
|
|
400
|
+
* cordis.patch.yml 静态清单提供(合并优先级见 computeBaseModels)。
|
|
401
|
+
*/
|
|
402
|
+
function catalogReasoningEfforts(m) {
|
|
403
|
+
const supported = Array.isArray(m?.supportedEfforts)
|
|
404
|
+
? m.supportedEfforts.filter((s) => typeof s === 'string' && s)
|
|
405
|
+
: []
|
|
406
|
+
if (!supported.length) return null
|
|
407
|
+
const table = {}
|
|
408
|
+
if (m.canDisableThinking === true && CATALOG_OFF_WIRE) table.off = CATALOG_OFF_WIRE
|
|
409
|
+
for (const level of [...supported].sort((a, b) => effortRank(a) - effortRank(b))) table[level] = level
|
|
410
|
+
return table
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 目录条目 → 模型 profile(尺寸/图像来自目录;思考档位表见上)。 */
|
|
414
|
+
function catalogToProfile(m) {
|
|
415
|
+
const p = { id: m.id, name: typeof m.name === 'string' && m.name ? m.name : m.id }
|
|
416
|
+
if (m.maxInputTokens != null) p.contextWindow = m.maxInputTokens
|
|
417
|
+
if (m.maxOutputTokens != null) p.maxTokens = m.maxOutputTokens
|
|
418
|
+
if (m.images === true) p.input = ['text', 'image']
|
|
419
|
+
const efforts = catalogReasoningEfforts(m)
|
|
420
|
+
if (efforts) p.reasoningEfforts = efforts
|
|
421
|
+
return p
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 某模型可用的档位表 = 基清单(静态清单 ∪ 动态目录)里的 reasoningEfforts。
|
|
426
|
+
* 目录声明优先(computeBaseModels 的展开顺序),静态表兜底。
|
|
427
|
+
* 缓存以 dynamicCatalog 引用为键(每次同步换新;静态清单运行期不变)。
|
|
428
|
+
*/
|
|
429
|
+
let effortTableCache = null
|
|
430
|
+
function effortTableFor(model) {
|
|
431
|
+
if (!effortTableCache || effortTableCache.source !== dynamicCatalog) {
|
|
432
|
+
const map = new Map()
|
|
433
|
+
for (const m of computeBaseModels()) {
|
|
434
|
+
if (m.reasoningEfforts && typeof m.reasoningEfforts === 'object') map.set(m.id, m.reasoningEfforts)
|
|
435
|
+
}
|
|
436
|
+
effortTableCache = { source: dynamicCatalog, map }
|
|
437
|
+
}
|
|
438
|
+
return effortTableCache.map.get(model) ?? null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 档位名 → 展示用清单:过滤掉"off 但线值为空"(那是省略参数 = 默认态)。 */
|
|
442
|
+
function effortTiersFor(model) {
|
|
443
|
+
const table = effortTableFor(model)
|
|
444
|
+
if (!table) return []
|
|
445
|
+
return Object.entries(table)
|
|
446
|
+
.filter(([level, wire]) => (level === 'off' ? typeof wire === 'string' && wire : true))
|
|
447
|
+
.map(([level]) => level)
|
|
448
|
+
.sort((a, b) => effortRank(a) - effortRank(b))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* G8:模型当前该注入的 reasoning_effort 线值。文件层的 effortByModel 只存
|
|
453
|
+
* 档位名,线值查该模型的档位表(静态 reasoningEfforts ∪ 目录声明)——
|
|
454
|
+
* off 的线值可能是 null(= 省略参数,不注入),也可能是显式"关思考"拼写;
|
|
455
|
+
* 无表模型/非法档位同样不注入(UI 之外的写入路径不会把脏档位送上线)。
|
|
456
|
+
*/
|
|
457
|
+
function effortWireFor(model) {
|
|
458
|
+
const level = Config({ ...readFileLayer() }).effortByModel[model]
|
|
459
|
+
if (!level) return undefined
|
|
460
|
+
const wire = effortTableFor(model)?.[level]
|
|
461
|
+
return typeof wire === 'string' && wire ? wire : undefined
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 动态基清单 = 目录 ∪ 静态:目录条目刷新同名静态条目的名称/尺寸/图像能力
|
|
466
|
+
* (静态的 reasoningEfforts 档位表保留——目录没有该信息);纯静态 id 保留
|
|
467
|
+
* (目录与可路由性三方互斥,routing.md R-R3:deepseek-v3 不在目录但可用,
|
|
468
|
+
* 而 agent-default-model 钉的正是它——盲从目录会把默认模型弄丢)。
|
|
469
|
+
*/
|
|
470
|
+
function computeBaseModels() {
|
|
471
|
+
if (!dynamicCatalog) return readStaticModels()
|
|
472
|
+
const staticById = new Map(readStaticModels().map((m) => [m.id, m]))
|
|
473
|
+
const seen = new Set()
|
|
474
|
+
const list = []
|
|
475
|
+
for (const p of dynamicCatalog.profiles) {
|
|
476
|
+
if (seen.has(p.id)) continue
|
|
477
|
+
seen.add(p.id)
|
|
478
|
+
const st = staticById.get(p.id)
|
|
479
|
+
// p(目录)不含 reasoningEfforts 键,展开不会覆盖静态档位表。
|
|
480
|
+
list.push(st ? { ...st, ...p } : p)
|
|
481
|
+
}
|
|
482
|
+
for (const m of readStaticModels()) {
|
|
483
|
+
if (!seen.has(m.id)) list.push(m)
|
|
484
|
+
}
|
|
485
|
+
return list
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function computeEffectiveModels() {
|
|
489
|
+
const { disabled, extra, overrides } = readModelState()
|
|
490
|
+
const ids = new Set()
|
|
491
|
+
const list = []
|
|
492
|
+
for (const m of computeBaseModels()) {
|
|
493
|
+
if (disabled[m.id]) continue
|
|
494
|
+
ids.add(m.id)
|
|
495
|
+
list.push(m)
|
|
496
|
+
}
|
|
497
|
+
for (const [id, profile] of Object.entries(extra)) {
|
|
498
|
+
if (disabled[id] || ids.has(id) || !profile?.id) continue
|
|
499
|
+
ids.add(id)
|
|
500
|
+
list.push({ ...profile, id })
|
|
501
|
+
}
|
|
502
|
+
// G5:应用每模型覆盖值(contextWindow/maxTokens),覆盖随镜像即时生效。
|
|
503
|
+
return list.map((m) => {
|
|
504
|
+
const o = overrides[m.id]
|
|
505
|
+
if (!o) return m
|
|
506
|
+
return {
|
|
507
|
+
...m,
|
|
508
|
+
...(o.contextWindow != null ? { contextWindow: o.contextWindow } : null),
|
|
509
|
+
...(o.maxTokens != null ? { maxTokens: o.maxTokens } : null),
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 宿主配置层通道(dsh 0.1.7 起 = Settings forms seam,写落 profile 的
|
|
516
|
+
* cordis.patch.yml;≤0.1.6 回退 ~/.dsh/settings.yaml 文档编辑)。
|
|
517
|
+
* 详见 host-config.js 的头注释——两代宿主的差异全部收在那一处。
|
|
518
|
+
*/
|
|
519
|
+
const hostConfig = createHostConfigLayer({
|
|
520
|
+
settingsPath: DSH_SETTINGS_PATH,
|
|
521
|
+
schema: Config,
|
|
522
|
+
log: (m) => process.stderr.write(`${m}\n`),
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Mirror the effective model list into the host config layer under
|
|
527
|
+
* llm-pi-ai.providers.codebuddy.models (the user-layer override over the
|
|
528
|
+
* patch layer). dsh 0.1.7+ 走 Settings forms(volatile 字段,写 profile
|
|
529
|
+
* patch 即时生效);旧宿主走注释保留的 settings.yaml 文档编辑。
|
|
530
|
+
* When the state is pristine (nothing disabled, no extras) AND no dynamic
|
|
531
|
+
* catalog is synced, the override is REMOVED instead — a stale override would
|
|
532
|
+
* shadow future patch updates. G4: a synced dynamic catalog intentionally keeps
|
|
533
|
+
* the override non-pristine (the list follows the gateway, refreshed every boot).
|
|
534
|
+
*/
|
|
535
|
+
async function syncModelsToDshSettings() {
|
|
536
|
+
try {
|
|
537
|
+
const state = readModelState()
|
|
538
|
+
const pristine = Object.keys(state.disabled).length === 0
|
|
539
|
+
&& Object.keys(state.extra).length === 0
|
|
540
|
+
&& Object.keys(state.overrides ?? {}).length === 0
|
|
541
|
+
&& dynamicCatalog == null
|
|
542
|
+
const path = ['providers', 'codebuddy', 'models']
|
|
543
|
+
if (pristine) return await hostConfig.applyOps([{ op: 'unset', path }])
|
|
544
|
+
const next = computeEffectiveModels()
|
|
545
|
+
// Defensive (0.8.7): an empty effective list must never be mirrored —
|
|
546
|
+
// llm-pi-ai (dsh 0.1.1-rc.2+) rejects it at apply time. setModelEnabled
|
|
547
|
+
// guards the last model already; this fallback drops the override (the
|
|
548
|
+
// patch static list then serves) instead of poisoning the namespace.
|
|
549
|
+
if (next.length === 0) return await hostConfig.applyOps([{ op: 'unset', path }])
|
|
550
|
+
return await hostConfig.applyOps([{ op: 'set', path, value: next }])
|
|
551
|
+
} catch (err) {
|
|
552
|
+
process.stderr.write(`[dsh-tap] model mirror failed: ${err?.message ?? err}\n`)
|
|
553
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Apply one enable/disable toggle and sync the effective list. */
|
|
558
|
+
async function setModelEnabled({ id, enabled, profile }) {
|
|
559
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('modelSetEnabled 需要 id')
|
|
560
|
+
assertSafeModelId(id)
|
|
561
|
+
const layer = readFileLayer()
|
|
562
|
+
const state = readModelState()
|
|
563
|
+
// G4:基清单 = 静态 ∪ 动态目录。基清单内的模型启停只动 disabled 标记,
|
|
564
|
+
// 不写 extra(否则状态永远非纯净,且与动态基重复)。
|
|
565
|
+
const isBase = computeBaseModels().some((m) => m.id === id)
|
|
566
|
+
if (enabled) {
|
|
567
|
+
delete state.disabled[id]
|
|
568
|
+
if (!isBase) {
|
|
569
|
+
if (!profile || typeof profile !== 'object') {
|
|
570
|
+
throw new Error('启用目录新增模型需要 profile(来自模型列表条目)')
|
|
571
|
+
}
|
|
572
|
+
state.extra[id] = profile
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
// Guard (0.8.7): disabling the LAST effective model would mirror an
|
|
576
|
+
// empty models list into settings.yaml, which llm-pi-ai (dsh 0.1.1-rc.2+)
|
|
577
|
+
// rejects at apply time — taking down the whole llm-pi-ai fiber and
|
|
578
|
+
// with it the main chat. Keep at least one servable model.
|
|
579
|
+
const effective = computeEffectiveModels()
|
|
580
|
+
if (effective.length <= 1 && effective.some((m) => m.id === id)) {
|
|
581
|
+
throw new Error('不能禁用最后一个模型:dsh 0.1.1-rc.2 起 llm-pi-ai 拒绝空模型清单(整域不可用)')
|
|
582
|
+
}
|
|
583
|
+
// Base ids need an explicit disabled mark; a catalog extra simply
|
|
584
|
+
// drops out of the extras map — marking it disabled would keep the
|
|
585
|
+
// state non-pristine (and the settings override) forever.
|
|
586
|
+
if (isBase) state.disabled[id] = true
|
|
587
|
+
delete state.extra[id]
|
|
588
|
+
}
|
|
589
|
+
layer.modelState = state
|
|
590
|
+
writeFileLayer(layer)
|
|
591
|
+
await syncModelsToDshSettings()
|
|
592
|
+
return state
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* G5:设置/清除单模型上下文与输出上限覆盖值。字段值为 null = 清除该字段
|
|
597
|
+
* 覆盖(回目录/静态基值)。覆盖值必须是正整数,且不得超过基清单(目录或
|
|
598
|
+
* 静态 profile)给定的该模型实际上限;基清单无尺寸信息时不设上限。
|
|
599
|
+
* 覆盖经 computeEffectiveModels 即时重铺 settings.yaml,下次请求生效。
|
|
600
|
+
*/
|
|
601
|
+
async function setModelLimits({ id, contextWindow, maxTokens }) {
|
|
602
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('modelSetLimits 需要 id')
|
|
603
|
+
assertSafeModelId(id)
|
|
604
|
+
const base = computeBaseModels().find((m) => m.id === id)
|
|
605
|
+
?? readModelState().extra[id]
|
|
606
|
+
?? null
|
|
607
|
+
const check = (v, ceiling, label) => {
|
|
608
|
+
if (!Number.isInteger(v) || v <= 0) throw new Error(`${label}必须是正整数`)
|
|
609
|
+
if (ceiling != null && v > ceiling) {
|
|
610
|
+
throw new Error(`${label}超出该模型实际上限 ${ceiling}`)
|
|
611
|
+
}
|
|
612
|
+
return v
|
|
613
|
+
}
|
|
614
|
+
const layer = readFileLayer()
|
|
615
|
+
const state = readModelState()
|
|
616
|
+
const o = { ...(state.overrides[id] || {}) }
|
|
617
|
+
if (contextWindow === null) delete o.contextWindow
|
|
618
|
+
else if (contextWindow !== undefined) o.contextWindow = check(contextWindow, base?.contextWindow, '上下文长度')
|
|
619
|
+
if (maxTokens === null) delete o.maxTokens
|
|
620
|
+
else if (maxTokens !== undefined) o.maxTokens = check(maxTokens, base?.maxTokens, '输出上限')
|
|
621
|
+
if (Object.keys(o).length) state.overrides[id] = o
|
|
622
|
+
else delete state.overrides[id]
|
|
623
|
+
layer.modelState = state
|
|
624
|
+
writeFileLayer(layer)
|
|
625
|
+
await syncModelsToDshSettings()
|
|
626
|
+
return state
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* G4:从 /v3/config 同步模型清单(启动自动 + 设置卡手动刷新共用,单飞)。
|
|
631
|
+
* 成功:dynamicCatalog 换新并重铺 settings.yaml 镜像(选择器跟网关走)。
|
|
632
|
+
* 失败:已有动态目录时保留(重铺它,选择器不掉模型);否则回落静态清单
|
|
633
|
+
* 并按纯净态纪律清理镜像——任何失败都不让选择器变空。
|
|
634
|
+
*/
|
|
635
|
+
let modelSyncInFlight = null
|
|
636
|
+
function syncModelsFromGateway(resolveNow) {
|
|
637
|
+
if (modelSyncInFlight) return modelSyncInFlight
|
|
638
|
+
modelSyncInFlight = (async () => {
|
|
639
|
+
try {
|
|
640
|
+
const catalog = await provider.catalog.fetchModelCatalog(resolveNow)
|
|
641
|
+
const profiles = catalog.models.map(catalogToProfile)
|
|
642
|
+
if (!profiles.length) throw new Error('网关目录为空(保持现有清单)')
|
|
643
|
+
dynamicCatalog = { profiles, fetchedAt: catalog.fetchedAt, count: profiles.length }
|
|
644
|
+
await syncModelsToDshSettings()
|
|
645
|
+
return { ok: true, fetchedAt: dynamicCatalog.fetchedAt, count: dynamicCatalog.count, source: 'gateway' }
|
|
646
|
+
} catch (err) {
|
|
647
|
+
// kept=false → dynamicCatalog 为 null,sync 落静态清单 + 纯净态纪律;
|
|
648
|
+
// kept=true → 以旧动态目录重铺(选择器不因一次拉取失败掉模型)。
|
|
649
|
+
const kept = dynamicCatalog != null
|
|
650
|
+
await syncModelsToDshSettings()
|
|
651
|
+
return { ok: false, error: err?.message ?? String(err), kept, source: kept ? 'gateway-stale' : 'static' }
|
|
652
|
+
} finally {
|
|
653
|
+
modelSyncInFlight = null
|
|
654
|
+
}
|
|
655
|
+
})()
|
|
656
|
+
return modelSyncInFlight
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// G6 多服务商注册表:key 型 OpenAI 兼容上游(preset 见下 + 自定义)。
|
|
661
|
+
// 机制与官方 CustomProviderCard 相同:provider 块写**宿主配置层**的
|
|
662
|
+
// llm-pi-ai.providers.<id>(dsh 0.1.7+ = Settings forms seam,落 profile 的
|
|
663
|
+
// cordis.patch.yml 即时生效;≤0.1.6 = ~/.dsh/settings.yaml,chokidar 热加载),
|
|
664
|
+
// 选路细节全在 host-config.js;key 写 ~/.dsh/.credentials.yaml 的
|
|
665
|
+
// <ID>_API_KEY(凭据缝每请求活解析,免重启)。
|
|
666
|
+
// 插件文件层只存登记册 managedProviders(无 secret);key 永不回传浏览器。
|
|
667
|
+
// 纪律:写块前必过 validateProviderSpec + 实测 GET /models——llm-pi-ai
|
|
668
|
+
// 命名空间解析失败会整域冻结(dsh-settings publish catch),坏块连坐
|
|
669
|
+
// codebuddy 路由。
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
const PROVIDER_PRESETS = [
|
|
673
|
+
arkProvider, bailianProvider,
|
|
674
|
+
deepseekProvider, bigmodelProvider, moonshotProvider, openrouterProvider,
|
|
675
|
+
qwenProvider,
|
|
676
|
+
]
|
|
677
|
+
|
|
678
|
+
function readManagedProviders() {
|
|
679
|
+
const list = readFileLayer().managedProviders
|
|
680
|
+
if (!Array.isArray(list)) return []
|
|
681
|
+
return list.filter((p) => p && typeof p.id === 'string' && typeof p.keyRef === 'string')
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function writeManagedProviders(list) {
|
|
685
|
+
const layer = readFileLayer()
|
|
686
|
+
if (list.length) layer.managedProviders = list
|
|
687
|
+
else delete layer.managedProviders
|
|
688
|
+
writeFileLayer(layer)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** ~/.dsh/.credentials.yaml 写入(dsh-credentials-local 要求文件 0600)。 */
|
|
692
|
+
function writeCredential(ref, value) {
|
|
693
|
+
let doc
|
|
694
|
+
try {
|
|
695
|
+
doc = YAML.parseDocument(readFileSync(DSH_CREDENTIALS_PATH, 'utf8'))
|
|
696
|
+
} catch {
|
|
697
|
+
doc = new YAML.Document()
|
|
698
|
+
}
|
|
699
|
+
doc.set(ref, value)
|
|
700
|
+
writeTextAtomic(DSH_CREDENTIALS_PATH, String(doc), 0o600)
|
|
701
|
+
chmodSync(DSH_CREDENTIALS_PATH, 0o600)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function deleteCredential(ref) {
|
|
705
|
+
let doc
|
|
706
|
+
try {
|
|
707
|
+
doc = YAML.parseDocument(readFileSync(DSH_CREDENTIALS_PATH, 'utf8'))
|
|
708
|
+
} catch {
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (doc.delete(ref)) writeTextAtomic(DSH_CREDENTIALS_PATH, String(doc), 0o600)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 有效 provider 块视图(登记册对账/重名判定)。新宿主读 describe 的 live value。 */
|
|
715
|
+
function readSettingsProviders() {
|
|
716
|
+
return hostConfig.readProviders()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 写/删(block=null)llm-pi-ai.providers.<id>。
|
|
721
|
+
* dsh 0.1.7+ = Settings forms 的 mutate(落 profile patch,即时生效);
|
|
722
|
+
* ≤0.1.6 = 注释保留的 settings.yaml 文档编辑。
|
|
723
|
+
*/
|
|
724
|
+
async function writeProviderBlock(id, block) {
|
|
725
|
+
try {
|
|
726
|
+
return await hostConfig.applyOps(block === null
|
|
727
|
+
? [{ op: 'unset', path: ['providers', id] }]
|
|
728
|
+
: [{ op: 'set', path: ['providers', id], value: block }])
|
|
729
|
+
} catch (err) {
|
|
730
|
+
process.stderr.write(`[dsh-tap] provider block write failed: ${err?.message ?? err}\n`)
|
|
731
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 添加上游:本地校验 → 实测 GET /models(验 key 兼拿目录)→ 写凭据 →
|
|
737
|
+
* 写 provider 块 → 登记。任何一步失败都不留半成品(凭据先写是因为
|
|
738
|
+
* 可服务性校验不查凭据,块后写保证热加载看到的是完整配置)。
|
|
739
|
+
*/
|
|
740
|
+
async function addExtraProvider({ preset, id, baseURL, displayName, apiKey }) {
|
|
741
|
+
let adapter
|
|
742
|
+
let presetId = null
|
|
743
|
+
if (preset != null) {
|
|
744
|
+
const found = PROVIDER_PRESETS.find((p) => p.id === preset)
|
|
745
|
+
if (!found) throw new Error(`未知 preset:${preset}`)
|
|
746
|
+
adapter = found
|
|
747
|
+
presetId = found.id
|
|
748
|
+
} else {
|
|
749
|
+
if (typeof id !== 'string' || !PROVIDER_ID_RE.test(id)) {
|
|
750
|
+
throw new Error('id 必须小写字母/数字开头(小写字母、数字、中划线)')
|
|
751
|
+
}
|
|
752
|
+
if (typeof baseURL !== 'string') throw new Error('需要 baseURL')
|
|
753
|
+
validateBaseURL(baseURL)
|
|
754
|
+
adapter = createOpenAICompatProvider({
|
|
755
|
+
id,
|
|
756
|
+
displayName: typeof displayName === 'string' && displayName.trim() ? displayName.trim() : id,
|
|
757
|
+
baseURL: baseURL.replace(/\/+$/, ''),
|
|
758
|
+
})
|
|
759
|
+
}
|
|
760
|
+
const pid = adapter.id
|
|
761
|
+
if (pid === 'codebuddy') throw new Error('codebuddy 是本插件保留路由')
|
|
762
|
+
if (readManagedProviders().some((p) => p.id === pid)) throw new Error(`${pid} 已在注册表里`)
|
|
763
|
+
if (readSettingsProviders()[pid]) throw new Error(`宿主配置层已存在 ${pid} 提供商(先手动清理或换 id)`)
|
|
764
|
+
if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('需要 API Key')
|
|
765
|
+
const key = apiKey.trim()
|
|
766
|
+
// 实测目录:key/URL 错误在这里就炸,不落任何文件。
|
|
767
|
+
const models = await adapter.fetchModels(key)
|
|
768
|
+
writeCredential(adapter.keyRef, key)
|
|
769
|
+
await writeProviderBlock(pid, adapter.modelBlock(models))
|
|
770
|
+
writeManagedProviders(readManagedProviders().concat([{
|
|
771
|
+
id: pid,
|
|
772
|
+
displayName: adapter.displayName,
|
|
773
|
+
baseURL: adapter.baseURL,
|
|
774
|
+
preset: presetId ?? 'custom',
|
|
775
|
+
keyRef: adapter.keyRef,
|
|
776
|
+
addedAt: Date.now(),
|
|
777
|
+
}]))
|
|
778
|
+
return { id: pid, modelCount: models.length }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 移除上游:删块 + 删凭据 + 出登记册(外部已删块也照常清理)。 */
|
|
782
|
+
async function removeExtraProvider(id) {
|
|
783
|
+
const entry = readManagedProviders().find((p) => p.id === id)
|
|
784
|
+
if (!entry) throw new Error(`${id} 不在注册表里`)
|
|
785
|
+
await writeProviderBlock(id, null)
|
|
786
|
+
deleteCredential(entry.keyRef)
|
|
787
|
+
writeManagedProviders(readManagedProviders().filter((p) => p.id !== id))
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* P2-5「测一下」:只读验证——与 refresh 同一路径解析 key 并实拉目录
|
|
792
|
+
* (/models;无目录/静态目录上游走 probeChatKey 探针),**不写 provider 块**。
|
|
793
|
+
*/
|
|
794
|
+
async function testExtraProvider(id) {
|
|
795
|
+
const entry = readManagedProviders().find((p) => p.id === id)
|
|
796
|
+
if (!entry) throw new Error(`${id} 不在注册表里`)
|
|
797
|
+
const key = resolveEnvKey(entry.keyRef, DSH_CREDENTIALS_PATH)
|
|
798
|
+
if (!key) throw new Error(`凭据 ${entry.keyRef} 不在环境或 .credentials.yaml 里`)
|
|
799
|
+
const presetHit = PROVIDER_PRESETS.find((p) => p.id === entry.preset)
|
|
800
|
+
const adapter = rebuildAdapterForEntry(entry, presetHit)
|
|
801
|
+
const models = await adapter.fetchModels(key)
|
|
802
|
+
return { id, modelCount: models.length }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** 重新拉取模型清单(key 从 .credentials.yaml 活解析)。 */
|
|
806
|
+
async function refreshExtraProviderModels(id) {
|
|
807
|
+
const entry = readManagedProviders().find((p) => p.id === id)
|
|
808
|
+
if (!entry) throw new Error(`${id} 不在注册表里`)
|
|
809
|
+
const key = resolveEnvKey(entry.keyRef, DSH_CREDENTIALS_PATH)
|
|
810
|
+
if (!key) throw new Error(`凭据 ${entry.keyRef} 不在环境或 .credentials.yaml 里`)
|
|
811
|
+
// preset 条目经共用接缝重建(与 provider-test 同参):fallbackModels/
|
|
812
|
+
// staticCatalog 与档位声明(modelEfforts/reasoningCompat)一起合并 ⇒ 无
|
|
813
|
+
// /models 或静态目录上游刷新 = 探针复验 + 沿用兜底/内置清单 + **档位声明
|
|
814
|
+
// 不丢**(providerBlock 按模型 id 重新合并)。0.19.0 修:此前此处裸调
|
|
815
|
+
// createOpenAICompatProvider 漏传声明字段,每次刷新洗掉「推理等级」档位表。
|
|
816
|
+
const presetHit = PROVIDER_PRESETS.find((p) => p.id === entry.preset)
|
|
817
|
+
const adapter = rebuildAdapterForEntry(entry, presetHit)
|
|
818
|
+
const models = await adapter.fetchModels(key)
|
|
819
|
+
await writeProviderBlock(id, adapter.modelBlock(models))
|
|
820
|
+
return { id, modelCount: models.length }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 设置卡视图:登记册 × settings.yaml 实况对账(外部删块 → 自动出册)。 */
|
|
824
|
+
function extraProvidersView() {
|
|
825
|
+
const providers = readSettingsProviders()
|
|
826
|
+
const stale = []
|
|
827
|
+
const list = []
|
|
828
|
+
for (const p of readManagedProviders()) {
|
|
829
|
+
const block = providers[p.id]
|
|
830
|
+
if (!block) {
|
|
831
|
+
stale.push(p.id)
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
const key = resolveEnvKey(p.keyRef, DSH_CREDENTIALS_PATH)
|
|
835
|
+
list.push({
|
|
836
|
+
id: p.id,
|
|
837
|
+
displayName: typeof block.displayName === 'string' ? block.displayName : p.displayName,
|
|
838
|
+
baseURL: typeof block.baseURL === 'string' ? block.baseURL : p.baseURL,
|
|
839
|
+
preset: p.preset,
|
|
840
|
+
keyRef: p.keyRef,
|
|
841
|
+
maskedKey: key ? maskKey(key) : null,
|
|
842
|
+
modelCount: Array.isArray(block.models) ? block.models.length : 0,
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
if (stale.length) {
|
|
846
|
+
writeManagedProviders(readManagedProviders().filter((p) => !stale.includes(p.id)))
|
|
847
|
+
}
|
|
848
|
+
return list
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// Credential composition (core rotation engine + provider OAuth flow).
|
|
853
|
+
// api-key mode: apiKeys list > legacy env ref; ≥2 keys round-robin with
|
|
854
|
+
// cooldown failover. OAuth: single candidate, never rotated.
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
/** Legacy single-key path: process env, then ~/.dsh/.credentials.yaml. */
|
|
858
|
+
const envKey = (envName) => resolveEnvKey(envName, join(DSH_HOME, '.credentials.yaml'))
|
|
859
|
+
|
|
860
|
+
const rotator = new KeyRotator()
|
|
861
|
+
const meter = createUsageMeter({ path: join(DSH_HOME, 'codebuddy-plugin-usage.json') })
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The provider instance for this module instance. `withKeyRotation` /
|
|
865
|
+
* `resolveCredential` are late-bound arrows over the hoisted function
|
|
866
|
+
* declarations below — the provider needs them for outbound calls, they need
|
|
867
|
+
* the provider for the OAuth branch; this is the deliberate cycle break.
|
|
868
|
+
*/
|
|
869
|
+
const provider = createCodeBuddyProvider({
|
|
870
|
+
meter,
|
|
871
|
+
readAuth,
|
|
872
|
+
writeAuth,
|
|
873
|
+
envKey,
|
|
874
|
+
withKeyRotation: (settings, attempt) => withKeyRotation(settings, attempt),
|
|
875
|
+
resolveCredential: (settings) => resolveCredential(settings),
|
|
876
|
+
effortWireFor: (model) => effortWireFor(model),
|
|
877
|
+
dshHome: DSH_HOME,
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
export const makeSearchProvider = provider.makeSearchProvider
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Resolve the credential every outbound call should use (no rotation —
|
|
884
|
+
* the catalog/quota dialect endpoints authenticate by raw key).
|
|
885
|
+
* @returns {Promise<{authorization: string, headers: Record<string,string>} | null>}
|
|
886
|
+
*/
|
|
887
|
+
async function resolveCredential(settings) {
|
|
888
|
+
const s = settings()
|
|
889
|
+
if (s.authMode === 'oauth') return provider.oauth.resolveOAuthCredential(s)
|
|
890
|
+
// api-key mode: the active list entry wins, legacy env ref is the fallback.
|
|
891
|
+
const active = s.apiKeys.find((k) => k.name === s.activeApiKey)
|
|
892
|
+
const key = active?.key ?? envKey(s.apiKeyEnv)
|
|
893
|
+
if (!key) return null
|
|
894
|
+
return { authorization: `Bearer ${key}`, headers: {} }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Credential candidates for one outbound call, in try order.
|
|
899
|
+
* OAuth: zero or one candidate (rotation deliberately not applied).
|
|
900
|
+
* api-key: 0 keys → the legacy env ref (single candidate); 1 key → that key;
|
|
901
|
+
* ≥2 keys → every key exactly once, non-cooling first in round-robin order,
|
|
902
|
+
* cooling keys appended as last resort.
|
|
903
|
+
*/
|
|
904
|
+
async function resolveCredentialCandidates(settings) {
|
|
905
|
+
const s = settings()
|
|
906
|
+
if (s.authMode === 'oauth') {
|
|
907
|
+
const single = await provider.oauth.resolveOAuthCredential(s)
|
|
908
|
+
return single ? [{ ...single, keyName: null }] : []
|
|
909
|
+
}
|
|
910
|
+
const keys = (s.apiKeys ?? []).filter((k) => typeof k?.key === 'string' && k.key.length > 0)
|
|
911
|
+
if (keys.length === 0) {
|
|
912
|
+
const env = envKey(s.apiKeyEnv)
|
|
913
|
+
return env ? [{ authorization: `Bearer ${env}`, headers: {}, keyName: null }] : []
|
|
914
|
+
}
|
|
915
|
+
if (keys.length === 1) {
|
|
916
|
+
return [{ authorization: `Bearer ${keys[0].key}`, headers: {}, keyName: keys[0].name }]
|
|
917
|
+
}
|
|
918
|
+
return rotator.ordered(keys)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Run `attempt(cred)` over the rotation candidates with failover (see
|
|
923
|
+
* core/rotation.js). The empty-candidate error is flagged
|
|
924
|
+
* `credentialUnavailable` so the bridge/provider layers recognize it without
|
|
925
|
+
* matching on the message text (the text itself stays for users).
|
|
926
|
+
*/
|
|
927
|
+
async function withKeyRotation(settings, attempt) {
|
|
928
|
+
const candidates = await resolveCredentialCandidates(settings)
|
|
929
|
+
const emptyError = new Error(CREDENTIAL_UNAVAILABLE_MESSAGE)
|
|
930
|
+
emptyError.credentialUnavailable = true
|
|
931
|
+
return rotator.run(candidates, attempt, {
|
|
932
|
+
cooldownMs: settings().keyCooldownMs,
|
|
933
|
+
emptyError,
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
// TraeWork CN 通道(v0.8.x):第二上游 = OAuth 订阅额度 + 私有协议翻译网关。
|
|
939
|
+
// 凭据只有 OAuth 一支(Trae 订阅跟账号走);设备密钥自持(providers/trae/
|
|
940
|
+
// oauth.js)。模型目录来自本机 state.vscdb(提取器纯函数复用),镜像进
|
|
941
|
+
// settings.yaml 的 llm-pi-ai.providers.trae.models——patch 层只带路由不带
|
|
942
|
+
// 模型,清单永远由镜像独占(无"陈旧遮蔽"问题,纯净态=删镜像路径)。
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
const readTraeAuth = () => readJson(TRAE_AUTH_PATH)
|
|
946
|
+
const writeTraeAuth = (v) => writeJson(TRAE_AUTH_PATH, v)
|
|
947
|
+
|
|
948
|
+
const traeRuntime = { running: false, port: null, lastError: null }
|
|
949
|
+
|
|
950
|
+
/** Trae 凭据候选(OAuth 单候选,无轮换);空候选错误带稳定标记。 */
|
|
951
|
+
async function withTraeCredentials(settingsFn, attempt) {
|
|
952
|
+
const s = settingsFn()
|
|
953
|
+
let cred = null
|
|
954
|
+
try {
|
|
955
|
+
cred = await traeProvider.oauth.resolveTraeCredential(s)
|
|
956
|
+
} catch (err) {
|
|
957
|
+
const e = new Error(`trae credential error: ${err?.message ?? err}`)
|
|
958
|
+
e.credentialUnavailable = true
|
|
959
|
+
return { cred: null, res: null, err: e }
|
|
960
|
+
}
|
|
961
|
+
if (!cred) {
|
|
962
|
+
const e = new Error(TRAE_CREDENTIAL_UNAVAILABLE_MESSAGE)
|
|
963
|
+
e.credentialUnavailable = true
|
|
964
|
+
return { cred: null, res: null, err: e }
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
const res = await attempt(cred)
|
|
968
|
+
return { cred, res, err: null }
|
|
969
|
+
} catch (err) {
|
|
970
|
+
return { cred, res: null, err }
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// 迟绑定:网关需要"本代"的 settings 解析函数;apply() 每代重设该模块级
|
|
975
|
+
// 变量(与 codebuddy 侧 hoisted 函数的迟绑定等价,形态不同只因工厂签名)。
|
|
976
|
+
let traeSettingsFn = () => ({ traeEnabled: false })
|
|
977
|
+
|
|
978
|
+
const traeProvider = createTraeProvider({
|
|
979
|
+
readAuth: readTraeAuth,
|
|
980
|
+
writeAuth: writeTraeAuth,
|
|
981
|
+
settings: () => traeSettingsFn(),
|
|
982
|
+
withCredentials: (attempt) => withTraeCredentials(() => traeSettingsFn(), attempt),
|
|
983
|
+
meter,
|
|
984
|
+
runtime: traeRuntime,
|
|
985
|
+
forensics: { logPath: () => process.env.TRAE_BRIDGE_LOG },
|
|
986
|
+
getModelPrefs: () => readTraeModelPrefs(),
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// Qoder CN 通道(2026-09-19 Phase 1 登录;2026-09-20 Phase 2 聊天面打通):
|
|
991
|
+
// 设备流 OAuth(凭据存 ~/.dsh/qoder-plugin-auth.json)+ COSY WASM 签名
|
|
992
|
+
// (providers/qoder/cosy.js)+ 翻译网关(OpenAI↔COSY SSE 信封)+ 网关目录
|
|
993
|
+
// 镜像 providers.qoder 整块(路由存在性管理,同 trae 踩坑 #25 纪律)。
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
const readQoderAuth = () => readJson(QODER_AUTH_PATH)
|
|
997
|
+
const writeQoderAuth = (v) => writeJson(QODER_AUTH_PATH, v)
|
|
998
|
+
|
|
999
|
+
const qoderRuntime = { running: false, port: null, lastError: null }
|
|
1000
|
+
|
|
1001
|
+
// 迟绑定:网关/目录需要"本代"的 settings 解析函数;apply() 每代重设。
|
|
1002
|
+
let qoderSettingsFn = () => ({ qoderEnabled: false })
|
|
1003
|
+
|
|
1004
|
+
const qoderProvider = createQoderProvider({
|
|
1005
|
+
readAuth: readQoderAuth,
|
|
1006
|
+
writeAuth: writeQoderAuth,
|
|
1007
|
+
settings: () => qoderSettingsFn(),
|
|
1008
|
+
meter,
|
|
1009
|
+
runtime: qoderRuntime,
|
|
1010
|
+
forensics: { logPath: () => process.env.QODER_GATEWAY_LOG },
|
|
1011
|
+
getModelPrefs: () => readQoderModelPrefs(),
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
function readQoderModelState() {
|
|
1015
|
+
const state = readFileLayer().qoderModelState
|
|
1016
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return { disabled: {} }
|
|
1017
|
+
return {
|
|
1018
|
+
disabled: state.disabled && typeof state.disabled === 'object' && !Array.isArray(state.disabled)
|
|
1019
|
+
? state.disabled : {},
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// 逐模型偏好:{ [id]: { effort?, contextVariant? } },与 qoderModelState 并列的
|
|
1024
|
+
// 独立文件层键(其形状是 disabled 集合字典,与 prefs 记录不对称,故不扩它)。
|
|
1025
|
+
// 只存已设置的键、空记录不落盘(= 默认)。effort 的**真源是目录逐模型声明**
|
|
1026
|
+
// (providers/qoder/catalog.js 的 qoderEffortTiers ← thinking_config.enabled.efforts
|
|
1027
|
+
// ∪ disabled→off);contextVariant 是目录 context_config 变体名,镜像时换成
|
|
1028
|
+
// contextWindow(applyQoderContextVariant)。
|
|
1029
|
+
//
|
|
1030
|
+
// QODER_EFFORT_LEVELS 不再是 UI/校验真源,只作**存量兼容网**(R3):0.17 及以前
|
|
1031
|
+
// 写下的、目录未声明的拼写(如 qmodel_38max 的 max)读侧保留、不静默丢;目录未
|
|
1032
|
+
// 同步的启动窗口内也仍认这五个拼写。新写入一律以目录声明为准(qoderEffortScope)。
|
|
1033
|
+
const QODER_EFFORT_LEVELS = ['off', 'low', 'medium', 'high', 'max']
|
|
1034
|
+
|
|
1035
|
+
/** 目录为该模型声明的档位(未同步 / 未声明命名档位 → [])。 */
|
|
1036
|
+
function qoderDeclaredTiers(id) {
|
|
1037
|
+
const tiers = qoderProvider.catalogView()?.efforts?.[id]
|
|
1038
|
+
return Array.isArray(tiers) ? tiers : []
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* 写入校验的允许集 = 目录声明 ∪ {该模型当前存量值}。
|
|
1043
|
+
* 含存量值是为 R3:UI 提交的是**全量期望态**(effort + contextVariant 一起发),
|
|
1044
|
+
* 若目录不再声明存量档位(如 max),只改上下文长度也会被误判非法。新设一个
|
|
1045
|
+
* 目录未声明的档位仍然拒(允许集里没有它)。
|
|
1046
|
+
*/
|
|
1047
|
+
function qoderEffortScope(id) {
|
|
1048
|
+
const declared = qoderDeclaredTiers(id)
|
|
1049
|
+
const stored = readQoderModelPrefs()[id]?.effort
|
|
1050
|
+
return stored ? [...new Set([...declared, stored])] : declared
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function readQoderModelPrefs() {
|
|
1054
|
+
const raw = readFileLayer().qoderModelPrefs
|
|
1055
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
|
1056
|
+
const out = {}
|
|
1057
|
+
for (const [id, v] of Object.entries(raw)) {
|
|
1058
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
1059
|
+
const rec = {}
|
|
1060
|
+
// 读侧保留规则:非空字符串且(目录声明了它 ∪ 旧固定表认得它)。前者覆盖
|
|
1061
|
+
// xhigh 这类上游专有拼写,后者是存量兼容网(R3);两者之外的手改垃圾丢弃。
|
|
1062
|
+
if (typeof v.effort === 'string' && v.effort
|
|
1063
|
+
&& (qoderDeclaredTiers(id).includes(v.effort) || QODER_EFFORT_LEVELS.includes(v.effort))) {
|
|
1064
|
+
rec.effort = v.effort
|
|
1065
|
+
}
|
|
1066
|
+
if (typeof v.contextVariant === 'string' && v.contextVariant) rec.contextVariant = v.contextVariant
|
|
1067
|
+
if (Object.keys(rec).length) out[id] = rec
|
|
1068
|
+
}
|
|
1069
|
+
return out
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** 镜像 providers.qoder **整块**(路由存在性管理,同 trae 镜像纪律)。 */
|
|
1073
|
+
async function syncQoderModelsToDshSettings() {
|
|
1074
|
+
try {
|
|
1075
|
+
// 端口同源:经 effectiveSettingsFn(apply 内赋值的 resolveNow)解析,
|
|
1076
|
+
// 未赋值窗口回退纯文件层默认(shift=0)语义。
|
|
1077
|
+
const s = effectiveSettingsFn ? effectiveSettingsFn() : Config({ ...readFileLayer() })
|
|
1078
|
+
const view = qoderProvider.catalogView()
|
|
1079
|
+
const disabled = readQoderModelState().disabled
|
|
1080
|
+
const prefs = readQoderModelPrefs()
|
|
1081
|
+
const models = s.qoderEnabled === true && view
|
|
1082
|
+
? view.profiles
|
|
1083
|
+
.filter((p) => !disabled[p.id])
|
|
1084
|
+
.map((p) => applyQoderContextVariant(p, prefs[p.id]?.contextVariant, view.variants?.[p.id]))
|
|
1085
|
+
: null
|
|
1086
|
+
const path = ['providers', 'qoder']
|
|
1087
|
+
if (!models || models.length === 0) return await hostConfig.applyOps([{ op: 'unset', path }])
|
|
1088
|
+
const block = {
|
|
1089
|
+
displayName: 'Qoder CN',
|
|
1090
|
+
api: 'openai-completions',
|
|
1091
|
+
baseURL: `http://127.0.0.1:${s.qoderBridgePort}/v1`,
|
|
1092
|
+
headers: { Authorization: 'Bearer dsh-qoder-bridge' },
|
|
1093
|
+
// 宿主 Model/Effort 选择器要出档,路由必须声明 supportsReasoningEffort——
|
|
1094
|
+
// pi-ai 的 openai-completions 出站只在 compat.supportsReasoningEffort 为真时
|
|
1095
|
+
// 才把选中档位写成 reasoning_effort(同 codebuddy 路由的 compat 形状);
|
|
1096
|
+
// thinkingFormat 显式钉 openai,免得 pi-ai 从 127.0.0.1 的 URL 猜成别的方言
|
|
1097
|
+
// (codebuddy 侧踩过:猜错会发 thinking 而不是 reasoning_effort)。
|
|
1098
|
+
// 档位表由 catalog 投影进 models[].reasoningEfforts(见 qoderReasoningEfforts)。
|
|
1099
|
+
compat: { thinkingFormat: 'openai', supportsReasoningEffort: true },
|
|
1100
|
+
models: YAML.parse(YAML.stringify(models)),
|
|
1101
|
+
}
|
|
1102
|
+
return await hostConfig.applyOps([{ op: 'set', path, value: block }])
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
process.stderr.write(`[dsh-tap] qoder mirror failed: ${err?.message ?? err}\n`)
|
|
1105
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function setQoderModelEnabled({ id, enabled }) {
|
|
1110
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('qoderModelSetEnabled 需要 id')
|
|
1111
|
+
assertSafeModelId(id)
|
|
1112
|
+
const view = qoderProvider.catalogView()
|
|
1113
|
+
if (!view || !view.profiles.some((p) => p.id === id)) throw new Error(`${id} 不在 Qoder 目录里(先同步目录)`)
|
|
1114
|
+
const layer = readFileLayer()
|
|
1115
|
+
const state = readQoderModelState()
|
|
1116
|
+
if (enabled) delete state.disabled[id]
|
|
1117
|
+
else state.disabled[id] = true
|
|
1118
|
+
layer.qoderModelState = state
|
|
1119
|
+
writeFileLayer(layer)
|
|
1120
|
+
await syncQoderModelsToDshSettings()
|
|
1121
|
+
return state
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* qoderModelSetPrefs 写路径(UI 契约:patch.qoderModelSetPrefs = { id, prefs })。
|
|
1126
|
+
* prefs 是该模型记录的**完整替换**——只存已设置的键,空对象 = 删记录回默认。
|
|
1127
|
+
* effort 校验按**该模型的目录声明**(qoderDeclaredTiers)∪ 其存量值(R3 兼容);
|
|
1128
|
+
* contextVariant 必须命中该模型目录变体(无变体模型拒收)。
|
|
1129
|
+
*/
|
|
1130
|
+
async function setQoderModelPrefs({ id, prefs }) {
|
|
1131
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('qoderModelSetPrefs 需要 id')
|
|
1132
|
+
assertSafeModelId(id)
|
|
1133
|
+
const view = qoderProvider.catalogView()
|
|
1134
|
+
if (!view || !view.profiles.some((p) => p.id === id)) throw new Error(`${id} 不在 Qoder 目录里(先同步目录)`)
|
|
1135
|
+
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) throw new Error('qoderModelSetPrefs 需要 prefs 对象')
|
|
1136
|
+
const unknown = Object.keys(prefs).filter((k) => k !== 'effort' && k !== 'contextVariant')
|
|
1137
|
+
if (unknown.length) throw new Error(`prefs 不支持的键:${unknown.join(', ')}`)
|
|
1138
|
+
const rec = {}
|
|
1139
|
+
if (prefs.effort !== undefined) {
|
|
1140
|
+
const declared = qoderDeclaredTiers(id)
|
|
1141
|
+
if (!qoderEffortScope(id).includes(prefs.effort)) {
|
|
1142
|
+
throw new Error(declared.length
|
|
1143
|
+
? `effort 档位必须是该模型目录声明的 ${declared.join('/')} 之一`
|
|
1144
|
+
: `该模型目录未声明思考档位,effort 只能清除不能新设`)
|
|
1145
|
+
}
|
|
1146
|
+
rec.effort = prefs.effort
|
|
1147
|
+
}
|
|
1148
|
+
if (prefs.contextVariant !== undefined) {
|
|
1149
|
+
const variants = view.variants?.[id] ?? []
|
|
1150
|
+
if (!variants.some((v) => v?.name === prefs.contextVariant)) {
|
|
1151
|
+
throw new Error(`${id} 没有名为 ${prefs.contextVariant} 的上下文变体`)
|
|
1152
|
+
}
|
|
1153
|
+
rec.contextVariant = prefs.contextVariant
|
|
1154
|
+
}
|
|
1155
|
+
const state = readQoderModelPrefs()
|
|
1156
|
+
if (Object.keys(rec).length) state[id] = rec
|
|
1157
|
+
else delete state[id]
|
|
1158
|
+
const layer = readFileLayer()
|
|
1159
|
+
layer.qoderModelPrefs = state
|
|
1160
|
+
writeFileLayer(layer)
|
|
1161
|
+
await syncQoderModelsToDshSettings()
|
|
1162
|
+
return state
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function readTraeModelState() {
|
|
1166
|
+
const state = readFileLayer().traeModelState
|
|
1167
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return { disabled: {} }
|
|
1168
|
+
return {
|
|
1169
|
+
disabled: state.disabled && typeof state.disabled === 'object' && !Array.isArray(state.disabled)
|
|
1170
|
+
? state.disabled : {},
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** 目录为该模型声明的档位(未同步 / 未声明 → [])。 */
|
|
1175
|
+
function traeDeclaredTiers(id) {
|
|
1176
|
+
const tiers = traeProvider.catalogView()?.efforts?.[id]
|
|
1177
|
+
return Array.isArray(tiers) ? tiers : []
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Trae 逐模型 prefs(goal: trae-work-cn-repair G3)。目前只有 effort 一个键。
|
|
1182
|
+
* 读侧保留规则:目录已同步时只认声明档位;未同步窗口(启动竞态)读不出声明,
|
|
1183
|
+
* 存量值原样保留——同步完成后的下次读写自然按声明过滤。
|
|
1184
|
+
*/
|
|
1185
|
+
function readTraeModelPrefs() {
|
|
1186
|
+
const raw = readFileLayer().traeModelPrefs
|
|
1187
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
|
1188
|
+
const synced = traeProvider.catalogView() != null
|
|
1189
|
+
const out = {}
|
|
1190
|
+
for (const [id, v] of Object.entries(raw)) {
|
|
1191
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
1192
|
+
if (typeof v.effort !== 'string' || !v.effort) continue
|
|
1193
|
+
if (synced && !traeDeclaredTiers(id).includes(v.effort)) continue
|
|
1194
|
+
out[id] = { effort: v.effort }
|
|
1195
|
+
}
|
|
1196
|
+
return out
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* traeModelSetPrefs 写路径(UI 契约:patch.traeModelSetPrefs = { id, prefs })。
|
|
1201
|
+
* 允许集 = 目录声明 ∪ {该模型当前存量值}(同 qoder R3:UI 发全量期望态,存量
|
|
1202
|
+
* 值即使目录不再声明也不能误判非法);空 prefs = 删记录回默认。
|
|
1203
|
+
*/
|
|
1204
|
+
async function setTraeModelPrefs({ id, prefs } = {}) {
|
|
1205
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('traeModelSetPrefs 需要 id')
|
|
1206
|
+
assertSafeModelId(id)
|
|
1207
|
+
const view = traeProvider.catalogView()
|
|
1208
|
+
if (!view || !view.profiles.some((p) => p.id === id)) throw new Error(`${id} 不在 Trae 目录里(先同步目录)`)
|
|
1209
|
+
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) throw new Error('traeModelSetPrefs 需要 prefs 对象')
|
|
1210
|
+
const unknown = Object.keys(prefs).filter((k) => k !== 'effort')
|
|
1211
|
+
if (unknown.length) throw new Error(`prefs 不支持的键:${unknown.join(', ')}`)
|
|
1212
|
+
if (prefs.effort !== undefined) {
|
|
1213
|
+
const stored = readTraeModelPrefs()[id]?.effort
|
|
1214
|
+
const allowed = [...new Set([...traeDeclaredTiers(id), ...(stored ? [stored] : [])])]
|
|
1215
|
+
if (!allowed.length) throw new Error(`${id} 目录未声明思考档位(不支持逐模型调节)`)
|
|
1216
|
+
if (typeof prefs.effort !== 'string' || !allowed.includes(prefs.effort)) {
|
|
1217
|
+
throw new Error(`effort 必须是该模型目录声明的档位(${allowed.join('/')})`)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const layer = readFileLayer()
|
|
1221
|
+
const state = layer.traeModelPrefs && typeof layer.traeModelPrefs === 'object' && !Array.isArray(layer.traeModelPrefs)
|
|
1222
|
+
? { ...layer.traeModelPrefs } : {}
|
|
1223
|
+
const rec = {}
|
|
1224
|
+
if (prefs.effort !== undefined) rec.effort = prefs.effort
|
|
1225
|
+
if (Object.keys(rec).length) state[id] = rec
|
|
1226
|
+
else delete state[id]
|
|
1227
|
+
layer.traeModelPrefs = state
|
|
1228
|
+
writeFileLayer(layer)
|
|
1229
|
+
await syncTraeModelsToDshSettings()
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* 镜像 providers.trae **整块**(0.8.7 / dsh 0.1.1-rc.2 适配,取代"恒铺
|
|
1234
|
+
* models 路径 + 空数组遮蔽"):llm-pi-ai 收紧了目录校验——非目录路由的空
|
|
1235
|
+
* models 清单在 apply 时直接 throw(连坐整棵 llm-pi-ai 纤维,主聊天全挂),
|
|
1236
|
+
* 热加载路径也被 onChange 拒绝并保持旧值,"空数组遮蔽"彻底失效(踩坑 #25
|
|
1237
|
+
* 修订)。新策略 = **路由存在性管理**:patch 不再带 trae 静态基线,镜像独占
|
|
1238
|
+
* 该路由的完整定义——
|
|
1239
|
+
* 启用+已同步 → 铺完整块(displayName/api/baseURL/headers/models),
|
|
1240
|
+
* baseURL 跟随 traeBridgePort(改端口重铺镜像即热生效,优于旧 patch 静态式);
|
|
1241
|
+
* 禁用/未同步/全部模型禁用 → 删除 providers.trae 整块(路由消失,选择器
|
|
1242
|
+
* 隐藏通道,免重启:0.1.7+ 经 forms seam 落 profile patch 即时生效,
|
|
1243
|
+
* ≤0.1.6 靠 settings.yaml 的 chokidar 热加载)。无 patch 基线即无回落,
|
|
1244
|
+
* 删块即干净。
|
|
1245
|
+
* 升级注意:<=0.8.5 写的 trae 块只带 models 路径(其余字段靠 patch 深合并),
|
|
1246
|
+
* 重启前须先清掉旧块(见 cordis.patch.yml 的 UPGRADE NOTE)。
|
|
1247
|
+
*/
|
|
1248
|
+
async function syncTraeModelsToDshSettings() {
|
|
1249
|
+
try {
|
|
1250
|
+
// 端口同源 effectiveSettingsFn(镜像与 listen 一次解析;注释「entry 侧无
|
|
1251
|
+
// trae 字段,schema 默认补齐」指纯文件层路径,resolveNow 本身已含 entry 层)。
|
|
1252
|
+
const s = effectiveSettingsFn ? effectiveSettingsFn() : Config({ ...readFileLayer() })
|
|
1253
|
+
const view = traeProvider.catalogView()
|
|
1254
|
+
const disabled = readTraeModelState().disabled
|
|
1255
|
+
const models = s.traeEnabled === true && view
|
|
1256
|
+
? view.profiles.filter((p) => !disabled[p.id])
|
|
1257
|
+
: null
|
|
1258
|
+
const path = ['providers', 'trae']
|
|
1259
|
+
if (!models || models.length === 0) return await hostConfig.applyOps([{ op: 'unset', path }])
|
|
1260
|
+
const block = {
|
|
1261
|
+
displayName: 'TraeWork CN',
|
|
1262
|
+
api: 'openai-completions',
|
|
1263
|
+
baseURL: `http://127.0.0.1:${s.traeBridgePort}/v1`,
|
|
1264
|
+
headers: { Authorization: 'Bearer dsh-trae-bridge' },
|
|
1265
|
+
// 宿主 Model/Effort 选择器出档的两前提(同 qoder 镜像纪律,踩坑 #64):
|
|
1266
|
+
// 路由声明 compat.supportsReasoningEffort(否则 pi-ai 出站不写
|
|
1267
|
+
// reasoning_effort)+ 档位表已投影进 models[].reasoningEfforts
|
|
1268
|
+
//(catalogToProfiles 从目录 reasoning_effort_config 投影)。
|
|
1269
|
+
// thinkingFormat 钉 openai 防 pi-ai 从 127.0.0.1 猜方言。
|
|
1270
|
+
compat: { thinkingFormat: 'openai', supportsReasoningEffort: true },
|
|
1271
|
+
models: YAML.parse(YAML.stringify(models)),
|
|
1272
|
+
}
|
|
1273
|
+
return await hostConfig.applyOps([{ op: 'set', path, value: block }])
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
process.stderr.write(`[dsh-tap] trae mirror failed: ${err?.message ?? err}\n`)
|
|
1276
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 安全审计 [18]:state.vscdb 路径纵深防御(CSRF 已被 settings 路由本地门
|
|
1282
|
+
* 挡住,这里是第二道)。规则:绝对路径、规范化后不含 .. 段、扩展名限
|
|
1283
|
+
* .db/.vscdb。不校验存在性——不存在的路径沿用既有同步错误路径报错。
|
|
1284
|
+
*/
|
|
1285
|
+
function isSafeStateDbPath(p) {
|
|
1286
|
+
if (typeof p !== 'string' || !p || !isAbsolute(p)) return false
|
|
1287
|
+
const normalized = normalize(p)
|
|
1288
|
+
if (normalized.split(/[\\/]/).includes('..')) return false
|
|
1289
|
+
const ext = extname(normalized).toLowerCase()
|
|
1290
|
+
return ext === '.db' || ext === '.vscdb'
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function setTraeModelEnabled({ id, enabled }) {
|
|
1294
|
+
if (typeof id !== 'string' || !id.trim()) throw new Error('traeModelSetEnabled 需要 id')
|
|
1295
|
+
assertSafeModelId(id)
|
|
1296
|
+
const view = traeProvider.catalogView()
|
|
1297
|
+
if (!view || !view.profiles.some((p) => p.id === id)) throw new Error(`${id} 不在 Trae 目录里(先同步目录)`)
|
|
1298
|
+
const layer = readFileLayer()
|
|
1299
|
+
const state = readTraeModelState()
|
|
1300
|
+
if (enabled) delete state.disabled[id]
|
|
1301
|
+
else state.disabled[id] = true
|
|
1302
|
+
layer.traeModelState = state
|
|
1303
|
+
writeFileLayer(layer)
|
|
1304
|
+
await syncTraeModelsToDshSettings()
|
|
1305
|
+
return state
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// ---------------------------------------------------------------------------
|
|
1309
|
+
// Bridge runtime state (module-level, shared across apply() generations —
|
|
1310
|
+
// production runs one plugin instance per process, so last-apply-wins is
|
|
1311
|
+
// correct; scripts/verify-bridge.mjs §10 pins the semantics).
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
|
|
1314
|
+
const bridgeRuntime = { running: false, port: null, lastError: null }
|
|
1315
|
+
|
|
1316
|
+
// ---------------------------------------------------------------------------
|
|
1317
|
+
// Settings route consumed by the Web UI card.
|
|
1318
|
+
// ---------------------------------------------------------------------------
|
|
1319
|
+
|
|
1320
|
+
function sendJSON(res, status, payload) {
|
|
1321
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
1322
|
+
res.end(JSON.stringify(payload))
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Only same-origin writes: POST mutates settings or credentials.
|
|
1326
|
+
* allowedOrigins:额外放行的 Origin 完整串(精确匹配,非前缀/通配)。
|
|
1327
|
+
* 无 Origin 的 POST 放行:应用壳把页面请求转发到本机 Host 时会剥掉 Origin
|
|
1328
|
+
* 头(自定义 scheme 的 Origin host 与回环 Host 不同形,壳侧按自有名单校验
|
|
1329
|
+
* 后转发),故壳内写入恒无 Origin——与 GET 侧 localGuardFailure 的「无
|
|
1330
|
+
* Origin 放行」同语义。浏览器发起的跨站 POST 恒带 Origin,不受影响;回环
|
|
1331
|
+
* Host 门(防 DNS rebinding / LAN 直连)不受影响;非浏览器客户端本就可
|
|
1332
|
+
* 伪造任意 Origin 头,此门对它们无额外约束力。 */
|
|
1333
|
+
function sameOrigin(req, allowedOrigins = []) {
|
|
1334
|
+
const origin = req.headers.origin
|
|
1335
|
+
const host = req.headers.host
|
|
1336
|
+
if (host === undefined) return false
|
|
1337
|
+
if (origin === undefined) return true
|
|
1338
|
+
if (Array.isArray(allowedOrigins) && allowedOrigins.includes(origin)) return true
|
|
1339
|
+
try {
|
|
1340
|
+
return new URL(origin).host === host
|
|
1341
|
+
} catch {
|
|
1342
|
+
return false
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* 安全审计 [6]+[7]:/dsh-tap/settings 是本地特权面(读 OAuth 状态/桥端口/
|
|
1348
|
+
* 目录,POST 改设置与凭据)。GET 与 POST 一体设防,统一判定不做分支复制:
|
|
1349
|
+
* - Host 门:Host 头必须解析为回环 hostname——防 DNS rebinding(攻击域
|
|
1350
|
+
* 解析到 127.0.0.1 后借浏览器直读)与 LAN 直连。代价:经 LAN IP 访问
|
|
1351
|
+
* 设置卡被拒,属有意收紧。
|
|
1352
|
+
* - Origin 门:带 Origin 头时其 host:port 必须与 Host 一致——浏览器跨站
|
|
1353
|
+
* 请求必带 Origin,不一致即跨站伪造。
|
|
1354
|
+
* Returns null to proceed, else the refusal reason (送 403 响应体).
|
|
1355
|
+
*/
|
|
1356
|
+
function localGuardFailure(req, allowedOrigins = []) {
|
|
1357
|
+
const host = req.headers.host
|
|
1358
|
+
const hostname = typeof host === 'string' ? hostHeaderHostname(host) : null
|
|
1359
|
+
if (!hostname || !isLoopbackHostname(hostname)) {
|
|
1360
|
+
return `本接口仅限本机访问:Host 必须是回环地址(127.0.0.1/localhost/::1),收到 ${host ?? '(缺失)'}`
|
|
1361
|
+
}
|
|
1362
|
+
const origin = req.headers.origin
|
|
1363
|
+
if (origin !== undefined && !(Array.isArray(allowedOrigins) && allowedOrigins.includes(origin))) {
|
|
1364
|
+
let originMatches = false
|
|
1365
|
+
try {
|
|
1366
|
+
originMatches = new URL(origin).host === host
|
|
1367
|
+
} catch {
|
|
1368
|
+
originMatches = false
|
|
1369
|
+
}
|
|
1370
|
+
if (!originMatches) return `Origin 与 Host 不一致(疑似跨站请求):${origin}`
|
|
1371
|
+
}
|
|
1372
|
+
return null
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** Mask a key for display: first 4 and last 4 characters. */
|
|
1376
|
+
function maskKey(key) {
|
|
1377
|
+
if (typeof key !== 'string' || key.length <= 8) return '****'
|
|
1378
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* File layer safe to ship to the browser: plaintext apiKeys[].key masked away.
|
|
1383
|
+
* Same policy as the GET view — f9aeeaa covered GET only; the four POST
|
|
1384
|
+
* responses that also carried the raw file layer regressed it. The card
|
|
1385
|
+
* consumes `user` solely for top-level field presence (overriddenFor →
|
|
1386
|
+
* hasOwnProperty), never nested key material — masking here is lossless.
|
|
1387
|
+
*/
|
|
1388
|
+
function maskedUserLayer(user) {
|
|
1389
|
+
return Array.isArray(user.apiKeys)
|
|
1390
|
+
? { ...user, apiKeys: user.apiKeys.map((k) => ({ ...k, key: maskKey(k.key) })) }
|
|
1391
|
+
: user
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** cordis.patch.yml 顶层 patch 行的 entry id 清单(insert 行除外)——宿主实况对账的期望集合。 */
|
|
1395
|
+
function patchEntryIds() {
|
|
1396
|
+
try {
|
|
1397
|
+
const rows = YAML.parse(readFileSync(PATCH_FILE, 'utf8'))
|
|
1398
|
+
return Array.isArray(rows) ? rows.map((r) => r?.id).filter((id) => typeof id === 'string') : []
|
|
1399
|
+
} catch {
|
|
1400
|
+
return []
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* 宿主实况对账(设置卡只读显示;踩坑 #43/#49 类静默失效的可见信号):
|
|
1406
|
+
* 期望态 = 插件自己的镜像纪律(codebuddy 有效清单长度 / trae·qoder 路由存在性);
|
|
1407
|
+
* 实际态 = hostConfig 读到的宿主配置层合成结果(forms = describe live value,
|
|
1408
|
+
* legacy = settings.yaml)。上游把模块改名时 patch 条目被整条跳过、退出码仍 0,
|
|
1409
|
+
* 运行时表现就是我们的镜像块从 describe 里消失或条目数对不上——这里把它算出来。
|
|
1410
|
+
* id→模块名的权威映射仍是离线的 `dsh --dump-config`(bundle 清单段),这里只做
|
|
1411
|
+
* 运行时可得的等价判定(describe 命名空间存在性 + provider 块计数)。
|
|
1412
|
+
*
|
|
1413
|
+
* patch 条目对账按条目性质分两路(2026-09-26 误报修正——「缺失:web」常驻假 warn):
|
|
1414
|
+
* allNamespaces 只覆盖**注册了 settings 命名空间**的条目;健康的 `web` 钉选行
|
|
1415
|
+
* 从来不在其中,拿它当全量预言机就是常驻假 warn(用户会对 warn 脱敏)。
|
|
1416
|
+
* - settings 条目(llm-pi-ai / agent-default-model)→ allNamespaces 存在性;
|
|
1417
|
+
* - `web` 钉选 → 运行时效果直查:web 服务实例的 searchProviderId/fetchProviderId
|
|
1418
|
+
* 公开字段(dsh-web 构造时从 config 落实例字段)。patch 行被跳过 ⇒ config 缺省
|
|
1419
|
+
* ⇒ 两字段 undefined——这是效果级判定,比命名空间在场更接近"用户可感知的事实"。
|
|
1420
|
+
*/
|
|
1421
|
+
function hostReconcileView(webService) {
|
|
1422
|
+
const s = Config({ ...readFileLayer() })
|
|
1423
|
+
const out = {
|
|
1424
|
+
mode: hostConfig.mode(),
|
|
1425
|
+
servicePresent: hostConfig.servicePresent(),
|
|
1426
|
+
missingEntries: null, // forms 模式限定;legacy 无 describe 可查
|
|
1427
|
+
webPin: null,
|
|
1428
|
+
channels: {},
|
|
1429
|
+
drift: [],
|
|
1430
|
+
}
|
|
1431
|
+
let providers = {}
|
|
1432
|
+
try { providers = hostConfig.readProviders() ?? {} } catch { providers = {} }
|
|
1433
|
+
if (out.mode === 'forms' && out.servicePresent) {
|
|
1434
|
+
try {
|
|
1435
|
+
const ns = hostConfig.probe().allNamespaces
|
|
1436
|
+
if (Array.isArray(ns)) {
|
|
1437
|
+
const have = new Set(ns)
|
|
1438
|
+
out.missingEntries = patchEntryIds().filter((id) => !have.has(id) && id !== 'web')
|
|
1439
|
+
}
|
|
1440
|
+
} catch { out.missingEntries = null }
|
|
1441
|
+
}
|
|
1442
|
+
// web 钉选行:效果级直查(见 docstring)。searchEnabled=false 时我们的 provider
|
|
1443
|
+
// 被注销,但钉选字段仍在(pin 是 config,不是注册状态)——两种状态下都能判。
|
|
1444
|
+
// 注意 webPin 不进 drift[]:drift 是通道形状(channel/expected/actual 模型数),
|
|
1445
|
+
// 区块头与对账分区分别直读 webPin.ok,避免污染通道行的 warn 不变量。
|
|
1446
|
+
try {
|
|
1447
|
+
const sp = webService?.()?.searchProviderId
|
|
1448
|
+
const fp = webService?.()?.fetchProviderId
|
|
1449
|
+
if (sp !== undefined || fp !== undefined || webService?.()) {
|
|
1450
|
+
out.webPin = { searchProvider: sp ?? null, fetchProvider: fp ?? null, ok: sp === 'codebuddy' && fp === 'codebuddy' }
|
|
1451
|
+
}
|
|
1452
|
+
} catch { out.webPin = null }
|
|
1453
|
+
// actual = 宿主层 provider 块的 models 数;块不存在 = null(路由缺席)。
|
|
1454
|
+
const actualOf = (id) => {
|
|
1455
|
+
const block = providers?.[id]
|
|
1456
|
+
if (!block || typeof block !== 'object') return null
|
|
1457
|
+
return Array.isArray(block.models) ? block.models.length : 0
|
|
1458
|
+
}
|
|
1459
|
+
const record = (channel, expected, actual, inferred) => {
|
|
1460
|
+
out.channels[channel] = { expected, actual, ...(inferred ? { inferred: true } : null) }
|
|
1461
|
+
const drifted = expected == null ? actual != null : actual == null || actual !== expected
|
|
1462
|
+
if (drifted) out.drift.push({ channel, expected, actual })
|
|
1463
|
+
}
|
|
1464
|
+
// codebuddy:恒应有路由;期望 = 有效清单长度。legacy 模式下镜像缺席时
|
|
1465
|
+
// settings.yaml 读不到 patch 静态基线(它由 bundle patch 层提供),回退静态数
|
|
1466
|
+
// 并标 inferred——别把"读不到"误报成漂移。
|
|
1467
|
+
const cbActualRaw = actualOf('codebuddy')
|
|
1468
|
+
const cbInferred = cbActualRaw == null && out.mode !== 'forms'
|
|
1469
|
+
record('codebuddy', computeEffectiveModels().length, cbActualRaw ?? (cbInferred ? readStaticModels().length : null), cbInferred)
|
|
1470
|
+
// trae/qoder:路由存在性管理(镜像独占整块)——期望 = 启用且已同步且 ≥1 个
|
|
1471
|
+
// 启用模型时的启用数,否则 null(不应存在镜像块)。
|
|
1472
|
+
const traeView = traeProvider.catalogView()
|
|
1473
|
+
const traeExpected = s.traeEnabled === true && traeView
|
|
1474
|
+
? traeView.profiles.filter((p) => !readTraeModelState().disabled[p.id]).length || null
|
|
1475
|
+
: null
|
|
1476
|
+
record('trae', traeExpected, actualOf('trae'))
|
|
1477
|
+
const qoderView = qoderProvider.catalogView()
|
|
1478
|
+
const qoderExpected = s.qoderEnabled === true && qoderView
|
|
1479
|
+
? qoderView.profiles.filter((p) => !readQoderModelState().disabled[p.id]).length || null
|
|
1480
|
+
: null
|
|
1481
|
+
record('qoder', qoderExpected, actualOf('qoder'))
|
|
1482
|
+
return out
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** The GET view: resolved settings with secrets masked, plus OAuth status. */
|
|
1486
|
+
function settingsView(resolveNow) {
|
|
1487
|
+
const s = resolveNow()
|
|
1488
|
+
const user = readFileLayer()
|
|
1489
|
+
const state = readModelState()
|
|
1490
|
+
return {
|
|
1491
|
+
value: {
|
|
1492
|
+
...s,
|
|
1493
|
+
apiKeys: (s.apiKeys ?? []).map((k) => ({ name: k.name, masked: maskKey(k.key) })),
|
|
1494
|
+
},
|
|
1495
|
+
// The raw file layer carries plaintext apiKeys[].key — never ship it to
|
|
1496
|
+
// the browser (the card only checks top-level field presence).
|
|
1497
|
+
user: maskedUserLayer(user),
|
|
1498
|
+
oauth: provider.oauth.oauthStatus(),
|
|
1499
|
+
bridge: {
|
|
1500
|
+
running: bridgeRuntime.running,
|
|
1501
|
+
port: bridgeRuntime.port,
|
|
1502
|
+
lastError: bridgeRuntime.lastError,
|
|
1503
|
+
},
|
|
1504
|
+
trae: {
|
|
1505
|
+
oauth: traeProvider.credentialView(),
|
|
1506
|
+
bridge: {
|
|
1507
|
+
running: traeRuntime.running,
|
|
1508
|
+
port: traeRuntime.port,
|
|
1509
|
+
lastError: traeRuntime.lastError,
|
|
1510
|
+
},
|
|
1511
|
+
models: {
|
|
1512
|
+
disabled: Object.keys(readTraeModelState().disabled),
|
|
1513
|
+
// 逐模型偏好读侧(UI 契约 traeModelSetPrefs 的镜像;只含已设置键)+
|
|
1514
|
+
// 目录声明的逐模型档位表(设置卡 select 选项真源,G3)。
|
|
1515
|
+
modelPrefs: readTraeModelPrefs(),
|
|
1516
|
+
efforts: traeProvider.catalogView()?.efforts ?? {},
|
|
1517
|
+
sync: traeProvider.syncView(),
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
models: {
|
|
1521
|
+
staticIds: readStaticModels().map((m) => m.id),
|
|
1522
|
+
disabled: Object.keys(state.disabled),
|
|
1523
|
+
extraIds: Object.keys(state.extra),
|
|
1524
|
+
// G5:每模型覆盖值(contextWindow/maxTokens),设置卡据此显示与校验。
|
|
1525
|
+
overrides: state.overrides ?? {},
|
|
1526
|
+
effectiveCount: computeEffectiveModels().length,
|
|
1527
|
+
// G4:选择器真实内容——设置卡勾选状态的唯一权威(动态目录启用后
|
|
1528
|
+
// 目录模型默认在内,不在 extra 里,不能靠 disabled/extra 反推)。
|
|
1529
|
+
effectiveIds: computeEffectiveModels().map((m) => m.id),
|
|
1530
|
+
// G4 动态目录同步状态:null = 静态兜底(未同步/同步失败且无旧目录)。
|
|
1531
|
+
// P2-4:routable = 目录数 − 已知不可路由(恒 11102)在列数。
|
|
1532
|
+
sync: dynamicCatalog
|
|
1533
|
+
? {
|
|
1534
|
+
at: dynamicCatalog.fetchedAt, count: dynamicCatalog.count, source: 'gateway',
|
|
1535
|
+
routable: dynamicCatalog.profiles.filter((p) => !UNROUTABLE_MODELS[p.id]).length,
|
|
1536
|
+
}
|
|
1537
|
+
: null,
|
|
1538
|
+
},
|
|
1539
|
+
qoder: {
|
|
1540
|
+
oauth: qoderProvider.credentialView(),
|
|
1541
|
+
bridge: {
|
|
1542
|
+
running: qoderRuntime.running,
|
|
1543
|
+
port: qoderRuntime.port,
|
|
1544
|
+
lastError: qoderRuntime.lastError,
|
|
1545
|
+
},
|
|
1546
|
+
models: {
|
|
1547
|
+
disabled: Object.keys(readQoderModelState().disabled),
|
|
1548
|
+
// 逐模型偏好读侧(UI 契约 qoderModelSetPrefs 的镜像;只含已设置键,
|
|
1549
|
+
// 无 effort 键 = 不注入、无 contextVariant 键 = 目录默认档)。
|
|
1550
|
+
modelPrefs: readQoderModelPrefs(),
|
|
1551
|
+
// 每模型上下文变体清单(目录 context_config;无变体 = [],UI 据此隐藏
|
|
1552
|
+
// 该模型的上下文选择)。
|
|
1553
|
+
variants: qoderProvider.catalogView()?.variants ?? {},
|
|
1554
|
+
// 每模型**目录声明的思考档位**(thinking_config → qoderEffortTiers;
|
|
1555
|
+
// 未声明命名档位 = [])。设置卡 select 的选项真源——与宿主「推理等级」
|
|
1556
|
+
// 同一份声明,UI 不再用固定表(0.19.0)。
|
|
1557
|
+
efforts: qoderProvider.catalogView()?.efforts ?? {},
|
|
1558
|
+
sync: qoderProvider.catalogView()
|
|
1559
|
+
? { at: qoderProvider.catalogView().at, count: qoderProvider.catalogView().count }
|
|
1560
|
+
: null,
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Route contract:
|
|
1568
|
+
* GET → settingsView
|
|
1569
|
+
* POST {patch: {...}} → merge & apply
|
|
1570
|
+
* POST {action:'oauth-start'} → {authUrl}
|
|
1571
|
+
* POST {action:'oauth-status'} → oauthStatus()
|
|
1572
|
+
* POST {action:'oauth-logout'} → clears tokens
|
|
1573
|
+
* POST {action:'qoder-oauth-start'|'qoder-oauth-status'|'qoder-oauth-logout'}
|
|
1574
|
+
* → Qoder CN 设备流(Phase 1 仅登录)
|
|
1575
|
+
* POST {patch:{qoderModelSetEnabled:{id,enabled}}} → Qoder 逐模型启停 → 镜像
|
|
1576
|
+
* POST {patch:{qoderModelSetPrefs:{id,prefs}}} → Qoder 逐模型思考强度/上下文
|
|
1577
|
+
* 变体(prefs 完整替换;{}
|
|
1578
|
+
* = 删记录回默认)→ 镜像
|
|
1579
|
+
* POST {action:'model-list'} → gateway catalog
|
|
1580
|
+
* POST {action:'model-sync'} → G4 resync /v3/config → mirror
|
|
1581
|
+
* POST {action:'provider-list'|'provider-add'|'provider-remove'|'provider-refresh'}
|
|
1582
|
+
* → G6 extra OpenAI-compat providers
|
|
1583
|
+
* POST {action:'provider-test'|'credential-test'} → P2-5「测一下」凭据主动验证
|
|
1584
|
+
* (available 布尔即结论,恒 200)
|
|
1585
|
+
* POST {action:'trae-oauth-*'|'trae-model-*'|'trae-quota'(双池余额只读)}
|
|
1586
|
+
* POST {patch:{traeModelSetEnabled:{id,enabled}}} → Trae 逐模型启停 → 镜像
|
|
1587
|
+
* POST {patch:{traeModelSetPrefs:{id,prefs}}} → Trae 逐模型思考强度
|
|
1588
|
+
* (prefs 完整替换;{} = 删记录
|
|
1589
|
+
* 回默认;effort 限目录声明档位)
|
|
1590
|
+
* POST {action:'qoder-oauth-*'|'qoder-model-*'|'qoder-quota'(配额只读)}
|
|
1591
|
+
* POST {action:'usage'} → usage meter + bridge state + quota snapshot
|
|
1592
|
+
* POST {action:'gateway-retry', channel} → P2-3 区块头「重试监听」:
|
|
1593
|
+
* 重跑该通道 sync*(enabled 限定,见路由处注释)
|
|
1594
|
+
* GET 响应另带 host = 宿主实况对账(镜像漂移检测,只读)
|
|
1595
|
+
*/
|
|
1596
|
+
function registerSettingsRoute(ctx, entryConfig, resolveNow, applyLive, retryGateway) {
|
|
1597
|
+
ctx.inject(['webServer'], (wsctx) => {
|
|
1598
|
+
wsctx.webServer.register({
|
|
1599
|
+
kind: 'exact',
|
|
1600
|
+
path: '/dsh-tap/settings',
|
|
1601
|
+
handler: (request, response) => {
|
|
1602
|
+
// 安全审计 [6]+[7]:GET 与 POST 都先过本地门(回环 Host + Origin 一致;
|
|
1603
|
+
// localAllowedOrigins 是壳内自定义 scheme Origin 的例外登记,精确匹配)。
|
|
1604
|
+
const allowedOrigins = resolveNow().localAllowedOrigins ?? []
|
|
1605
|
+
const guardFail = localGuardFailure(request, allowedOrigins)
|
|
1606
|
+
if (guardFail) {
|
|
1607
|
+
sendJSON(response, 403, { ok: false, error: guardFail })
|
|
1608
|
+
return
|
|
1609
|
+
}
|
|
1610
|
+
if (request.method === 'GET') {
|
|
1611
|
+
// 升级排查用:?probe=host-config 报宿主配置层实况(选路/可写性/
|
|
1612
|
+
// entry 是否可见/revision/有效 provider 清单),同过本地门。
|
|
1613
|
+
const probe = new URL(request.url, 'http://127.0.0.1').searchParams.get('probe')
|
|
1614
|
+
if (probe === 'host-config') {
|
|
1615
|
+
sendJSON(response, 200, { ok: true, probe: hostConfig.probe() })
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
// host = 宿主实况对账(只读;P1)。只挂主 GET——POST 响应的局部视图
|
|
1619
|
+
// 不带它,免得每次保存都多跑一遍 describe。
|
|
1620
|
+
sendJSON(response, 200, { ...settingsView(resolveNow), host: hostReconcileView(() => ctx.web) })
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
if (request.method !== 'POST' || !sameOrigin(request, allowedOrigins)) {
|
|
1624
|
+
sendJSON(response, request.method === 'POST' ? 403 : 405, { ok: false })
|
|
1625
|
+
return
|
|
1626
|
+
}
|
|
1627
|
+
// Buffer 收集 + 一次解码(踩坑 #28,同 core/bridge.js):
|
|
1628
|
+
// provider displayName 等中文经逐分片隐式解码同样会损坏。
|
|
1629
|
+
const chunks = []
|
|
1630
|
+
request.on('data', (c) => {
|
|
1631
|
+
chunks.push(c)
|
|
1632
|
+
})
|
|
1633
|
+
request.on('end', async () => {
|
|
1634
|
+
try {
|
|
1635
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1636
|
+
if (body?.action === 'oauth-start') {
|
|
1637
|
+
provider.oauth.startOAuth(resolveNow().baseURL)
|
|
1638
|
+
.then((r) => sendJSON(response, 200, { ok: true, authUrl: r.authUrl }))
|
|
1639
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1640
|
+
return
|
|
1641
|
+
}
|
|
1642
|
+
if (body?.action === 'oauth-status') {
|
|
1643
|
+
sendJSON(response, 200, { ok: true, oauth: provider.oauth.oauthStatus() })
|
|
1644
|
+
return
|
|
1645
|
+
}
|
|
1646
|
+
if (body?.action === 'model-list') {
|
|
1647
|
+
provider.catalog.fetchModelCatalog(resolveNow)
|
|
1648
|
+
.then((catalog) => {
|
|
1649
|
+
// G5:ceilings = 基清单∪extra 的实际上限(校验上限与 title);
|
|
1650
|
+
// profiles = ceilings 应用覆盖后的有效值(输入框显示值),
|
|
1651
|
+
// 覆盖所有已知 id(含禁用行——禁用不清覆盖值)。
|
|
1652
|
+
const ceilings = {}
|
|
1653
|
+
for (const m of computeBaseModels()) {
|
|
1654
|
+
ceilings[m.id] = { contextWindow: m.contextWindow ?? null, maxTokens: m.maxTokens ?? null }
|
|
1655
|
+
}
|
|
1656
|
+
for (const [id, p] of Object.entries(readModelState().extra)) {
|
|
1657
|
+
if (!ceilings[id] && p) ceilings[id] = { contextWindow: p.contextWindow ?? null, maxTokens: p.maxTokens ?? null }
|
|
1658
|
+
}
|
|
1659
|
+
const overrides = readModelState().overrides
|
|
1660
|
+
const profiles = {}
|
|
1661
|
+
for (const [id, c] of Object.entries(ceilings)) {
|
|
1662
|
+
const o = overrides[id] || {}
|
|
1663
|
+
profiles[id] = {
|
|
1664
|
+
contextWindow: o.contextWindow != null ? o.contextWindow : c.contextWindow,
|
|
1665
|
+
maxTokens: o.maxTokens != null ? o.maxTokens : c.maxTokens,
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
sendJSON(response, 200, {
|
|
1669
|
+
ok: true,
|
|
1670
|
+
catalog,
|
|
1671
|
+
staticIds: readStaticModels().map((m) => m.id),
|
|
1672
|
+
// G4:选择器真实内容(勾选语义 = 在不在选择器里)
|
|
1673
|
+
effectiveIds: computeEffectiveModels().map((m) => m.id),
|
|
1674
|
+
// P2-4:已知不可路由条目(目录在列但 /v2 恒 11102)——
|
|
1675
|
+
// 列表置灰+徽标与「可路由 M」计数的数据源。
|
|
1676
|
+
unroutable: UNROUTABLE_MODELS,
|
|
1677
|
+
profiles,
|
|
1678
|
+
ceilings,
|
|
1679
|
+
// id → 档位名清单,卡片据此给任意行出档位 select。
|
|
1680
|
+
// `efforts` = 基清单(patch 静态 reasoningEfforts ∪ 目录
|
|
1681
|
+
// supportedEfforts 声明)——目录声明优先;"off 但线值为空"
|
|
1682
|
+
// (= 省略参数 = 默认态)不进清单。`staticEfforts` 保留为
|
|
1683
|
+
// patch 静态表视图(旧契约)。
|
|
1684
|
+
efforts: Object.fromEntries(
|
|
1685
|
+
computeBaseModels()
|
|
1686
|
+
.map((m) => [m.id, effortTiersFor(m.id)])
|
|
1687
|
+
.filter(([, tiers]) => tiers.length > 0),
|
|
1688
|
+
),
|
|
1689
|
+
staticEfforts: Object.fromEntries(
|
|
1690
|
+
readStaticModels()
|
|
1691
|
+
.filter((m) => m.reasoningEfforts && typeof m.reasoningEfforts === 'object')
|
|
1692
|
+
.map((m) => [m.id, Object.keys(m.reasoningEfforts)]),
|
|
1693
|
+
),
|
|
1694
|
+
state: readModelState(),
|
|
1695
|
+
})
|
|
1696
|
+
})
|
|
1697
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
// G4:手动刷新——重新同步 /v3/config 并重铺 settings.yaml 镜像。
|
|
1701
|
+
if (body?.action === 'model-sync') {
|
|
1702
|
+
syncModelsFromGateway(resolveNow)
|
|
1703
|
+
.then((r) => sendJSON(response, 200, { ok: true, sync: r, models: settingsView(resolveNow).models }))
|
|
1704
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
// G6:多服务商注册表(key 型 OpenAI 兼容上游)。
|
|
1708
|
+
if (body?.action === 'provider-list') {
|
|
1709
|
+
sendJSON(response, 200, {
|
|
1710
|
+
ok: true,
|
|
1711
|
+
providers: extraProvidersView(),
|
|
1712
|
+
presets: PROVIDER_PRESETS.map((p) => ({ id: p.id, displayName: p.displayName, baseURL: p.baseURL })),
|
|
1713
|
+
})
|
|
1714
|
+
return
|
|
1715
|
+
}
|
|
1716
|
+
if (body?.action === 'provider-add') {
|
|
1717
|
+
addExtraProvider(body)
|
|
1718
|
+
.then((r) => sendJSON(response, 200, { ok: true, added: r, providers: extraProvidersView() }))
|
|
1719
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1722
|
+
if (body?.action === 'provider-remove') {
|
|
1723
|
+
try {
|
|
1724
|
+
if (typeof body.id !== 'string') throw new Error('provider-remove 需要 id')
|
|
1725
|
+
await removeExtraProvider(body.id)
|
|
1726
|
+
sendJSON(response, 200, { ok: true, providers: extraProvidersView() })
|
|
1727
|
+
} catch (err) {
|
|
1728
|
+
sendJSON(response, 400, { ok: false, error: err.message })
|
|
1729
|
+
}
|
|
1730
|
+
return
|
|
1731
|
+
}
|
|
1732
|
+
if (body?.action === 'provider-refresh') {
|
|
1733
|
+
if (typeof body.id !== 'string') {
|
|
1734
|
+
sendJSON(response, 400, { ok: false, error: 'provider-refresh 需要 id' })
|
|
1735
|
+
return
|
|
1736
|
+
}
|
|
1737
|
+
refreshExtraProviderModels(body.id)
|
|
1738
|
+
.then((r) => sendJSON(response, 200, { ok: true, refreshed: r, providers: extraProvidersView() }))
|
|
1739
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1740
|
+
return
|
|
1741
|
+
}
|
|
1742
|
+
// P2-5「测一下」:凭据主动验证。验证结论就是响应本体(available
|
|
1743
|
+
// 布尔),失败不是路由故障——恒 200(参数错误除外)。
|
|
1744
|
+
if (body?.action === 'provider-test') {
|
|
1745
|
+
if (typeof body.id !== 'string') {
|
|
1746
|
+
sendJSON(response, 400, { ok: false, error: 'provider-test 需要 id' })
|
|
1747
|
+
return
|
|
1748
|
+
}
|
|
1749
|
+
testExtraProvider(body.id)
|
|
1750
|
+
.then((r) => sendJSON(response, 200, { ok: true, available: true, detail: `目录 ${r.modelCount} 个模型` }))
|
|
1751
|
+
.catch((err) => sendJSON(response, 200, { ok: true, available: false, error: err.message }))
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
// CodeBuddy = 目录面 GET /v3/config(零额度消耗);Qoder = 签名目录
|
|
1755
|
+
// GET(catalog 面 /algo,不是聊天面 prepareInferRequest)。Trae 不在本批。
|
|
1756
|
+
if (body?.action === 'credential-test') {
|
|
1757
|
+
const ch = body?.channel
|
|
1758
|
+
if (ch === 'codebuddy') {
|
|
1759
|
+
provider.catalog.fetchModelCatalog(resolveNow)
|
|
1760
|
+
.then((c) => sendJSON(response, 200, { ok: true, available: true, detail: `目录 ${c.models.length} 个模型` }))
|
|
1761
|
+
.catch((err) => sendJSON(response, 200, { ok: true, available: false, error: err.message }))
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
if (ch === 'qoder') {
|
|
1765
|
+
qoderProvider.syncCatalog()
|
|
1766
|
+
.then((r) => sendJSON(response, 200, r.ok
|
|
1767
|
+
? { ok: true, available: true, detail: `目录 ${r.count} 个模型` }
|
|
1768
|
+
: { ok: true, available: false, error: r.error }))
|
|
1769
|
+
.catch((err) => sendJSON(response, 200, { ok: true, available: false, error: err.message }))
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
sendJSON(response, 400, { ok: false, error: 'credential-test 需要 channel: codebuddy|qoder' })
|
|
1773
|
+
return
|
|
1774
|
+
}
|
|
1775
|
+
// G7:本机登录态检测。扫描只读、findings 不含 secret;
|
|
1776
|
+
// 导入是用户确认后的显式动作(设置卡"一键导入"按钮)。
|
|
1777
|
+
if (body?.action === 'credential-scan') {
|
|
1778
|
+
sendJSON(response, 200, { ok: true, findings: scanLocalCredentials() })
|
|
1779
|
+
return
|
|
1780
|
+
}
|
|
1781
|
+
if (body?.action === 'credential-import') {
|
|
1782
|
+
let spec
|
|
1783
|
+
try {
|
|
1784
|
+
spec = readImportCredential(scanLocalCredentials(), body?.source)
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
sendJSON(response, 400, { ok: false, error: err.message })
|
|
1787
|
+
return
|
|
1788
|
+
}
|
|
1789
|
+
// 命中同名 preset(同 id 同 baseURL)走 preset 通道——拿到
|
|
1790
|
+
// fallbackModels 等先验;否则按自定义上游严格校验(必须有 /models)。
|
|
1791
|
+
const presetHit = PROVIDER_PRESETS.find((p) => p.id === spec.id && p.baseURL === spec.baseURL)
|
|
1792
|
+
addExtraProvider(presetHit
|
|
1793
|
+
? { preset: presetHit.id, apiKey: spec.apiKey }
|
|
1794
|
+
: { id: spec.id, baseURL: spec.baseURL, displayName: spec.displayName, apiKey: spec.apiKey })
|
|
1795
|
+
.then((r) => sendJSON(response, 200, { ok: true, added: r, providers: extraProvidersView(), findings: scanLocalCredentials() }))
|
|
1796
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
if (body?.action === 'oauth-logout') {
|
|
1800
|
+
provider.oauth.logout()
|
|
1801
|
+
sendJSON(response, 200, { ok: true, oauth: provider.oauth.oauthStatus() })
|
|
1802
|
+
return
|
|
1803
|
+
}
|
|
1804
|
+
// ---- TraeWork CN 通道(v0.8.x)----
|
|
1805
|
+
if (body?.action === 'trae-oauth-start') {
|
|
1806
|
+
traeProvider.oauth.startOAuth(resolveNow())
|
|
1807
|
+
.then((r) => sendJSON(response, 200, { ok: true, authUrl: r.authUrl }))
|
|
1808
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1809
|
+
return
|
|
1810
|
+
}
|
|
1811
|
+
if (body?.action === 'trae-oauth-status') {
|
|
1812
|
+
sendJSON(response, 200, { ok: true, trae: traeProvider.credentialView() })
|
|
1813
|
+
return
|
|
1814
|
+
}
|
|
1815
|
+
if (body?.action === 'trae-oauth-logout') {
|
|
1816
|
+
traeProvider.oauth.logout()
|
|
1817
|
+
sendJSON(response, 200, { ok: true, trae: traeProvider.credentialView() })
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
if (body?.action === 'trae-model-sync') {
|
|
1821
|
+
// 安全审计 [18] 纵深防御:dbPath 是读文件的调用方输入——必须
|
|
1822
|
+
// 绝对路径、规范化后无 .. 段、扩展名限 .db/.vscdb。不存在的
|
|
1823
|
+
// 路径不在此预检,交给既有同步错误路径(502 + 原因)。
|
|
1824
|
+
const dbPath = body?.dbPath
|
|
1825
|
+
if (dbPath !== undefined && !isSafeStateDbPath(dbPath)) {
|
|
1826
|
+
throw new Error('dbPath 必须是绝对路径、不含 .. 段且以 .db/.vscdb 结尾')
|
|
1827
|
+
}
|
|
1828
|
+
traeProvider.syncCatalog(typeof dbPath === 'string' ? { dbPath } : {})
|
|
1829
|
+
.then((r) => {
|
|
1830
|
+
syncTraeModelsToDshSettings()
|
|
1831
|
+
sendJSON(response, 200, { ok: r.ok, sync: r, trae: settingsView(resolveNow).trae })
|
|
1832
|
+
})
|
|
1833
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1834
|
+
return
|
|
1835
|
+
}
|
|
1836
|
+
if (body?.action === 'trae-model-list') {
|
|
1837
|
+
sendJSON(response, 200, {
|
|
1838
|
+
ok: true,
|
|
1839
|
+
view: traeProvider.catalogView(),
|
|
1840
|
+
disabled: Object.keys(readTraeModelState().disabled),
|
|
1841
|
+
})
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
// P1-2:跨通道余额只读快照(上游只读、零写入;60s memoize 在 provider 内)。
|
|
1845
|
+
if (body?.action === 'trae-quota') {
|
|
1846
|
+
traeProvider.quota.snapshot()
|
|
1847
|
+
.then((quota) => sendJSON(response, 200, { ok: true, quota }))
|
|
1848
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1849
|
+
return
|
|
1850
|
+
}
|
|
1851
|
+
// ---- Qoder CN 通道 ----
|
|
1852
|
+
if (body?.action === 'qoder-oauth-start') {
|
|
1853
|
+
qoderProvider.oauth.startOAuth(resolveNow())
|
|
1854
|
+
.then((r) => sendJSON(response, 200, { ok: true, authUrl: r.authUrl }))
|
|
1855
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1856
|
+
return
|
|
1857
|
+
}
|
|
1858
|
+
if (body?.action === 'qoder-oauth-status') {
|
|
1859
|
+
sendJSON(response, 200, { ok: true, qoder: qoderProvider.credentialView() })
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
if (body?.action === 'qoder-oauth-logout') {
|
|
1863
|
+
qoderProvider.oauth.logout()
|
|
1864
|
+
sendJSON(response, 200, { ok: true, qoder: qoderProvider.credentialView() })
|
|
1865
|
+
return
|
|
1866
|
+
}
|
|
1867
|
+
if (body?.action === 'qoder-model-sync') {
|
|
1868
|
+
qoderProvider.syncCatalog()
|
|
1869
|
+
.then((r) => {
|
|
1870
|
+
if (r.ok || qoderProvider.catalogView()) syncQoderModelsToDshSettings()
|
|
1871
|
+
sendJSON(response, 200, { ok: r.ok, sync: r, qoder: settingsView(resolveNow).qoder })
|
|
1872
|
+
})
|
|
1873
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1874
|
+
return
|
|
1875
|
+
}
|
|
1876
|
+
if (body?.action === 'qoder-model-list') {
|
|
1877
|
+
sendJSON(response, 200, {
|
|
1878
|
+
ok: true,
|
|
1879
|
+
view: qoderProvider.catalogView(),
|
|
1880
|
+
disabled: Object.keys(readQoderModelState().disabled),
|
|
1881
|
+
})
|
|
1882
|
+
return
|
|
1883
|
+
}
|
|
1884
|
+
// P1-2:同 trae-quota(openapi 明文面裸 Bearer,零写入)。
|
|
1885
|
+
if (body?.action === 'qoder-quota') {
|
|
1886
|
+
qoderProvider.quota.snapshot()
|
|
1887
|
+
.then((quota) => sendJSON(response, 200, { ok: true, quota }))
|
|
1888
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1889
|
+
return
|
|
1890
|
+
}
|
|
1891
|
+
if (body?.action === 'usage') {
|
|
1892
|
+
provider.catalog.quotaSnapshot(resolveNow)
|
|
1893
|
+
.then((quota) => sendJSON(response, 200, {
|
|
1894
|
+
ok: true,
|
|
1895
|
+
usage: meter.view(),
|
|
1896
|
+
bridge: {
|
|
1897
|
+
enabled: resolveNow().bridgeEnabled === true,
|
|
1898
|
+
running: bridgeRuntime.running,
|
|
1899
|
+
port: bridgeRuntime.port,
|
|
1900
|
+
lastError: bridgeRuntime.lastError,
|
|
1901
|
+
},
|
|
1902
|
+
quota,
|
|
1903
|
+
}))
|
|
1904
|
+
.catch((err) => sendJSON(response, 502, { ok: false, error: err.message }))
|
|
1905
|
+
return
|
|
1906
|
+
}
|
|
1907
|
+
// P2-3:区块头「重试监听」。只对已启用通道重跑对应 sync*——禁用分支
|
|
1908
|
+
// 会顺带撤镜像(写宿主层 ⇒ profile 重载,踩坑 #44),一次重试不该写配置。
|
|
1909
|
+
// 响应立即返回:running 由 'listening' 事件异步翻转,客户端走退避补拉自愈。
|
|
1910
|
+
if (body?.action === 'gateway-retry') {
|
|
1911
|
+
const ch = body?.channel
|
|
1912
|
+
if (ch !== 'codebuddy' && ch !== 'trae' && ch !== 'qoder') {
|
|
1913
|
+
throw new Error('gateway-retry 需要 channel: codebuddy|trae|qoder')
|
|
1914
|
+
}
|
|
1915
|
+
sendJSON(response, 200, { ok: true, retried: retryGateway(ch) })
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
const patch = body?.patch
|
|
1919
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
1920
|
+
throw new Error('body must be {patch} or {action}')
|
|
1921
|
+
}
|
|
1922
|
+
const nextUser = { ...readFileLayer() }
|
|
1923
|
+
// Key-list operations run against the stored raw list — the card
|
|
1924
|
+
// only ever sees masked keys, so adds/removes must resolve here.
|
|
1925
|
+
if (patch.apiKeysAdd !== undefined) {
|
|
1926
|
+
const entry = patch.apiKeysAdd
|
|
1927
|
+
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string'
|
|
1928
|
+
|| typeof entry.key !== 'string' || !entry.name.trim() || !entry.key.trim()) {
|
|
1929
|
+
throw new Error('apiKeysAdd 需要 {name, key}')
|
|
1930
|
+
}
|
|
1931
|
+
const name = entry.name.trim()
|
|
1932
|
+
const key = entry.key.trim()
|
|
1933
|
+
const list = Array.isArray(nextUser.apiKeys) ? nextUser.apiKeys : []
|
|
1934
|
+
nextUser.apiKeys = list.filter((k) => k?.name !== name).concat([{ name, key }])
|
|
1935
|
+
if (!nextUser.activeApiKey) nextUser.activeApiKey = name
|
|
1936
|
+
}
|
|
1937
|
+
if (patch.apiKeysRemove !== undefined) {
|
|
1938
|
+
if (typeof patch.apiKeysRemove !== 'string') throw new Error('apiKeysRemove 需要名称字符串')
|
|
1939
|
+
const list = Array.isArray(nextUser.apiKeys) ? nextUser.apiKeys : []
|
|
1940
|
+
nextUser.apiKeys = list.filter((k) => k?.name !== patch.apiKeysRemove)
|
|
1941
|
+
if (nextUser.activeApiKey === patch.apiKeysRemove) delete nextUser.activeApiKey
|
|
1942
|
+
}
|
|
1943
|
+
if (patch.modelSetEnabled !== undefined) {
|
|
1944
|
+
// Runs against modelState inside setModelEnabled; re-read the
|
|
1945
|
+
// layer afterwards so apiKeys edits above are not clobbered.
|
|
1946
|
+
const withKeys = { ...nextUser }
|
|
1947
|
+
await setModelEnabled(patch.modelSetEnabled)
|
|
1948
|
+
const after = readFileLayer()
|
|
1949
|
+
delete withKeys.modelState
|
|
1950
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
1951
|
+
writeFileLayer(after)
|
|
1952
|
+
sendJSON(response, 200, {
|
|
1953
|
+
ok: true,
|
|
1954
|
+
value: settingsView(resolveNow).value,
|
|
1955
|
+
user: maskedUserLayer(after),
|
|
1956
|
+
models: settingsView(resolveNow).models,
|
|
1957
|
+
})
|
|
1958
|
+
applyLive()
|
|
1959
|
+
return
|
|
1960
|
+
}
|
|
1961
|
+
if (patch.modelSetLimits !== undefined) {
|
|
1962
|
+
// G5:同 modelSetEnabled 的层叠纪律——setModelLimits 内部自写
|
|
1963
|
+
// modelState,事后重读层并把本请求里的 apiKeys 改动合回。
|
|
1964
|
+
const withKeys = { ...nextUser }
|
|
1965
|
+
await setModelLimits(patch.modelSetLimits)
|
|
1966
|
+
const after = readFileLayer()
|
|
1967
|
+
delete withKeys.modelState
|
|
1968
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
1969
|
+
writeFileLayer(after)
|
|
1970
|
+
sendJSON(response, 200, {
|
|
1971
|
+
ok: true,
|
|
1972
|
+
value: settingsView(resolveNow).value,
|
|
1973
|
+
user: maskedUserLayer(after),
|
|
1974
|
+
models: settingsView(resolveNow).models,
|
|
1975
|
+
})
|
|
1976
|
+
applyLive()
|
|
1977
|
+
return
|
|
1978
|
+
}
|
|
1979
|
+
if (patch.traeModelSetEnabled !== undefined) {
|
|
1980
|
+
const withKeys = { ...nextUser }
|
|
1981
|
+
await setTraeModelEnabled(patch.traeModelSetEnabled)
|
|
1982
|
+
const after = readFileLayer()
|
|
1983
|
+
delete withKeys.traeModelState
|
|
1984
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
1985
|
+
writeFileLayer(after)
|
|
1986
|
+
sendJSON(response, 200, {
|
|
1987
|
+
ok: true,
|
|
1988
|
+
value: settingsView(resolveNow).value,
|
|
1989
|
+
user: maskedUserLayer(after),
|
|
1990
|
+
trae: settingsView(resolveNow).trae,
|
|
1991
|
+
})
|
|
1992
|
+
applyLive()
|
|
1993
|
+
return
|
|
1994
|
+
}
|
|
1995
|
+
if (patch.traeModelSetPrefs !== undefined) {
|
|
1996
|
+
// 同 traeModelSetEnabled 的层叠纪律:setTraeModelPrefs 内部自写
|
|
1997
|
+
// traeModelPrefs,事后重读层并把本请求里的 apiKeys 改动合回。
|
|
1998
|
+
const withKeys = { ...nextUser }
|
|
1999
|
+
await setTraeModelPrefs(patch.traeModelSetPrefs)
|
|
2000
|
+
const after = readFileLayer()
|
|
2001
|
+
delete withKeys.traeModelPrefs
|
|
2002
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
2003
|
+
writeFileLayer(after)
|
|
2004
|
+
sendJSON(response, 200, {
|
|
2005
|
+
ok: true,
|
|
2006
|
+
value: settingsView(resolveNow).value,
|
|
2007
|
+
user: maskedUserLayer(after),
|
|
2008
|
+
trae: settingsView(resolveNow).trae,
|
|
2009
|
+
})
|
|
2010
|
+
applyLive()
|
|
2011
|
+
return
|
|
2012
|
+
}
|
|
2013
|
+
if (patch.qoderModelSetEnabled !== undefined) {
|
|
2014
|
+
const withKeys = { ...nextUser }
|
|
2015
|
+
await setQoderModelEnabled(patch.qoderModelSetEnabled)
|
|
2016
|
+
const after = readFileLayer()
|
|
2017
|
+
delete withKeys.qoderModelState
|
|
2018
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
2019
|
+
writeFileLayer(after)
|
|
2020
|
+
sendJSON(response, 200, {
|
|
2021
|
+
ok: true,
|
|
2022
|
+
value: settingsView(resolveNow).value,
|
|
2023
|
+
user: maskedUserLayer(after),
|
|
2024
|
+
qoder: settingsView(resolveNow).qoder,
|
|
2025
|
+
})
|
|
2026
|
+
applyLive()
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
if (patch.qoderModelSetPrefs !== undefined) {
|
|
2030
|
+
// 同 qoderModelSetEnabled 的层叠纪律:setQoderModelPrefs 内部自写
|
|
2031
|
+
// qoderModelPrefs,事后重读层并把本请求里的 apiKeys 改动合回。
|
|
2032
|
+
const withKeys = { ...nextUser }
|
|
2033
|
+
await setQoderModelPrefs(patch.qoderModelSetPrefs)
|
|
2034
|
+
const after = readFileLayer()
|
|
2035
|
+
delete withKeys.qoderModelPrefs
|
|
2036
|
+
Object.assign(after, { apiKeys: withKeys.apiKeys, activeApiKey: withKeys.activeApiKey })
|
|
2037
|
+
writeFileLayer(after)
|
|
2038
|
+
sendJSON(response, 200, {
|
|
2039
|
+
ok: true,
|
|
2040
|
+
value: settingsView(resolveNow).value,
|
|
2041
|
+
user: maskedUserLayer(after),
|
|
2042
|
+
qoder: settingsView(resolveNow).qoder,
|
|
2043
|
+
})
|
|
2044
|
+
applyLive()
|
|
2045
|
+
return
|
|
2046
|
+
}
|
|
2047
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2048
|
+
if (key === 'apiKeysAdd' || key === 'apiKeysRemove') continue
|
|
2049
|
+
// Whitelist by SETTINGS_FIELDS, not Config({}) keys: fields
|
|
2050
|
+
// without a schema default (activeApiKey) vanish from a bare
|
|
2051
|
+
// Config({}) resolution and were silently dropped here.
|
|
2052
|
+
if (!SETTINGS_FIELDS.some((f) => f.key === key)) continue
|
|
2053
|
+
if (value === null) delete nextUser[key]
|
|
2054
|
+
else nextUser[key] = value
|
|
2055
|
+
}
|
|
2056
|
+
// Validate through the schema before persisting.
|
|
2057
|
+
const candidate = Config({ ...entryConfig, ...nextUser })
|
|
2058
|
+
validateBaseURL(candidate.baseURL)
|
|
2059
|
+
for (const f of ['traeAuthBaseURL', 'traeChatBaseURL', 'traeLoginHost', 'qoderLoginHost', 'qoderOpenapiBaseURL', 'qoderInferBaseURL']) {
|
|
2060
|
+
validateBaseURL(candidate[f])
|
|
2061
|
+
}
|
|
2062
|
+
const resolved = candidate
|
|
2063
|
+
if (resolved.activeApiKey && !resolved.apiKeys.some((k) => k.name === resolved.activeApiKey)) {
|
|
2064
|
+
throw new Error('activeApiKey 不在 apiKeys 列表中')
|
|
2065
|
+
}
|
|
2066
|
+
writeFileLayer(nextUser)
|
|
2067
|
+
applyLive()
|
|
2068
|
+
sendJSON(response, 200, { ok: true, value: settingsView(resolveNow).value, user: maskedUserLayer(nextUser) })
|
|
2069
|
+
} catch (err) {
|
|
2070
|
+
sendJSON(response, 400, { ok: false, error: err.message })
|
|
2071
|
+
}
|
|
2072
|
+
})
|
|
2073
|
+
},
|
|
2074
|
+
})
|
|
2075
|
+
})
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
export function apply(ctx, config = {}) {
|
|
2079
|
+
// entry < file; schema defaults fill the rest. Live-resolved per read.
|
|
2080
|
+
// 端口分流:三端口有效值由 resolveBridgePorts 收口(运行时宿主信号现读,
|
|
2081
|
+
// 不缓存),文件层显式端口值优先于分流默认。
|
|
2082
|
+
const resolveNow = () => {
|
|
2083
|
+
const file = readFileLayer()
|
|
2084
|
+
return Config({ ...config, ...file, ...resolveBridgePorts(config, file, currentProfileDirName()) })
|
|
2085
|
+
}
|
|
2086
|
+
effectiveSettingsFn = resolveNow
|
|
2087
|
+
|
|
2088
|
+
// Web providers ride the searchEnabled switch: disposing unregisters from
|
|
2089
|
+
// ctx.web, re-enabling registers fresh instances. While disabled the
|
|
2090
|
+
// seam's pinned id (patch: web.searchProvider=codebuddy) reports
|
|
2091
|
+
// CONFIGURED_MISSING — intended: the switch means "this feature is off".
|
|
2092
|
+
let disposeSearch = null
|
|
2093
|
+
let disposeFetch = null
|
|
2094
|
+
const syncProviders = () => {
|
|
2095
|
+
const enabled = resolveNow().searchEnabled === true
|
|
2096
|
+
if (!enabled) {
|
|
2097
|
+
if (disposeSearch) { disposeSearch(); disposeSearch = null }
|
|
2098
|
+
if (disposeFetch) { disposeFetch(); disposeFetch = null }
|
|
2099
|
+
return
|
|
2100
|
+
}
|
|
2101
|
+
if (disposeSearch && disposeFetch) return
|
|
2102
|
+
disposeSearch = ctx.web.registerSearchProvider(provider.makeSearchProvider(resolveNow))
|
|
2103
|
+
disposeFetch = ctx.web.registerFetchProvider(provider.makeFetchProvider(resolveNow))
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Image generation tool on the tools seam: registered while
|
|
2107
|
+
// imageGenEnabled, disposed when switched off. `tools` resolves lazily via
|
|
2108
|
+
// ctx.inject (same pattern as the webServer route) so the plugin still
|
|
2109
|
+
// loads in compositions without the tools service.
|
|
2110
|
+
let toolsCtx = null
|
|
2111
|
+
let disposeImageTool = null
|
|
2112
|
+
const syncImageTool = () => {
|
|
2113
|
+
if (!toolsCtx) return
|
|
2114
|
+
const enabled = resolveNow().imageGenEnabled === true
|
|
2115
|
+
if (!enabled) {
|
|
2116
|
+
if (disposeImageTool) { disposeImageTool(); disposeImageTool = null }
|
|
2117
|
+
return
|
|
2118
|
+
}
|
|
2119
|
+
if (disposeImageTool) return
|
|
2120
|
+
try {
|
|
2121
|
+
disposeImageTool = toolsCtx.tools.register(provider.makeImageGenTool(resolveNow))
|
|
2122
|
+
process.stderr.write('[dsh-tap] image_generate tool registered\n')
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
disposeImageTool = null
|
|
2125
|
+
process.stderr.write(`[dsh-tap] image_generate register failed: ${err?.message ?? err}\n`)
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
ctx.inject(['tools'], (tctx) => { toolsCtx = tctx; syncImageTool() })
|
|
2129
|
+
|
|
2130
|
+
// 宿主配置层挂载(dsh 0.1.7 起 settings 服务换了形态,见 host-config.js):
|
|
2131
|
+
// - 0.1.7+:configure({auto:false}) 声明"本插件自带设置卡页面",宿主不再按
|
|
2132
|
+
// Config schema 自动生成一个页面;模型镜像/路由存在性经 mutate 落 profile patch。
|
|
2133
|
+
// - ≤0.1.6:register('dsh-tap', Config) 声明命名空间——旧 `settings.plugin.item`
|
|
2134
|
+
// 卡片派发按 Host 服务的命名空间清单走(api-proxy `settings.describe`),
|
|
2135
|
+
// 不注册卡片就不出现(踩坑 #30)。
|
|
2136
|
+
// 两条路由都由 host-config 按能力自选;读写仍走我们自己的 webServer 路由 +
|
|
2137
|
+
// 文件层,seam 只承担"宿主配置层"这一段。
|
|
2138
|
+
hostConfig.attach(ctx)
|
|
2139
|
+
|
|
2140
|
+
// Bridge lifecycle: running state keyed by the port it listens on.
|
|
2141
|
+
// bridgeRuntime mirrors reality for the settings view (listen is async —
|
|
2142
|
+
// `running` flips on the server's 'listening' event, failures land in
|
|
2143
|
+
// lastError via its 'error' event instead of crashing the process).
|
|
2144
|
+
//
|
|
2145
|
+
// One createBridge instance per apply(): it captures this apply's
|
|
2146
|
+
// resolveNow (the entry config differs per apply); bridgeRuntime stays
|
|
2147
|
+
// module-shared. The forensic env var NAMES stay here in the composition
|
|
2148
|
+
// root — core/bridge.js only receives the path getters.
|
|
2149
|
+
const bridge = createBridge({
|
|
2150
|
+
settings: resolveNow,
|
|
2151
|
+
provider,
|
|
2152
|
+
withCredentials: (attempt) => withKeyRotation(resolveNow, attempt),
|
|
2153
|
+
meter,
|
|
2154
|
+
forensics: {
|
|
2155
|
+
logPath: () => process.env.CODEBUDDY_BRIDGE_LOG,
|
|
2156
|
+
dumpDir: () => process.env.CODEBUDDY_BRIDGE_DUMP,
|
|
2157
|
+
},
|
|
2158
|
+
runtime: bridgeRuntime,
|
|
2159
|
+
})
|
|
2160
|
+
let stopBridge = null
|
|
2161
|
+
let runningPort = null
|
|
2162
|
+
const syncBridge = () => {
|
|
2163
|
+
const s = resolveNow()
|
|
2164
|
+
if (s.bridgeEnabled !== true) {
|
|
2165
|
+
if (stopBridge) {
|
|
2166
|
+
stopBridge()
|
|
2167
|
+
stopBridge = null
|
|
2168
|
+
runningPort = null
|
|
2169
|
+
}
|
|
2170
|
+
bridgeRuntime.running = false
|
|
2171
|
+
bridgeRuntime.port = null
|
|
2172
|
+
bridgeRuntime.lastError = null
|
|
2173
|
+
return
|
|
2174
|
+
}
|
|
2175
|
+
// A wedged listener (EADDRINUSE race with a previous stop, or another
|
|
2176
|
+
// instance holding the port) leaves lastError set — do NOT early-return
|
|
2177
|
+
// then, or the bridge stays down until restart; fall through and retry.
|
|
2178
|
+
if (stopBridge && runningPort === s.bridgePort && !bridgeRuntime.lastError) return
|
|
2179
|
+
if (stopBridge) stopBridge()
|
|
2180
|
+
runningPort = s.bridgePort
|
|
2181
|
+
bridgeRuntime.running = false
|
|
2182
|
+
bridgeRuntime.port = s.bridgePort
|
|
2183
|
+
bridgeRuntime.lastError = null
|
|
2184
|
+
stopBridge = bridge.listen(runningPort)
|
|
2185
|
+
}
|
|
2186
|
+
const applyLive = () => {
|
|
2187
|
+
syncProviders()
|
|
2188
|
+
syncImageTool()
|
|
2189
|
+
syncBridge()
|
|
2190
|
+
syncTraeBridge()
|
|
2191
|
+
syncQoderBridge()
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// TraeWork CN 通道生命周期:迟绑定本代 settings;启用时起翻译网关、
|
|
2195
|
+
// 尝试目录同步(静默失败——state.vscdb 不在本机时 Trae 分区只是空转);
|
|
2196
|
+
// 禁用时停网关并按纯净态纪律撤掉 providers.trae.models 镜像。
|
|
2197
|
+
traeSettingsFn = resolveNow
|
|
2198
|
+
let stopTraeBridge = null
|
|
2199
|
+
let traeRunningPort = null
|
|
2200
|
+
const syncTraeBridge = () => {
|
|
2201
|
+
const s = resolveNow()
|
|
2202
|
+
if (s.traeEnabled !== true) {
|
|
2203
|
+
if (stopTraeBridge) {
|
|
2204
|
+
stopTraeBridge()
|
|
2205
|
+
stopTraeBridge = null
|
|
2206
|
+
traeRunningPort = null
|
|
2207
|
+
}
|
|
2208
|
+
traeRuntime.running = false
|
|
2209
|
+
traeRuntime.port = null
|
|
2210
|
+
traeRuntime.lastError = null
|
|
2211
|
+
syncTraeModelsToDshSettings()
|
|
2212
|
+
return
|
|
2213
|
+
}
|
|
2214
|
+
// Same wedge self-heal as syncBridge above: lastError set = not actually
|
|
2215
|
+
// listening, retry instead of early-returning.
|
|
2216
|
+
if (stopTraeBridge && traeRunningPort === s.traeBridgePort && !traeRuntime.lastError) return
|
|
2217
|
+
if (stopTraeBridge) stopTraeBridge()
|
|
2218
|
+
traeRunningPort = s.traeBridgePort
|
|
2219
|
+
traeRuntime.running = false
|
|
2220
|
+
traeRuntime.port = s.traeBridgePort
|
|
2221
|
+
traeRuntime.lastError = null
|
|
2222
|
+
stopTraeBridge = traeProvider.gateway.listen(traeRunningPort)
|
|
2223
|
+
traeProvider.syncCatalog().then((r) => {
|
|
2224
|
+
if (r.ok) {
|
|
2225
|
+
syncTraeModelsToDshSettings()
|
|
2226
|
+
process.stderr.write(`[dsh-tap] trae catalog synced (${r.count} models)\n`)
|
|
2227
|
+
} else if (traeProvider.catalogView()) {
|
|
2228
|
+
syncTraeModelsToDshSettings()
|
|
2229
|
+
}
|
|
2230
|
+
})
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// Qoder CN 通道生命周期:启用时起翻译网关并同步网关目录(失败静默——
|
|
2234
|
+
// 未登录/网络故障时 Qoder 分区只是空转);禁用时停网关并撤 providers.qoder
|
|
2235
|
+
// 镜像(路由存在性管理)。wedge 自愈同 syncBridge:lastError 置位不早退。
|
|
2236
|
+
qoderSettingsFn = resolveNow
|
|
2237
|
+
let stopQoderBridge = null
|
|
2238
|
+
let qoderRunningPort = null
|
|
2239
|
+
const syncQoderBridge = () => {
|
|
2240
|
+
const s = resolveNow()
|
|
2241
|
+
if (s.qoderEnabled !== true) {
|
|
2242
|
+
if (stopQoderBridge) {
|
|
2243
|
+
stopQoderBridge()
|
|
2244
|
+
stopQoderBridge = null
|
|
2245
|
+
qoderRunningPort = null
|
|
2246
|
+
}
|
|
2247
|
+
qoderRuntime.running = false
|
|
2248
|
+
qoderRuntime.port = null
|
|
2249
|
+
qoderRuntime.lastError = null
|
|
2250
|
+
syncQoderModelsToDshSettings()
|
|
2251
|
+
return
|
|
2252
|
+
}
|
|
2253
|
+
if (stopQoderBridge && qoderRunningPort === s.qoderBridgePort && !qoderRuntime.lastError) return
|
|
2254
|
+
if (stopQoderBridge) stopQoderBridge()
|
|
2255
|
+
qoderRunningPort = s.qoderBridgePort
|
|
2256
|
+
qoderRuntime.running = false
|
|
2257
|
+
qoderRuntime.port = s.qoderBridgePort
|
|
2258
|
+
qoderRuntime.lastError = null
|
|
2259
|
+
stopQoderBridge = qoderProvider.gateway.listen(qoderRunningPort)
|
|
2260
|
+
qoderProvider.syncCatalog().then((r) => {
|
|
2261
|
+
if (r.ok) {
|
|
2262
|
+
syncQoderModelsToDshSettings()
|
|
2263
|
+
process.stderr.write(`[dsh-tap] qoder catalog synced (${r.count} models)\n`)
|
|
2264
|
+
} else if (qoderProvider.catalogView()) {
|
|
2265
|
+
syncQoderModelsToDshSettings()
|
|
2266
|
+
}
|
|
2267
|
+
}).catch(() => {})
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// P2-3:设置卡「重试监听」动作的服务端落点——enabled 限定的定点重跑。
|
|
2271
|
+
// 三个 sync* 的 wedge 自愈路径(lastError 置位 ⇒ 不早退、重新 listen)就是重试
|
|
2272
|
+
// 语义本身;未启用通道返回 false 并跳过——sync* 的禁用分支会触发镜像撤铺
|
|
2273
|
+
// (写宿主层 ⇒ profile 重载,踩坑 #44),一次「重试」不该顺带写配置。
|
|
2274
|
+
const retryGateway = (channel) => {
|
|
2275
|
+
const s = resolveNow()
|
|
2276
|
+
if (channel === 'codebuddy' && s.bridgeEnabled === true) { syncBridge(); return true }
|
|
2277
|
+
if (channel === 'trae' && s.traeEnabled === true) { syncTraeBridge(); return true }
|
|
2278
|
+
if (channel === 'qoder' && s.qoderEnabled === true) { syncQoderBridge(); return true }
|
|
2279
|
+
return false
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
applyLive()
|
|
2283
|
+
registerSettingsRoute(ctx, config, resolveNow, applyLive, retryGateway)
|
|
2284
|
+
// 镜像写入统一交给下面 syncModelsFromGateway 的收尾(成功/失败两条路都会写),
|
|
2285
|
+
// **此处不再先铺一次**:那一刻 dynamicCatalog 还是 null,有效清单只含静态基线,
|
|
2286
|
+
// 会把宿主里上一次同步成功的完整清单打回残缺态(实测 28 → 16);且 dsh 0.1.7
|
|
2287
|
+
// 起每次写入都会触发 profile patch 重载 → 插件 re-apply → boot 再走一遍,
|
|
2288
|
+
// 两次写入互相覆盖,最终停在残缺的那份(踩坑 #43 同批)。
|
|
2289
|
+
// G4:启动时自动从 /v3/config 同步模型清单;失败无感回落静态清单
|
|
2290
|
+
// (syncModelsFromGateway 内部已兜住一切异常,这里只记一行日志)。
|
|
2291
|
+
syncModelsFromGateway(resolveNow).then((r) => {
|
|
2292
|
+
process.stderr.write(r.ok
|
|
2293
|
+
? `[dsh-tap] model catalog synced from gateway (${r.count} models)\n`
|
|
2294
|
+
: `[dsh-tap] model catalog sync failed (${r.error}) — ${r.kept ? 'keeping last synced list' : 'static fallback'}\n`)
|
|
2295
|
+
})
|
|
2296
|
+
ctx.on('dispose', () => {
|
|
2297
|
+
if (stopBridge) stopBridge()
|
|
2298
|
+
if (stopTraeBridge) stopTraeBridge()
|
|
2299
|
+
if (stopQoderBridge) stopQoderBridge()
|
|
2300
|
+
if (disposeSearch) disposeSearch()
|
|
2301
|
+
if (disposeFetch) disposeFetch()
|
|
2302
|
+
if (disposeImageTool) disposeImageTool()
|
|
2303
|
+
hostConfig.dispose()
|
|
2304
|
+
meter.dispose()
|
|
2305
|
+
})
|
|
2306
|
+
}
|