@wallbreakerno4/opencode-commandcode 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +54 -0
  3. package/dist/disguise/backoff.d.ts +33 -0
  4. package/dist/disguise/backoff.js +43 -0
  5. package/dist/disguise/config-block.d.ts +71 -0
  6. package/dist/disguise/config-block.js +166 -0
  7. package/dist/disguise/config-freeze.d.ts +27 -0
  8. package/dist/disguise/config-freeze.js +87 -0
  9. package/dist/disguise/fingerprint.d.ts +44 -0
  10. package/dist/disguise/fingerprint.js +127 -0
  11. package/dist/disguise/hash.d.ts +8 -0
  12. package/dist/disguise/hash.js +13 -0
  13. package/dist/disguise/headers.d.ts +25 -0
  14. package/dist/disguise/headers.js +25 -0
  15. package/dist/disguise/logger.d.ts +14 -0
  16. package/dist/disguise/logger.js +18 -0
  17. package/dist/disguise/preflight.d.ts +74 -0
  18. package/dist/disguise/preflight.js +139 -0
  19. package/dist/disguise/redact.d.ts +12 -0
  20. package/dist/disguise/redact.js +17 -0
  21. package/dist/disguise/session.d.ts +11 -0
  22. package/dist/disguise/session.js +24 -0
  23. package/dist/disguise/slug.d.ts +12 -0
  24. package/dist/disguise/slug.js +47 -0
  25. package/dist/disguise/state.d.ts +62 -0
  26. package/dist/disguise/state.js +157 -0
  27. package/dist/disguise/traceparent.d.ts +7 -0
  28. package/dist/disguise/traceparent.js +10 -0
  29. package/dist/disguise/version-cache.d.ts +27 -0
  30. package/dist/disguise/version-cache.js +69 -0
  31. package/dist/disguise/version-runtime.d.ts +42 -0
  32. package/dist/disguise/version-runtime.js +137 -0
  33. package/dist/disguise/version.d.ts +21 -0
  34. package/dist/disguise/version.js +17 -0
  35. package/dist/host/constants.d.ts +14 -0
  36. package/dist/host/constants.js +14 -0
  37. package/dist/host/v1.d.ts +85 -0
  38. package/dist/host/v1.js +118 -0
  39. package/dist/host/v2.d.ts +33 -0
  40. package/dist/host/v2.js +110 -0
  41. package/dist/index.d.ts +47 -0
  42. package/dist/index.js +33 -0
  43. package/dist/models/api.d.ts +23 -0
  44. package/dist/models/api.js +44 -0
  45. package/dist/models/artifact.d.ts +45 -0
  46. package/dist/models/artifact.js +108 -0
  47. package/dist/models/cascade.d.ts +47 -0
  48. package/dist/models/cascade.js +40 -0
  49. package/dist/models/mapping.d.ts +66 -0
  50. package/dist/models/mapping.js +47 -0
  51. package/dist/models/pipeline.d.ts +89 -0
  52. package/dist/models/pipeline.js +331 -0
  53. package/dist/models/signature.d.ts +20 -0
  54. package/dist/models/signature.js +29 -0
  55. package/dist/models/snapshot.d.ts +15 -0
  56. package/dist/models/snapshot.js +16 -0
  57. package/dist/models/snapshot.json +535 -0
  58. package/dist/models/urls.d.ts +39 -0
  59. package/dist/models/urls.js +96 -0
  60. package/dist/protocol/envelope.d.ts +118 -0
  61. package/dist/protocol/envelope.js +331 -0
  62. package/dist/protocol/errors.d.ts +97 -0
  63. package/dist/protocol/errors.js +281 -0
  64. package/dist/protocol/generate.d.ts +53 -0
  65. package/dist/protocol/generate.js +173 -0
  66. package/dist/protocol/images.d.ts +45 -0
  67. package/dist/protocol/images.js +115 -0
  68. package/dist/protocol/json.d.ts +11 -0
  69. package/dist/protocol/json.js +8 -0
  70. package/dist/protocol/ndjson.d.ts +41 -0
  71. package/dist/protocol/ndjson.js +321 -0
  72. package/dist/protocol/watchdog.d.ts +37 -0
  73. package/dist/protocol/watchdog.js +61 -0
  74. package/dist/provider/model.d.ts +95 -0
  75. package/dist/provider/model.js +323 -0
  76. package/package.json +46 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 运行时三层级联合并(契约:docs/spec/model-pipeline.md §3)。
3
+ *
4
+ * 原则:**每字段单一天窗**,权威 = 离网关事实最近的来源,包内快照永远最后(§3 字段表):
5
+ * - 发现(暴露哪些 id):API ∩ 产物层——产物拉取失败时快照顶替产物角色,交集规则
6
+ * 不变(「快照兜发现时同规则」);API 整体失败时发现退化为产物层 id 清单。
7
+ * - `context` / `name`:API 权威(`context_length` 是网关实际执行值;name 同为
8
+ * API 字段),字段级缺失时逐级回落产物 → 快照。
9
+ * - `efforts` / variants:产物层独供,无兜底链。
10
+ * - `reasoning` / `inputModalities` / `maxOutput`:产物层权威;「快照」兜底由产物
11
+ * 解析的整体弃用语义结构性保证——坏产物进不到本模块,只可能整层换成快照。
12
+ * - `tool_call`:常量 `true`,在宿主消费映射(mapping.ts)落键。
13
+ *
14
+ * 纯函数、无 I/O:拉取、TTL、变更签名与降级重试归模型管线 II(#34);本模块只把
15
+ * 已解析的层合并成宿主消费映射的直接输入,并暴露产物层来源供降级日志注明退到了哪层。
16
+ */
17
+ export function mergeModelLayers(layers) {
18
+ const product = layers.artifact ?? layers.snapshot;
19
+ const productLayer = layers.artifact !== undefined ? "artifact" : "snapshot";
20
+ const apiById = layers.api === undefined ? undefined : new Map(layers.api.map((entry) => [entry.id, entry]));
21
+ const models = [];
22
+ // 顺序跟随产物层(官方目录序):迭代产物条目并以 API 集合过滤,即「API 有产物无 →
23
+ // 隐藏(已下架)」;「API 有产物无」的条目根本不进入本循环——可能是非 Go 档新模型,
24
+ // 防止 403 漏到用户面前(暴露规则,保守)。
25
+ for (const productModel of product.models) {
26
+ const apiEntry = apiById?.get(productModel.id);
27
+ if (apiById !== undefined && apiEntry === undefined)
28
+ continue;
29
+ models.push({
30
+ id: productModel.id,
31
+ context: apiEntry?.contextLength ?? productModel.context,
32
+ name: apiEntry?.name ?? productModel.name,
33
+ reasoning: productModel.reasoning,
34
+ inputModalities: productModel.inputModalities,
35
+ ...(productModel.efforts !== undefined ? { efforts: productModel.efforts } : {}),
36
+ maxOutput: productModel.maxOutput,
37
+ });
38
+ }
39
+ return { models, productLayer };
40
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 宿主消费映射(契约:docs/spec/model-pipeline.md §3.1/§3.2;字段形状细节:
3
+ * docs/research/opencode-plugin-provider.md §3/§4)——把级联解析出的 ResolvedModel
4
+ * 翻译成 v1 config models entry 与 v2 `Model.Info` 字段,由 v1/v2 glue 票(#36/#37)
5
+ * 注入宿主。
6
+ *
7
+ * - v1:`{id, name, tool_call: true, reasoning, attachment, modalities,
8
+ * limit: {context, output}, variants}`;variants 为 map(档位 → 请求覆盖),值
9
+ * `{reasoningEffort}` 选中后由宿主合并进 model options,最终经
10
+ * `providerOptions.reasoningEffort` 抵达协议核心(#11 实测宿主行为,brent 参考
11
+ * 实现同款);`attachment` = inputModalities 含 image(可贴图开关)。
12
+ * - v2:`capabilities: {tools, input, output}` + `limit` + `variants` 数组
13
+ * `[{id, settings: {reasoningEffort}}]`;`modelID` = wire id 原样(可含 `/`)。
14
+ * - 无档位 → 空 map / 空数组:不造任何变体——base 模型无档可选,即不会发送
15
+ * `reasoning_effort`,交网关/模型自决(§3.1)。
16
+ * - 档位零过滤:上游给什么透传什么(含将来可能出现的 none/minimal),不发明白名单。
17
+ * - 价格字段 `cost`:schema v1 明确砍掉,两宿主均不写。
18
+ */
19
+ import type { ResolvedModel } from "./cascade.js";
20
+ /** v1 variant 值:仅承载档位设置,形状经 #11 实测(宿主按 variant 合并 options) */
21
+ export interface V1Variant {
22
+ readonly reasoningEffort: string;
23
+ }
24
+ export interface V1ModelEntry {
25
+ readonly id: string;
26
+ readonly name: string;
27
+ readonly tool_call: true;
28
+ readonly reasoning: boolean;
29
+ readonly attachment: boolean;
30
+ readonly modalities: {
31
+ readonly input: readonly string[];
32
+ readonly output: readonly string[];
33
+ };
34
+ readonly limit: {
35
+ readonly context: number;
36
+ readonly output: number;
37
+ };
38
+ readonly variants: Readonly<Record<string, V1Variant>>;
39
+ }
40
+ /** v2 variant:`settings` 包相关,`reasoningEffort` 是 aisdk 系运行时包的档位设置键 */
41
+ export interface V2Variant {
42
+ readonly id: string;
43
+ readonly settings: {
44
+ readonly reasoningEffort: string;
45
+ };
46
+ }
47
+ /** v2 `Model.Info` 中由本插件填充的字段;目录键(id)与上游 id(modelID)同为 wire id */
48
+ export interface V2ModelFields {
49
+ readonly id: string;
50
+ readonly modelID: string;
51
+ readonly name: string;
52
+ readonly capabilities: {
53
+ readonly tools: true;
54
+ readonly input: readonly string[];
55
+ readonly output: readonly string[];
56
+ };
57
+ readonly limit: {
58
+ readonly context: number;
59
+ readonly output: number;
60
+ };
61
+ readonly variants: readonly V2Variant[];
62
+ }
63
+ export declare function toV1ModelEntry(model: ResolvedModel): V1ModelEntry;
64
+ /** v1 config 的 models 块:以 wire id 为键(可含 `/`,原样保留) */
65
+ export declare function toV1ModelMap(models: readonly ResolvedModel[]): Record<string, V1ModelEntry>;
66
+ export declare function toV2ModelFields(model: ResolvedModel): V2ModelFields;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 宿主消费映射(契约:docs/spec/model-pipeline.md §3.1/§3.2;字段形状细节:
3
+ * docs/research/opencode-plugin-provider.md §3/§4)——把级联解析出的 ResolvedModel
4
+ * 翻译成 v1 config models entry 与 v2 `Model.Info` 字段,由 v1/v2 glue 票(#36/#37)
5
+ * 注入宿主。
6
+ *
7
+ * - v1:`{id, name, tool_call: true, reasoning, attachment, modalities,
8
+ * limit: {context, output}, variants}`;variants 为 map(档位 → 请求覆盖),值
9
+ * `{reasoningEffort}` 选中后由宿主合并进 model options,最终经
10
+ * `providerOptions.reasoningEffort` 抵达协议核心(#11 实测宿主行为,brent 参考
11
+ * 实现同款);`attachment` = inputModalities 含 image(可贴图开关)。
12
+ * - v2:`capabilities: {tools, input, output}` + `limit` + `variants` 数组
13
+ * `[{id, settings: {reasoningEffort}}]`;`modelID` = wire id 原样(可含 `/`)。
14
+ * - 无档位 → 空 map / 空数组:不造任何变体——base 模型无档可选,即不会发送
15
+ * `reasoning_effort`,交网关/模型自决(§3.1)。
16
+ * - 档位零过滤:上游给什么透传什么(含将来可能出现的 none/minimal),不发明白名单。
17
+ * - 价格字段 `cost`:schema v1 明确砍掉,两宿主均不写。
18
+ */
19
+ export function toV1ModelEntry(model) {
20
+ const variants = {};
21
+ for (const effort of model.efforts ?? [])
22
+ variants[effort] = { reasoningEffort: effort };
23
+ return {
24
+ id: model.id,
25
+ name: model.name,
26
+ tool_call: true,
27
+ reasoning: model.reasoning,
28
+ attachment: model.inputModalities.includes("image"),
29
+ modalities: { input: model.inputModalities, output: ["text"] },
30
+ limit: { context: model.context, output: model.maxOutput },
31
+ variants,
32
+ };
33
+ }
34
+ /** v1 config 的 models 块:以 wire id 为键(可含 `/`,原样保留) */
35
+ export function toV1ModelMap(models) {
36
+ return Object.fromEntries(models.map((model) => [model.id, toV1ModelEntry(model)]));
37
+ }
38
+ export function toV2ModelFields(model) {
39
+ return {
40
+ id: model.id,
41
+ modelID: model.id,
42
+ name: model.name,
43
+ capabilities: { tools: true, input: model.inputModalities, output: ["text"] },
44
+ limit: { context: model.context, output: model.maxOutput },
45
+ variants: (model.efforts ?? []).map((effort) => ({ id: effort, settings: { reasoningEffort: effort } })),
46
+ };
47
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 模型管线运行时:刷新节奏与降级状态机(契约:docs/spec/model-pipeline.md §4–§5、
3
+ * 覆盖机制 models-url-override.md §4)。#31 的解析/级联在此接上拉取、TTL 与重试。
4
+ *
5
+ * **刷新节奏**(§4):
6
+ * - 构建产物:按 modelsUrls 解析出的有序 URL 列表拉取、首个成功者胜;TTL 1h +
7
+ * 随机抖动;内容 hash(signature.ts,只含 models)变化才触发下游动作(v2
8
+ * `catalog.reload()`);渠道支持时带 ETag/Last-Modified 条件请求(304 = 未变,
9
+ * 复用该渠道上次产物,不重下载不重解析)。
10
+ * - `/provider/v1/models`:匿名拉取、TTL 5min;变更签名 = 排序后 (id,
11
+ * context_length) 序列(`created` 已在 api.ts 数据入口剥除)。
12
+ * - **v2 形态**(`start`):启动零阻塞——立即以包内快照注册返回,首轮拉取转后台,
13
+ * 签名变化才回调 `onChange`(glue 接 catalog.reload())。
14
+ * - **v1 形态**(`initializeOnce`):启动拉取一次(15s 总预算,跨渠道共享),失败
15
+ * 用快照;之后不做后台刷新(v1 无 reload 机制)。
16
+ *
17
+ * **刷新驱动**:与伪装模块版本头同款惰性节奏——不挂 setInterval,读时(
18
+ * `getModels`)或显式(`refreshIfDue`)检查 TTL 到点即后台刷新,
19
+ * stale-while-revalidate:读永不等待在途拉取。
20
+ *
21
+ * **降级**(§5):快照 = 「最后已知良好产物」的角色扮演者——产物/API 一旦成功拉到,
22
+ * 后续刷新失败**沿用内存中的上次成功数据**(它就是最新鲜的最后已知良好产物,比
23
+ * 内置快照新),只有从未成功过才落到内置快照;全部失败照常可用 + 指数退避重试
24
+ * (30s 起、上限 30min),任一来源恢复即回到完整级联。所有降级路径打 warn 注明
25
+ * 退到了哪层。
26
+ *
27
+ * 出网统一走注入的 fetch 接缝(testing.md §2);时间直接取 `Date.now()`(fake
28
+ * timers 驱动)。
29
+ */
30
+ import type { FetchLike } from "../protocol/json.js";
31
+ import { type Artifact } from "./artifact.js";
32
+ import { type CascadeResult } from "./cascade.js";
33
+ /** `/provider/v1/models` 端点(匿名拉取,带 key 实测无差异——§0) */
34
+ export declare const MODELS_API_URL = "https://api.commandcode.ai/provider/v1/models";
35
+ /** 构建产物刷新 TTL:1h(§4) */
36
+ export declare const ARTIFACT_REFRESH_TTL_MS: number;
37
+ /** 产物 TTL 随机抖动上限:+0~5min(错开整点、防雷群) */
38
+ export declare const ARTIFACT_TTL_JITTER_MS: number;
39
+ /** `/models` API 刷新 TTL:5min(§4,无抖动) */
40
+ export declare const API_REFRESH_TTL_MS: number;
41
+ /** 刷新失败指数退避基数:30s × 2^failCount(§5) */
42
+ export declare const REFRESH_RETRY_BASE_MS: number;
43
+ /** 刷新失败指数退避上限:30min(§5) */
44
+ export declare const REFRESH_RETRY_CAP_MS: number;
45
+ /** 单请求超时上限(v2 后台轮次逐请求计时) */
46
+ export declare const MODELS_FETCH_TIMEOUT_MS: number;
47
+ /** v1 启动拉取的总预算:15s(§4,跨渠道共享——首渠道挂满预算即落快照) */
48
+ export declare const V1_STARTUP_TIMEOUT_MS: number;
49
+ export interface PipelineLogger {
50
+ debug(message: string): void;
51
+ warn(message: string): void;
52
+ }
53
+ export interface ModelPipelineOptions {
54
+ /** 包内快照(已解析的 Artifact):启动即注册的初始层,产物从未成功时顶替产物角色 */
55
+ readonly snapshot: Artifact;
56
+ /** modelsUrls 的 config 通道原样值(v1 工厂 options / v2 settings 透传的顶层键,未解析) */
57
+ readonly modelsUrls?: unknown;
58
+ /** 出网接缝(工厂 options.fetch;缺省 globalThis.fetch) */
59
+ readonly fetch?: FetchLike;
60
+ readonly logger?: PipelineLogger;
61
+ /** 下游动作(v2 catalog.reload()):产物内容 hash 或 API 签名变化时回调,携带最新级联 */
62
+ readonly onChange?: (cascade: CascadeResult) => void;
63
+ }
64
+ export interface ModelPipeline {
65
+ /** v2 形态:启动零阻塞——立即返回快照注册的级联,首轮拉取转后台 */
66
+ start(): CascadeResult;
67
+ /** v1 形态:启动拉取一次(15s 总预算),失败用快照;此后不做后台刷新 */
68
+ initializeOnce(): Promise<CascadeResult>;
69
+ /** 当前级联结果;读时惰性检查 TTL,到点转后台刷新(stale-while-revalidate,读不等待) */
70
+ getModels(): CascadeResult;
71
+ /** 显式到期检查并触发后台刷新;返回在途轮次的落定 promise(宿主无读机会时的兜底触发点) */
72
+ refreshIfDue(): Promise<void>;
73
+ /**
74
+ * modelsUrls config 通道的运行时接驳(model-pipeline.md §1.3,#36):v2 宿主把
75
+ * `settings.modelsUrls` 合并进工厂 options 顶层——但那要等首次工厂调用才可见
76
+ * (beta-18684 实测:transform 草稿不带 config settings,插件侧构造时拿不到),
77
+ * 构造时管线只能按 env/默认列表启动。本方法在工厂调用时把 config 值重绑定进
78
+ * 管线:原值未变零开销跳过(宿主逐请求调工厂);解析列表真变了才替换并立即
79
+ * 触发一轮产物拉取(不等 TTL——用户切换镜像不该等 1h);非法值按 §1.3 回退并
80
+ * warn。v1 形态(无后台刷新)不接驳。
81
+ */
82
+ rebindModelsUrls(config: unknown): void;
83
+ /**
84
+ * 版本头兜底链 ③(disguise.md §6):内存中已拉取构建产物的 `sourceCliVersion`。
85
+ * 只读内存——不触发拉取、不等待;产物从未成功时 undefined(调用方落 ④ 快照层)。
86
+ */
87
+ artifactSourceCliVersion(): string | undefined;
88
+ }
89
+ export declare function createModelPipeline(options: ModelPipelineOptions): ModelPipeline;
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 模型管线运行时:刷新节奏与降级状态机(契约:docs/spec/model-pipeline.md §4–§5、
3
+ * 覆盖机制 models-url-override.md §4)。#31 的解析/级联在此接上拉取、TTL 与重试。
4
+ *
5
+ * **刷新节奏**(§4):
6
+ * - 构建产物:按 modelsUrls 解析出的有序 URL 列表拉取、首个成功者胜;TTL 1h +
7
+ * 随机抖动;内容 hash(signature.ts,只含 models)变化才触发下游动作(v2
8
+ * `catalog.reload()`);渠道支持时带 ETag/Last-Modified 条件请求(304 = 未变,
9
+ * 复用该渠道上次产物,不重下载不重解析)。
10
+ * - `/provider/v1/models`:匿名拉取、TTL 5min;变更签名 = 排序后 (id,
11
+ * context_length) 序列(`created` 已在 api.ts 数据入口剥除)。
12
+ * - **v2 形态**(`start`):启动零阻塞——立即以包内快照注册返回,首轮拉取转后台,
13
+ * 签名变化才回调 `onChange`(glue 接 catalog.reload())。
14
+ * - **v1 形态**(`initializeOnce`):启动拉取一次(15s 总预算,跨渠道共享),失败
15
+ * 用快照;之后不做后台刷新(v1 无 reload 机制)。
16
+ *
17
+ * **刷新驱动**:与伪装模块版本头同款惰性节奏——不挂 setInterval,读时(
18
+ * `getModels`)或显式(`refreshIfDue`)检查 TTL 到点即后台刷新,
19
+ * stale-while-revalidate:读永不等待在途拉取。
20
+ *
21
+ * **降级**(§5):快照 = 「最后已知良好产物」的角色扮演者——产物/API 一旦成功拉到,
22
+ * 后续刷新失败**沿用内存中的上次成功数据**(它就是最新鲜的最后已知良好产物,比
23
+ * 内置快照新),只有从未成功过才落到内置快照;全部失败照常可用 + 指数退避重试
24
+ * (30s 起、上限 30min),任一来源恢复即回到完整级联。所有降级路径打 warn 注明
25
+ * 退到了哪层。
26
+ *
27
+ * 出网统一走注入的 fetch 接缝(testing.md §2);时间直接取 `Date.now()`(fake
28
+ * timers 驱动)。
29
+ */
30
+ import { parseModelsApi } from "./api.js";
31
+ import { parseArtifact } from "./artifact.js";
32
+ import { mergeModelLayers } from "./cascade.js";
33
+ import { apiChangeSignature, artifactContentHash } from "./signature.js";
34
+ import { MODELS_URLS_ENV_VAR, resolveModelsUrls } from "./urls.js";
35
+ /** `/provider/v1/models` 端点(匿名拉取,带 key 实测无差异——§0) */
36
+ export const MODELS_API_URL = "https://api.commandcode.ai/provider/v1/models";
37
+ /** 构建产物刷新 TTL:1h(§4) */
38
+ export const ARTIFACT_REFRESH_TTL_MS = 60 * 60 * 1000;
39
+ /** 产物 TTL 随机抖动上限:+0~5min(错开整点、防雷群) */
40
+ export const ARTIFACT_TTL_JITTER_MS = 5 * 60 * 1000;
41
+ /** `/models` API 刷新 TTL:5min(§4,无抖动) */
42
+ export const API_REFRESH_TTL_MS = 5 * 60 * 1000;
43
+ /** 刷新失败指数退避基数:30s × 2^failCount(§5) */
44
+ export const REFRESH_RETRY_BASE_MS = 30 * 1000;
45
+ /** 刷新失败指数退避上限:30min(§5) */
46
+ export const REFRESH_RETRY_CAP_MS = 30 * 60 * 1000;
47
+ /** 单请求超时上限(v2 后台轮次逐请求计时) */
48
+ export const MODELS_FETCH_TIMEOUT_MS = 15 * 1000;
49
+ /** v1 启动拉取的总预算:15s(§4,跨渠道共享——首渠道挂满预算即落快照) */
50
+ export const V1_STARTUP_TIMEOUT_MS = 15 * 1000;
51
+ const defaultLogger = {
52
+ debug: (message) => console.debug(message),
53
+ warn: (message) => console.warn(message),
54
+ };
55
+ const idleOutcome = { attempted: false, ok: true, changed: false };
56
+ export function createModelPipeline(options) {
57
+ const fetchImpl = options.fetch ?? globalThis.fetch;
58
+ const logger = options.logger ?? defaultLogger;
59
+ // modelsUrls 三通道解析(config > env > 默认)在构造时完成一次;解析永不失败、
60
+ // 不阻断启动(urls.ts)。env 直读进程环境(models-url-override.md §3.1:v1/v2
61
+ // 插件均在宿主进程内),测试以 config 通道与解析器参数注入直测,不碰该变量。
62
+ // config 值的运行时接驳见 rebindModelsUrls(#36:v2 的 config settings 经工厂
63
+ // options 才可见,构造时通常缺席)。
64
+ let { urls } = resolveModelsUrls({
65
+ config: options.modelsUrls,
66
+ env: process.env[MODELS_URLS_ENV_VAR],
67
+ logger,
68
+ });
69
+ /** 上次重绑定的 config 原值:宿主逐请求透传同一 settings,原值相等即零开销跳过 */
70
+ let modelsUrlsRaw = options.modelsUrls;
71
+ const snapshot = options.snapshot;
72
+ const state = {
73
+ /** 上次成功产物(sticky——刷新失败不清空,它就是最新鲜的「最后已知良好产物」) */
74
+ artifact: undefined,
75
+ artifactHash: undefined,
76
+ /** 上次成功 API 清单(sticky,同上) */
77
+ api: undefined,
78
+ apiSignature: undefined,
79
+ nextArtifactAttemptAt: 0,
80
+ nextApiAttemptAt: 0,
81
+ artifactFailCount: 0,
82
+ apiFailCount: 0,
83
+ };
84
+ /** 宿主形态:initializeOnce → v1(此后刷新停用);start → v2 */
85
+ let mode;
86
+ let current = mergeModelLayers({ snapshot });
87
+ let roundInFlight;
88
+ /** 条件请求记忆:每渠道上次成功响应的 validators + 解析产物(304 复用) */
89
+ const channelCache = new Map();
90
+ function recomputeCascade() {
91
+ current = mergeModelLayers({ api: state.api, artifact: state.artifact, snapshot });
92
+ }
93
+ function backoffDelay(failCount) {
94
+ return Math.min(REFRESH_RETRY_BASE_MS * 2 ** failCount, REFRESH_RETRY_CAP_MS);
95
+ }
96
+ function formatDelay(ms) {
97
+ return ms % 60_000 === 0 ? `${ms / 60_000}min` : `${Math.round(ms / 1000)}s`;
98
+ }
99
+ /** 逐请求信号:v1 轮次直接以 15s 总预算信号为每请求上限(预算与请求上限合一,
100
+ * 不叠加独立 per-request 计时器——两个同刻 15s 计时器触发次序不定,会让
101
+ * 「预算耗尽即止损」的判断出现一拍竞态);v2 轮次:仅单请求超时 */
102
+ function requestSignal(deadline) {
103
+ return deadline ?? AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS);
104
+ }
105
+ function describeError(error) {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+ /** 应用一次成功产物:更新 sticky 层与 hash,重排 TTL;返回内容是否变化 */
109
+ function applyArtifact(artifact) {
110
+ const hash = artifactContentHash(artifact);
111
+ const changed = state.artifactHash !== hash;
112
+ state.artifact = artifact;
113
+ state.artifactHash = hash;
114
+ state.artifactFailCount = 0;
115
+ state.nextArtifactAttemptAt = Date.now() + ARTIFACT_REFRESH_TTL_MS + Math.floor(Math.random() * ARTIFACT_TTL_JITTER_MS);
116
+ // 镜像滞后自查点(models-url-override.md §4.4):每次成功拉取都落 generatedAt
117
+ logger.debug(`构建产物拉取成功:generatedAt=${artifact.generatedAt},sourceCliVersion=${artifact.sourceCliVersion},${artifact.models.length} 个模型`);
118
+ return changed;
119
+ }
120
+ /**
121
+ * 产物层:按 URL 列表顺序尝试,首个成功者胜(解析失败/非 2xx/超时都算该渠道
122
+ * 失败,继续下一渠道——未来版本号是「这一份」产物被弃用,列表后位可能是旧但
123
+ * 可读的副本)。全渠道失败才动退避与降级告警。
124
+ */
125
+ async function fetchArtifactIntoState(deadline) {
126
+ const failures = [];
127
+ for (const url of urls) {
128
+ if (deadline?.aborted) {
129
+ failures.push("启动预算耗尽,剩余渠道跳过");
130
+ break;
131
+ }
132
+ try {
133
+ const cached = channelCache.get(url);
134
+ const headers = { accept: "application/json" };
135
+ if (cached?.etag !== undefined)
136
+ headers["if-none-match"] = cached.etag;
137
+ if (cached?.lastModified !== undefined)
138
+ headers["if-modified-since"] = cached.lastModified;
139
+ const response = await fetchImpl(url, { headers, signal: requestSignal(deadline) });
140
+ if (response.status === 304 && cached !== undefined) {
141
+ // 未变:复用该渠道上次产物(内容同上次成功响应,hash 比对后大概率无动作)
142
+ return { attempted: true, ok: true, changed: applyArtifact(cached.artifact) };
143
+ }
144
+ if (!response.ok)
145
+ throw new Error(`HTTP ${response.status}`);
146
+ const body = await response.json();
147
+ const parsed = parseArtifact(body);
148
+ if (!parsed.ok) {
149
+ // 未来版本号 = 上游破坏性变更已发布,是最需要人看见的渠道级事件,
150
+ // 单独 warn(其余渠道级失败只在整层降级时随汇总 warn 出现)
151
+ if (parsed.error.reason === "future-version") {
152
+ logger.warn(`构建产物渠道 ${url} 的产物 schemaVersion ${parsed.error.schemaVersion} 高于已知上限,整体弃用该产物`);
153
+ failures.push(`${url} → schemaVersion ${parsed.error.schemaVersion} 高于已知上限,整体弃用`);
154
+ continue;
155
+ }
156
+ throw new Error(`产物解析失败:${parsed.error.detail}`);
157
+ }
158
+ channelCache.set(url, {
159
+ artifact: parsed.artifact,
160
+ etag: response.headers.get("etag") ?? undefined,
161
+ lastModified: response.headers.get("last-modified") ?? undefined,
162
+ });
163
+ return { attempted: true, ok: true, changed: applyArtifact(parsed.artifact) };
164
+ }
165
+ catch (error) {
166
+ failures.push(`${url} → ${describeError(error)}`);
167
+ if (deadline?.aborted)
168
+ break;
169
+ }
170
+ }
171
+ state.nextArtifactAttemptAt = Date.now() + backoffDelay(state.artifactFailCount);
172
+ state.artifactFailCount += 1;
173
+ const summary = `构建产物拉取失败(${failures.join(";")})`;
174
+ if (state.artifact === undefined) {
175
+ logger.warn(`${summary},包内快照顶替产物角色`);
176
+ }
177
+ else {
178
+ logger.warn(`${summary},沿用上次成功产物(相当于快照角色)`);
179
+ }
180
+ return { attempted: true, ok: false, changed: false, detail: failures.join(";") };
181
+ }
182
+ /** API 层:单次拉取 + 签名比对;失败动退避(退化 warn 归轮次落定后统一打,判层才准确) */
183
+ async function fetchApiIntoState(deadline) {
184
+ try {
185
+ const response = await fetchImpl(MODELS_API_URL, {
186
+ headers: { accept: "application/json" },
187
+ signal: requestSignal(deadline),
188
+ });
189
+ if (!response.ok)
190
+ throw new Error(`HTTP ${response.status}`);
191
+ const body = await response.json();
192
+ const parsed = parseModelsApi(body);
193
+ if (!parsed.ok)
194
+ throw new Error(`/models 响应解析失败:${parsed.detail}`);
195
+ const signature = apiChangeSignature(parsed.models);
196
+ const changed = state.apiSignature !== signature;
197
+ state.api = parsed.models;
198
+ state.apiSignature = signature;
199
+ state.apiFailCount = 0;
200
+ state.nextApiAttemptAt = Date.now() + API_REFRESH_TTL_MS;
201
+ return { attempted: true, ok: true, changed };
202
+ }
203
+ catch (error) {
204
+ state.nextApiAttemptAt = Date.now() + backoffDelay(state.apiFailCount);
205
+ state.apiFailCount += 1;
206
+ return { attempted: true, ok: false, changed: false, detail: describeError(error) };
207
+ }
208
+ }
209
+ /** 一轮刷新:到点的来源并行拉取,落定后统一重算级联、判变更、触发下游 */
210
+ function runRound(deadline) {
211
+ if (roundInFlight !== undefined)
212
+ return roundInFlight;
213
+ const task = (async () => {
214
+ try {
215
+ const wantArtifact = Date.now() >= state.nextArtifactAttemptAt;
216
+ const wantApi = Date.now() >= state.nextApiAttemptAt;
217
+ if (!wantArtifact && !wantApi)
218
+ return;
219
+ const [artifactOutcome, apiOutcome] = await Promise.all([
220
+ wantArtifact ? fetchArtifactIntoState(deadline) : idleOutcome,
221
+ wantApi ? fetchApiIntoState(deadline) : idleOutcome,
222
+ ]);
223
+ recomputeCascade();
224
+ // API 降级 warn 归轮次落定后统一打:degradedTo 依据最终产物层判层——
225
+ // 在 fetch 内判会撞上并行产物拉取未落定的竞态(两个来源并行拉取)
226
+ if (apiOutcome.attempted && !apiOutcome.ok) {
227
+ const degradedTo = current.productLayer === "artifact" ? "产物" : "快照";
228
+ if (state.api === undefined) {
229
+ logger.warn(`/provider/v1/models 拉取失败(${apiOutcome.detail}),发现退化为${degradedTo} id 清单`);
230
+ }
231
+ else {
232
+ logger.warn(`/provider/v1/models 刷新失败(${apiOutcome.detail}),沿用上次成功清单`);
233
+ }
234
+ }
235
+ if (artifactOutcome.attempted && apiOutcome.attempted && !artifactOutcome.ok && !apiOutcome.ok) {
236
+ // 全部来源失败(§5):照常可用 + 指数退避;下次可试时刻取两来源的较早者
237
+ const nextRetry = Math.min(state.nextArtifactAttemptAt, state.nextApiAttemptAt) - Date.now();
238
+ const holding = state.artifact === undefined
239
+ ? "包内快照全量注册照常可用(数据可能过期)"
240
+ : "沿用上次成功数据照常可用";
241
+ logger.warn(`模型数据全部来源失败:${holding},${formatDelay(Math.max(nextRetry, 0))} 后指数退避重试`);
242
+ }
243
+ if ((artifactOutcome.changed || apiOutcome.changed) && mode !== "v1") {
244
+ try {
245
+ options.onChange?.(current);
246
+ }
247
+ catch (error) {
248
+ logger.warn(`模型管线 onChange 回调失败:${describeError(error)}`);
249
+ }
250
+ }
251
+ }
252
+ finally {
253
+ roundInFlight = undefined;
254
+ }
255
+ })();
256
+ roundInFlight = task;
257
+ return task;
258
+ }
259
+ /** 到期检查 + 后台刷新触发;返回在途轮次 promise(无动作立即落定) */
260
+ function refreshIfDue() {
261
+ if (mode === "v1")
262
+ return Promise.resolve();
263
+ if (Date.now() >= state.nextArtifactAttemptAt || Date.now() >= state.nextApiAttemptAt) {
264
+ void runRound();
265
+ }
266
+ return roundInFlight ?? Promise.resolve();
267
+ }
268
+ function rebindModelsUrls(config) {
269
+ if (mode !== "v2")
270
+ return;
271
+ if (config === undefined)
272
+ return;
273
+ // 原值未变(宿主逐请求透传同一 settings)→ 不重解析不打日志
274
+ if (JSON.stringify(config) === JSON.stringify(modelsUrlsRaw))
275
+ return;
276
+ modelsUrlsRaw = config;
277
+ const resolved = resolveModelsUrls({
278
+ config,
279
+ env: process.env[MODELS_URLS_ENV_VAR],
280
+ logger,
281
+ });
282
+ if (resolved.urls.join("\n") === urls.join("\n")) {
283
+ urls = resolved.urls;
284
+ return;
285
+ }
286
+ urls = resolved.urls;
287
+ // 用户显式替换列表:立即补一轮产物拉取(TTL 未到点也拉——镜像切换不该等 1h);
288
+ // 在途轮次仍用旧列表跑完,下一轮起走新列表(stale-while-revalidate)
289
+ state.nextArtifactAttemptAt = 0;
290
+ void runRound();
291
+ }
292
+ return {
293
+ start() {
294
+ if (mode !== undefined) {
295
+ logger.warn("模型管线入口已初始化过,start() 忽略重复调用");
296
+ return current;
297
+ }
298
+ mode = "v2";
299
+ // 零阻塞:首轮 fire-and-forget,快照级联先返回
300
+ void runRound();
301
+ return current;
302
+ },
303
+ async initializeOnce() {
304
+ if (mode === "v1") {
305
+ // 幂等重入:v1 config 重载会重放 config hook,同一进程的启动协商只做一次,
306
+ // 静默返回现值(模型清单已在首轮注入 config)
307
+ return current;
308
+ }
309
+ if (mode !== undefined) {
310
+ logger.warn("模型管线已以 v2 形态启动,initializeOnce() 不切换形态——v1 启动拉取未执行,模型停在当前级联");
311
+ return current;
312
+ }
313
+ mode = "v1";
314
+ await runRound(AbortSignal.timeout(V1_STARTUP_TIMEOUT_MS));
315
+ // v1 无 reload 机制:本轮之后关闭一切后台刷新(applyArtifact 会在轮内重排
316
+ // TTL,此处覆写为永不到期;refreshIfDue 的 mode 检查是第二道闸)
317
+ state.nextArtifactAttemptAt = Number.POSITIVE_INFINITY;
318
+ state.nextApiAttemptAt = Number.POSITIVE_INFINITY;
319
+ return current;
320
+ },
321
+ getModels() {
322
+ void refreshIfDue();
323
+ return current;
324
+ },
325
+ refreshIfDue,
326
+ rebindModelsUrls,
327
+ artifactSourceCliVersion() {
328
+ return state.artifact?.sourceCliVersion;
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 模型管线变更签名(契约:docs/spec/model-pipeline.md §4 刷新与缓存)。
3
+ *
4
+ * - 产物:对 `models` 数组做内容 hash,变化才触发下游动作(v2 `catalog.reload()`)。
5
+ * 只哈希 models——`generatedAt` 每次构建必变、`schemaVersion` 恒定,掺进来会把
6
+ * 「内容没变」误判成「变了」;未知可选字段(向前兼容被忽略者)经运行时解析剥除,
7
+ * 哈希的是客户端实际生效视图,上游加字段不触发无谓 reload。
8
+ * - `/provider/v1/models`:变更签名 = 排序后 `(id, context_length)` 序列,**必须剔除
9
+ * `created`**——那是响应生成时刻的动态时间戳,不剔必每次误判「列表变了」(§4)。
10
+ * `created` / `owned_by` 已在 api.ts 数据入口结构性剥除,此处对解析结果再排序固化。
11
+ *
12
+ * 哈希输入是解析器构造的定键序对象,`JSON.stringify` 序列化稳定;sha256 只是给
13
+ * 比较键一个定长摘要,防日志/调试时打印全量目录。
14
+ */
15
+ import type { ApiModelEntry } from "./api.js";
16
+ import type { Artifact } from "./artifact.js";
17
+ /** 产物内容 hash:models 数组的 sha256(hex) */
18
+ export declare function artifactContentHash(artifact: Artifact): string;
19
+ /** API 变更签名:排序后 (id, context_length) 序列的 sha256(hex);`created` 已在解析入口剥除 */
20
+ export declare function apiChangeSignature(models: readonly ApiModelEntry[]): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 模型管线变更签名(契约:docs/spec/model-pipeline.md §4 刷新与缓存)。
3
+ *
4
+ * - 产物:对 `models` 数组做内容 hash,变化才触发下游动作(v2 `catalog.reload()`)。
5
+ * 只哈希 models——`generatedAt` 每次构建必变、`schemaVersion` 恒定,掺进来会把
6
+ * 「内容没变」误判成「变了」;未知可选字段(向前兼容被忽略者)经运行时解析剥除,
7
+ * 哈希的是客户端实际生效视图,上游加字段不触发无谓 reload。
8
+ * - `/provider/v1/models`:变更签名 = 排序后 `(id, context_length)` 序列,**必须剔除
9
+ * `created`**——那是响应生成时刻的动态时间戳,不剔必每次误判「列表变了」(§4)。
10
+ * `created` / `owned_by` 已在 api.ts 数据入口结构性剥除,此处对解析结果再排序固化。
11
+ *
12
+ * 哈希输入是解析器构造的定键序对象,`JSON.stringify` 序列化稳定;sha256 只是给
13
+ * 比较键一个定长摘要,防日志/调试时打印全量目录。
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ /** 产物内容 hash:models 数组的 sha256(hex) */
17
+ export function artifactContentHash(artifact) {
18
+ return sha256(JSON.stringify(artifact.models));
19
+ }
20
+ /** API 变更签名:排序后 (id, context_length) 序列的 sha256(hex);`created` 已在解析入口剥除 */
21
+ export function apiChangeSignature(models) {
22
+ const pairs = models
23
+ .map((entry) => [entry.id, entry.contextLength ?? null])
24
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
25
+ return sha256(JSON.stringify(pairs));
26
+ }
27
+ function sha256(text) {
28
+ return createHash("sha256").update(text).digest("hex");
29
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 包内快照(docs/spec/model-pipeline.md §5):与构建产物同 schema 的「最后已知
3
+ * 良好产物」,随插件发版从最新产物复制进来,是运行时永远可用的兜底层——不占
4
+ * modelsUrls 列表位,产物/API 全部失败或从未成功时顶替产物角色(§5 降级表)。
5
+ *
6
+ * 产物原文在同目录 `snapshot.json`(逐字节来自构建产物,sha256 可与渠道产物对
7
+ * 账);刷新走 `pnpm embed-snapshot`(scripts/embed-snapshot.ts:运行时同款
8
+ * parseArtifact schema 校验通过才写入,发布流程见 docs/release/release-process.md)。
9
+ * build 脚本在 tsc 之后 `cp` 一份原文进 dist——tsc 自带的 JSON 复制会重排版,
10
+ * 逐字节一致靠 cp 保住。空快照占位形态不存在——校验拒绝空清单入库;运行时任何
11
+ * 降级路径都不会因快照缺位而崩(loadPackageSnapshot 的防御分支兜底)。
12
+ * 快照 `sourceCliVersion` 同时是版本头兜底链 ④(disguise.md §6)。
13
+ */
14
+ /** 包内快照原文(unknown:非可信数据,消费方一律经 parseArtifact 解析) */
15
+ export declare const PACKAGE_SNAPSHOT_JSON: unknown;