@morlay/dsh-llm-openai-compatible 0.0.9-alpha.5 → 0.0.9-alpha.7

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/README.md CHANGED
@@ -15,46 +15,54 @@ DeepSeek Harness 的 **OpenAI 兼容 LLM 适配器**插件。与内置 `llm-pi-a
15
15
  ## 配置
16
16
 
17
17
  `providers` 是 dict:**key 就是 provider 路由键**(选择器与
18
- `GenerateOptions.provider` 使用),值是 profile。
18
+ `GenerateOptions.provider` 使用),值是 profile。它标了 `.volatile()`——**运行期可改**:
19
+ 值经 Loader 的引用读取,设置页(Models 页)改的就是它,不需要重挂这行插件(见
20
+ [ADR-运行期改配置走volatile引用](./.agents/adrs/20260922-运行期改配置走volatile引用.md))。
21
+
22
+ 配置写在**这行的 config** 里(bundle patch / profile patch / 设置页都落到这里):
19
23
 
20
24
  ```yaml
21
- llm-openai-compatible:
22
- providers:
23
- ollama:
24
- apiKeyEnv: OLLAMA_API_KEY
25
- baseURL: https://ollama.com/v1
26
- displayName: Ollama Gateway
27
- # === 采样默认参数(请求级 temperature 优先)===
28
- temperature: 1 # 0..2
29
- topP: 0.95 # 0..1 → wire top_p
30
- topK: 40 # 正整数 → wire top_k(非标准,仅网关支持时发送)
31
- presencePenalty: 0 # -2..2 → wire presence_penalty
32
- frequencyPenalty: 0 # -2..2 → wire frequency_penalty
33
- seed: 42 # 正整数 → wire seed
34
- # === 推理 ===
35
- reasoning: high # 部署默认档位(省略 = 提供方默认)
36
- # === 模型目录 ===
37
- defaultContextWindow: 262144
38
- defaultMaxTokens: 32768
39
- models:
40
- - id: deepseek-v4-flash:0731
41
- name: DeepSeek V4 Flash
42
- contextWindow: 1000000
43
- maxTokens: 65535
44
- inputModalities: [text, image]
45
- reasoningEfforts:
46
- off: # off 空值 = 不发送 reasoning_effort
47
- high: high # 档位 → wire reasoning_effort 拼写
48
- max: max
49
- # === 传输 ===
50
- maxRequestImageBytes: 20971520
51
- streamIdleTimeoutMs: 300000
52
- timeoutMs: 600000 # 整体请求超时;缺省不设
53
- retryPolicy:
54
- mode: normal
55
- maxRetries: 5
25
+ - id: llm-openai-compatible
26
+ name: "@morlay/dsh-llm-openai-compatible"
27
+ config:
28
+ providers:
29
+ ollama:
30
+ apiKeyEnv: OLLAMA_API_KEY
31
+ baseURL: https://ollama.com/v1
32
+ displayName: Ollama Gateway
33
+ # === 采样默认参数(请求级 temperature 优先)===
34
+ temperature: 1 # 0..2
35
+ topP: 0.95 # 0..1 → wire top_p
36
+ topK: 40 # 正整数 → wire top_k(非标准,仅网关支持时发送)
37
+ presencePenalty: 0 # -2..2 → wire presence_penalty
38
+ frequencyPenalty: 0 # -2..2 → wire frequency_penalty
39
+ seed: 42 # 正整数 → wire seed
40
+ # === 推理 ===
41
+ reasoning: high # 部署默认档位(省略 = 提供方默认)
42
+ # === 模型目录 ===
43
+ defaultContextWindow: 262144
44
+ defaultMaxTokens: 32768
45
+ models:
46
+ - id: deepseek-v4-flash:0731
47
+ name: DeepSeek V4 Flash
48
+ contextWindow: 1000000
49
+ maxTokens: 65535
50
+ inputModalities: [text, image]
51
+ reasoningEfforts:
52
+ off: # off 空值 = 不发送 reasoning_effort
53
+ high: high # 档位 → wire reasoning_effort 拼写
54
+ max: max
55
+ # === 传输 ===
56
+ maxRequestImageBytes: 20971520
57
+ streamIdleTimeoutMs: 300000
58
+ timeoutMs: 600000 # 整体请求超时;缺省不设
59
+ retryPolicy:
60
+ mode: normal
61
+ maxRetries: 5
56
62
  ```
57
63
 
64
+ > 旧 `$DSH_HOME/settings.yaml` 的 `llm-openai-compatible` 段由上游启动时一次性导入到同 id 的行 config。
65
+
58
66
  ## 规则与取舍
59
67
 
60
68
  规则细节(合并表、wire 字段落点、端点与错误映射)在各自 ADR 里维护,这里只列结论:
package/dist/index.d.mts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { a as OpenAICompatibleAdapter, c as ResolvedModelProfile, i as DEFAULT_STREAM_IDLE_TIMEOUT_MS, l as ResolvedProviderProfile, n as DEFAULT_MAX_REQUEST_IMAGE_BYTES, o as OpenAICompatibleAdapterOptions, r as DEFAULT_MAX_TOKENS, s as ReasoningEffort, t as DEFAULT_CONTEXT_WINDOW } from "./adapter-CYd_pegB.mjs";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { ModelModality, RetryPolicyConfig } from "@deepseek-ai/dsh-llm";
4
- import { Context } from "@deepseek-ai/cordis";
4
+ import { Context, Volatile } from "@deepseek-ai/cordis";
5
+ //#region ../../../vendor/deepseek-harness/vendor/cosmokit/lib/types/misc.d.ts
6
+ /** String/symbol keyed dictionary type. */
7
+ type Dict<T = any, K extends string | symbol = string> = { [key in K]: T; };
8
+ //#endregion
5
9
  //#region src/index.d.ts
6
10
  declare const name = "llm-openai-compatible";
7
11
  declare const inject: string[];
@@ -38,12 +42,23 @@ interface ProviderProfileSource {
38
42
  retryPolicy?: RetryPolicyConfig;
39
43
  }
40
44
  interface Config {
41
- providers?: Record<string, ProviderProfileSource>;
45
+ /**
46
+ * provider 路由,key 是路由键。**volatile**:值经 Loader 的引用读取
47
+ * (`config.providers.get()`),设置页改它时不需要重挂这行插件——上游 0.1.7 起
48
+ * 「运行期可改」只有这一条路(旧的 settings namespace section 覆盖已取消)。
49
+ */
50
+ providers: Volatile<Record<string, ProviderProfileSource>>;
42
51
  }
43
- declare const Config: z<Config>;
52
+ /** 解析成普通值之后的配置形状(校验与解析只认它)。 */
53
+ type Options = { [K in keyof Config]?: Config[K] extends Volatile<infer T> ? T : never; };
54
+ declare const Config: z<Schemastery.ObjectS<NoInfer<{
55
+ providers: z<NoInfer<Dict<ProviderProfileSource, string>>, NoInfer<Dict<ProviderProfileSource, string>>, "volatile-defined">;
56
+ }>>, Schemastery.ObjectT<NoInfer<{
57
+ providers: z<NoInfer<Dict<ProviderProfileSource, string>>, NoInfer<Dict<ProviderProfileSource, string>>, "volatile-defined">;
58
+ }>>, "plain">;
44
59
  declare function resolveAdapterOptions(provider: string, source: ProviderProfileSource): ResolvedProviderProfile;
45
60
  declare function resolveProfiles(providers: Readonly<Record<string, ProviderProfileSource>> | undefined): Map<string, ResolvedProviderProfile>;
46
- declare function assertServiceable(config: Config): void;
61
+ declare function assertServiceable(config: Options): void;
47
62
  declare function apply(ctx: Context, config: Config): void;
48
63
  //#endregion
49
- export { Config, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, MODEL_MODALITIES, ModelProfileSource, NS, OpenAICompatibleAdapter, type OpenAICompatibleAdapterOptions, ProviderProfileSource, REASONING_LEVELS, type ReasoningEffort, type ResolvedModelProfile, type ResolvedProviderProfile, apply, assertServiceable, inject, name, resolveAdapterOptions, resolveProfiles };
64
+ export { Config, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, MODEL_MODALITIES, ModelProfileSource, NS, OpenAICompatibleAdapter, type OpenAICompatibleAdapterOptions, Options, ProviderProfileSource, REASONING_LEVELS, type ReasoningEffort, type ResolvedModelProfile, type ResolvedProviderProfile, apply, assertServiceable, inject, name, resolveAdapterOptions, resolveProfiles };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as serializeCallOptions, o as serializeCallOptionsWithImages, r as translate } from "./translate-CwniWJOm.mjs";
1
+ import { a as serializeCallOptions, o as serializeCallOptionsWithImages, r as translate } from "./translate-D9PyJrw2.mjs";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertUsableApiKey, attributionHeaders, contentHasImage, isContextWindowExceededError, isQuotaExceededError, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
4
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
@@ -288,7 +288,7 @@ const providerSchema = z.object({
288
288
  timeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS),
289
289
  retryPolicy: RetryPolicySchema
290
290
  });
291
- const Config = z.object({ providers: z.dict(providerSchema).default({}) });
291
+ const Config = z.object({ providers: z.dict(providerSchema).default({}).volatile() });
292
292
  function isReasoningEffort(value) {
293
293
  return REASONING_LEVELS.includes(value);
294
294
  }
@@ -397,25 +397,25 @@ function registrationFacts(profiles) {
397
397
  retryPolicy: profile.retryPolicy
398
398
  })).sort((left, right) => left.provider.localeCompare(right.provider));
399
399
  }
400
- function directoryEntries(profiles) {
400
+ function directoryEntries(profiles, settingsNs) {
401
401
  const entries = /* @__PURE__ */ new Map();
402
402
  for (const [provider, profile] of profiles) entries.set(provider, {
403
403
  provider,
404
404
  displayName: profile.displayName,
405
- settingsNs: NS,
405
+ settingsNs,
406
406
  settingsPath: ["providers", provider],
407
407
  declared: true
408
408
  });
409
409
  return [...entries.values()];
410
410
  }
411
411
  function apply(ctx, config) {
412
- let current = () => config;
412
+ const settingsNs = ctx.fiber.entry?.options.id ?? "llm-openai-compatible";
413
413
  let lastRaw;
414
414
  let memoized;
415
415
  const profiles = () => {
416
- const raw = current();
416
+ const raw = config.providers.get();
417
417
  if (raw === lastRaw && memoized !== void 0) return memoized;
418
- const next = resolveProfiles(raw.providers);
418
+ const next = resolveProfiles(raw);
419
419
  lastRaw = raw;
420
420
  memoized = next;
421
421
  return next;
@@ -445,7 +445,7 @@ function apply(ctx, config) {
445
445
  let directory;
446
446
  let directoryFacts;
447
447
  const ensureDirectory = () => {
448
- const entries = directoryEntries(profiles());
448
+ const entries = directoryEntries(profiles(), settingsNs);
449
449
  if (deepEqualJson(entries, directoryFacts)) return;
450
450
  if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
451
451
  else directory.replace(entries);
@@ -468,27 +468,27 @@ function apply(ctx, config) {
468
468
  registeredFacts = facts;
469
469
  };
470
470
  ensureRegistrationFacts();
471
- ctx.inject(["settings"], (settingsCtx) => {
472
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
473
- validate: assertServiceable,
474
- setSource: (source) => {
475
- current = source;
476
- },
477
- onChange: () => {
478
- try {
479
- ensureRegistrationFacts();
480
- } catch (error) {
481
- ctx.logger.error("llm-openai-compatible: keeping the previously registered routes after a refused update");
482
- ctx.logger.error(error);
483
- }
484
- try {
485
- ensureDirectory();
486
- } catch (error) {
487
- ctx.logger.error("llm-openai-compatible: keeping the previous configurable-provider directory after a refused update");
488
- ctx.logger.error(error);
489
- }
490
- }
491
- });
471
+ const refresh = () => {
472
+ try {
473
+ ensureRegistrationFacts();
474
+ } catch (error) {
475
+ ctx.logger.error("llm-openai-compatible: keeping the previously registered routes after a refused update");
476
+ ctx.logger.error(error);
477
+ }
478
+ try {
479
+ ensureDirectory();
480
+ } catch (error) {
481
+ ctx.logger.error("llm-openai-compatible: keeping the previous configurable-provider directory after a refused update");
482
+ ctx.logger.error(error);
483
+ }
484
+ };
485
+ ctx.on("loader/volatile-update", refresh);
486
+ ctx.on("internal/config", function(_raw, next) {
487
+ const raw = next();
488
+ if (this !== ctx.fiber) return raw;
489
+ const candidate = Config(raw);
490
+ assertServiceable({ providers: structuredClone(candidate.providers.get()) });
491
+ return raw;
492
492
  });
493
493
  }
494
494
  //#endregion
@@ -22,7 +22,7 @@ function assertTextOnly(blocks) {
22
22
  if (contentHasImage(blocks)) throw new LlmError("The OpenAI-compatible chat-completions adapter does not support image content in this message.", "UNSUPPORTED_CONTENT");
23
23
  }
24
24
  function assertSupportedImageRoles(messages) {
25
- for (const message of messages) if (message.role !== "user" && contentHasImage(message.content)) throw new LlmError(`The OpenAI-compatible chat-completions adapter cannot represent image content in a ${message.role} message.`, "UNSUPPORTED_CONTENT");
25
+ for (const message of messages) if (message.role !== "user" && message.role !== "tool" && contentHasImage(message.content)) throw new LlmError(`The OpenAI-compatible chat-completions adapter cannot represent image content in a ${message.role} message.`, "UNSUPPORTED_CONTENT");
26
26
  }
27
27
  function assertRetainedImagesFit(messages, maxRequestImageBytes) {
28
28
  const offloadImages = requiredImageOffload(messages, {
@@ -94,8 +94,6 @@ async function userParts(blocks, resolveImage, signal) {
94
94
  case "image":
95
95
  if (resolveImage === void 0) throw new LlmError("The OpenAI-compatible chat-completions adapter does not support image content in this message.", "UNSUPPORTED_CONTENT");
96
96
  parts.push(await resolveImage(block, signal));
97
- break;
98
- case "tool-result": parts.push(...await userParts(block.content, resolveImage, signal));
99
97
  }
100
98
  return parts;
101
99
  }
@@ -117,6 +115,8 @@ async function serializePrompt(messages, resolveImage, signal) {
117
115
  pendingToolImages = [];
118
116
  };
119
117
  for (const message of messages) {
118
+ if (message.role === "developer") throw new LlmError("The OpenAI-compatible chat-completions adapter cannot serialize developer messages.", "UNSUPPORTED_CONTENT");
119
+ if (message.content.some((block) => block.type === "tool-addition" || block.type === "tool-removal")) throw new LlmError("The OpenAI-compatible chat-completions adapter cannot serialize tool-change blocks outside developer messages.", "UNSUPPORTED_CONTENT");
120
120
  if (message.role === "system") {
121
121
  flushToolImages();
122
122
  prompt.push({
@@ -134,34 +134,34 @@ async function serializePrompt(messages, resolveImage, signal) {
134
134
  });
135
135
  continue;
136
136
  }
137
- const regular = message.content.filter((block) => block.type !== "tool-result");
138
- const toolResults = message.content.filter((block) => block.type === "tool-result");
139
- const content = await userParts(regular, resolveImage, signal);
140
- if (content.length > 0 || toolResults.length === 0) {
141
- flushToolImages();
142
- prompt.push({
143
- role: "user",
144
- content
145
- });
146
- }
147
- for (const result of toolResults) {
137
+ if (message.role === "tool") {
148
138
  const images = [];
149
139
  if (resolveImage !== void 0) {
150
- for (const block of result.content) if (block.type === "image") images.push(await resolveImage(block, signal));
140
+ for (const block of message.content) if (block.type === "image") images.push(await resolveImage(block, signal));
151
141
  }
142
+ flushToolImages();
152
143
  prompt.push({
153
144
  role: "tool",
154
145
  content: [{
155
146
  type: "tool-result",
156
- toolCallId: result.toolCallId,
157
- toolName: toolNames.get(result.toolCallId) ?? "",
147
+ toolCallId: message.toolCallId,
148
+ toolName: toolNames.get(message.toolCallId) ?? "",
158
149
  output: {
159
150
  type: "text",
160
- value: flattenText(result.content) || (images.length > 0 ? "(see attached image)" : "(no output)")
151
+ value: flattenText(message.content) || (images.length > 0 ? "(see attached image)" : "(no output)")
161
152
  }
162
153
  }]
163
154
  });
164
155
  pendingToolImages.push(...images);
156
+ continue;
157
+ }
158
+ const content = await userParts(message.content, resolveImage, signal);
159
+ if (content.length > 0) {
160
+ flushToolImages();
161
+ prompt.push({
162
+ role: "user",
163
+ content
164
+ });
165
165
  }
166
166
  }
167
167
  flushToolImages();
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as serializeCallOptions, i as resolveReasoningWire, n as mapUsage, o as serializeCallOptionsWithImages, r as translate, t as mapFinishReason } from "./translate-CwniWJOm.mjs";
1
+ import { a as serializeCallOptions, i as resolveReasoningWire, n as mapUsage, o as serializeCallOptionsWithImages, r as translate, t as mapFinishReason } from "./translate-D9PyJrw2.mjs";
2
2
  export { mapFinishReason, mapUsage, resolveReasoningWire, serializeCallOptions, serializeCallOptionsWithImages, translate };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenAI-Compatible LLM",
4
+ "description": "OpenAI-compatible LLM adapter with configurable default sampling parameters over a providers dict."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenAI 兼容 LLM 适配",
4
+ "description": "OpenAI 兼容端点的 LLM 适配器:可在 providers 上配置默认采样参数。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/dsh-llm-openai-compatible",
3
- "version": "0.0.9-alpha.5",
3
+ "version": "0.0.9-alpha.7",
4
4
  "description": "OpenAI-compatible LLM adapter plugin for DeepSeek Harness with configurable default sampling parameters (temperature / topP / topK / penalties / seed) over a providers dict.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -16,6 +16,7 @@
16
16
  "url": "https://github.com/morlay/dsh-plugin.git"
17
17
  },
18
18
  "files": [
19
+ "locale/*.json",
19
20
  "dist",
20
21
  "src",
21
22
  "cordis.patch.yml",
@@ -27,24 +28,25 @@
27
28
  "./invariant": "./dist/invariant.mjs",
28
29
  "./wire": "./dist/wire.mjs",
29
30
  "./package.json": "./package.json",
30
- "./cordis.patch.yml": "./cordis.patch.yml"
31
+ "./cordis.patch.yml": "./cordis.patch.yml",
32
+ "./locale/*.json": "./locale/*.json"
31
33
  },
32
34
  "dependencies": {
33
35
  "@ai-sdk/openai-compatible": "^3.0.32",
34
36
  "@ai-sdk/provider": "^4.0.7",
35
- "@deepseek-ai/dsh-util-values": "0.1.6-alpha.2"
37
+ "@deepseek-ai/dsh-util-values": "0.1.7-alpha.2"
36
38
  },
37
39
  "peerDependencies": {
38
- "@deepseek-ai/cordis": "4.0.2",
39
- "@deepseek-ai/dsh-anonymous-user-id": "0.1.6-alpha.2",
40
- "@deepseek-ai/dsh-attachment": "0.1.6-alpha.2",
41
- "@deepseek-ai/dsh-credentials": "0.1.6-alpha.2",
42
- "@deepseek-ai/dsh-invariants": "0.1.6-alpha.2",
43
- "@deepseek-ai/dsh-launch-environment": "0.1.6-alpha.2",
44
- "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
45
- "@deepseek-ai/dsh-settings": "0.1.6-alpha.2",
46
- "@deepseek-ai/dsh-timeout": "0.1.6-alpha.2",
47
- "@deepseek-ai/schemastery": "3.18.2"
40
+ "@deepseek-ai/cordis": "4.0.4",
41
+ "@deepseek-ai/dsh-anonymous-user-id": "0.1.7-alpha.2",
42
+ "@deepseek-ai/dsh-attachment": "0.1.7-alpha.2",
43
+ "@deepseek-ai/dsh-credentials": "0.1.7-alpha.2",
44
+ "@deepseek-ai/dsh-invariants": "0.1.7-alpha.2",
45
+ "@deepseek-ai/dsh-launch-environment": "0.1.7-alpha.2",
46
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
47
+ "@deepseek-ai/dsh-settings": "0.1.7-alpha.2",
48
+ "@deepseek-ai/dsh-timeout": "0.1.7-alpha.2",
49
+ "@deepseek-ai/schemastery": "3.18.4"
48
50
  },
49
51
  "dsh": {
50
52
  "bundle": {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Context } from "@deepseek-ai/cordis";
1
+ import type { Context, Volatile } from "@deepseek-ai/cordis";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import {
4
4
  LlmError,
@@ -82,9 +82,17 @@ export interface ProviderProfileSource {
82
82
  }
83
83
 
84
84
  export interface Config {
85
- providers?: Record<string, ProviderProfileSource>;
85
+ /**
86
+ * provider 路由,key 是路由键。**volatile**:值经 Loader 的引用读取
87
+ * (`config.providers.get()`),设置页改它时不需要重挂这行插件——上游 0.1.7 起
88
+ * 「运行期可改」只有这一条路(旧的 settings namespace section 覆盖已取消)。
89
+ */
90
+ providers: Volatile<Record<string, ProviderProfileSource>>;
86
91
  }
87
92
 
93
+ /** 解析成普通值之后的配置形状(校验与解析只认它)。 */
94
+ export type Options = { [K in keyof Config]?: Config[K] extends Volatile<infer T> ? T : never };
95
+
88
96
  const modelSchema = z.object({
89
97
  id: z.string().required(),
90
98
  name: z.string(),
@@ -95,7 +103,7 @@ const modelSchema = z.object({
95
103
  reasoningEfforts: z.union([z.const(false), z.dict(z.union([z.string(), z.const(null)]))]),
96
104
  });
97
105
 
98
- const providerSchema = z.object({
106
+ const providerSchema: z<ProviderProfileSource> = z.object({
99
107
  apiKeyEnv: z.string().role("credential-ref"),
100
108
  displayName: z.string(),
101
109
  baseURL: z.string().required(),
@@ -120,8 +128,8 @@ const providerSchema = z.object({
120
128
  retryPolicy: RetryPolicySchema,
121
129
  });
122
130
 
123
- export const Config: z<Config> = z.object({
124
- providers: z.dict(providerSchema).default({}),
131
+ export const Config = z.object({
132
+ providers: z.dict(providerSchema).default({}).volatile(),
125
133
  });
126
134
 
127
135
  function isReasoningEffort(value: string): value is ReasoningEffort {
@@ -355,7 +363,7 @@ export function resolveProfiles(
355
363
  return resolved;
356
364
  }
357
365
 
358
- export function assertServiceable(config: Config): void {
366
+ export function assertServiceable(config: Options): void {
359
367
  resolveProfiles(config.providers);
360
368
  }
361
369
 
@@ -369,10 +377,13 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedProviderProfile
369
377
  .sort((left, right) => left.provider.localeCompare(right.provider));
370
378
  }
371
379
 
372
- function directoryEntries(profiles: ReadonlyMap<string, ResolvedProviderProfile>): {
380
+ function directoryEntries(
381
+ profiles: ReadonlyMap<string, ResolvedProviderProfile>,
382
+ settingsNs: string,
383
+ ): {
373
384
  provider: string;
374
385
  displayName: string;
375
- settingsNs: typeof NS;
386
+ settingsNs: string;
376
387
  settingsPath: readonly string[];
377
388
  declared: boolean;
378
389
  }[] {
@@ -381,7 +392,7 @@ function directoryEntries(profiles: ReadonlyMap<string, ResolvedProviderProfile>
381
392
  {
382
393
  provider: string;
383
394
  displayName: string;
384
- settingsNs: typeof NS;
395
+ settingsNs: string;
385
396
  settingsPath: readonly string[];
386
397
  declared: boolean;
387
398
  }
@@ -390,7 +401,7 @@ function directoryEntries(profiles: ReadonlyMap<string, ResolvedProviderProfile>
390
401
  entries.set(provider, {
391
402
  provider,
392
403
  displayName: profile.displayName,
393
- settingsNs: NS,
404
+ settingsNs,
394
405
  settingsPath: ["providers", provider],
395
406
  declared: true,
396
407
  });
@@ -399,14 +410,18 @@ function directoryEntries(profiles: ReadonlyMap<string, ResolvedProviderProfile>
399
410
  }
400
411
 
401
412
  export function apply(ctx: Context, config: Config): void {
402
- let current = () => config;
403
- let lastRaw: Config | undefined;
413
+ // `settingsNs` 是**行 id**(不是包短名):设置页按 entry 定位这份配置。
414
+ const settingsNs = ctx.fiber.entry?.options.id ?? NS;
415
+ // 引用读回的是 schema 解析后的形状(只读、可选),不是手写的 `Options`。
416
+ let lastRaw: ReturnType<Config["providers"]["get"]> | undefined;
404
417
  let memoized: Map<string, ResolvedProviderProfile> | undefined;
405
418
 
406
419
  const profiles = (): ReadonlyMap<string, ResolvedProviderProfile> => {
407
- const raw = current();
420
+ const raw = config.providers.get();
408
421
  if (raw === lastRaw && memoized !== void 0) return memoized;
409
- const next = resolveProfiles(raw.providers);
422
+ // 引用读回的是 schema 解析后的只读形状(`exactOptionalPropertyTypes` 下与手写的可选字段形态不同),
423
+ // 这里按上游 llm-pi-ai 的做法转换一次再解析。
424
+ const next = resolveProfiles(raw as Record<string, ProviderProfileSource> | undefined);
410
425
  lastRaw = raw;
411
426
  memoized = next;
412
427
  return next;
@@ -443,7 +458,7 @@ export function apply(ctx: Context, config: Config): void {
443
458
  let directory: ReturnType<typeof ctx.llm.registerConfigurableProviders> | undefined;
444
459
  let directoryFacts: unknown[] | undefined;
445
460
  const ensureDirectory = () => {
446
- const entries = directoryEntries(profiles());
461
+ const entries = directoryEntries(profiles(), settingsNs);
447
462
  if (deepEqualJson(entries, directoryFacts)) return;
448
463
  if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
449
464
  else directory.replace(entries);
@@ -468,30 +483,35 @@ export function apply(ctx: Context, config: Config): void {
468
483
  registeredFacts = facts;
469
484
  };
470
485
  ensureRegistrationFacts();
471
- ctx.inject(["settings"], (settingsCtx) => {
472
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
473
- validate: assertServiceable,
474
- setSource: (source) => {
475
- current = source;
476
- },
477
- onChange: () => {
478
- try {
479
- ensureRegistrationFacts();
480
- } catch (error) {
481
- ctx.logger.error(
482
- "llm-openai-compatible: keeping the previously registered routes after a refused update",
483
- );
484
- ctx.logger.error(error);
485
- }
486
- try {
487
- ensureDirectory();
488
- } catch (error) {
489
- ctx.logger.error(
490
- "llm-openai-compatible: keeping the previous configurable-provider directory after a refused update",
491
- );
492
- ctx.logger.error(error);
493
- }
494
- },
486
+ const refresh = (): void => {
487
+ try {
488
+ ensureRegistrationFacts();
489
+ } catch (error) {
490
+ ctx.logger.error(
491
+ "llm-openai-compatible: keeping the previously registered routes after a refused update",
492
+ );
493
+ ctx.logger.error(error);
494
+ }
495
+ try {
496
+ ensureDirectory();
497
+ } catch (error) {
498
+ ctx.logger.error(
499
+ "llm-openai-compatible: keeping the previous configurable-provider directory after a refused update",
500
+ );
501
+ ctx.logger.error(error);
502
+ }
503
+ };
504
+ // volatile 更新(设置页保存)只把新值提交进运行引用并广播,不重挂这一行:这里重算路由注册与
505
+ // 可配置 provider 目录(上游 0.1.7 的运行期改配置机制;`profiles()` 读的就是引用里的新值)。
506
+ ctx.on("loader/volatile-update", refresh);
507
+ // 候选配置在提交前先校验:无效更新被拒绝,运行引用保持原值(loader 只记录这次拒绝)。
508
+ ctx.on("internal/config", function (this: Context["fiber"], _raw, next) {
509
+ const raw: unknown = next();
510
+ if (this !== ctx.fiber) return raw;
511
+ const candidate = Config(raw as Options);
512
+ assertServiceable({
513
+ providers: structuredClone(candidate.providers.get()) as Record<string, ProviderProfileSource>,
495
514
  });
515
+ return raw;
496
516
  });
497
517
  }
package/src/serialize.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  projectOffloadedImages,
7
7
  requiredImageOffload,
8
8
  } from "@deepseek-ai/dsh-llm";
9
- import type { ContentBlock, GenerateOptions, Message } from "@deepseek-ai/dsh-llm";
9
+ import type { ContentBlock, GenerateOptions, RequestMessage } from "@deepseek-ai/dsh-llm";
10
10
  import { AttachmentError } from "@deepseek-ai/dsh-attachment";
11
11
  import type { AttachmentStore } from "@deepseek-ai/dsh-attachment";
12
12
  import type {
@@ -83,9 +83,14 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void {
83
83
  }
84
84
  }
85
85
 
86
- function assertSupportedImageRoles(messages: readonly Message[]): void {
86
+ function assertSupportedImageRoles(messages: readonly RequestMessage[]): void {
87
87
  for (const message of messages) {
88
- if (message.role !== "user" && contentHasImage(message.content)) {
88
+ // 图片只出现在 user 与 tool 消息里;tool 结果的图片由 serializePrompt 聚成一条 user 消息发出。
89
+ if (
90
+ message.role !== "user" &&
91
+ message.role !== "tool" &&
92
+ contentHasImage(message.content)
93
+ ) {
89
94
  throw new LlmError(
90
95
  `The OpenAI-compatible chat-completions adapter cannot represent image content in a ${message.role} message.`,
91
96
  "UNSUPPORTED_CONTENT",
@@ -94,7 +99,10 @@ function assertSupportedImageRoles(messages: readonly Message[]): void {
94
99
  }
95
100
  }
96
101
 
97
- function assertRetainedImagesFit(messages: readonly Message[], maxRequestImageBytes: number): void {
102
+ function assertRetainedImagesFit(
103
+ messages: readonly RequestMessage[],
104
+ maxRequestImageBytes: number,
105
+ ): void {
98
106
  const offloadImages = requiredImageOffload(
99
107
  messages,
100
108
  { representation: "base64", maxBytes: maxRequestImageBytes },
@@ -133,7 +141,7 @@ async function imagePart(
133
141
  }
134
142
 
135
143
  function assistantParts(
136
- message: Message,
144
+ message: Extract<RequestMessage, { role: "assistant" }>,
137
145
  toolNames: Map<string, string>,
138
146
  ): Extract<LanguageModelV4Prompt[number], { role: "assistant" }>["content"] {
139
147
  const parts: Extract<LanguageModelV4Prompt[number], { role: "assistant" }>["content"] = [];
@@ -190,9 +198,6 @@ async function userParts(
190
198
  );
191
199
  parts.push(await resolveImage(block, signal));
192
200
  break;
193
- case "tool-result":
194
- parts.push(...(await userParts(block.content, resolveImage, signal)));
195
- break;
196
201
  default:
197
202
  break;
198
203
  }
@@ -201,7 +206,7 @@ async function userParts(
201
206
  }
202
207
 
203
208
  async function serializePrompt(
204
- messages: readonly Message[],
209
+ messages: readonly RequestMessage[],
205
210
  resolveImage:
206
211
  | ((
207
212
  block: Extract<ContentBlock, { type: "image" }>,
@@ -227,6 +232,20 @@ async function serializePrompt(
227
232
  pendingToolImages = [];
228
233
  };
229
234
  for (const message of messages) {
235
+ // V4 把 developer 消息与 tool-change 块持久化下来,但 provider 侧序列化明确不支持它们
236
+ // (与上游 `llm-deepseek` 同口径:先失败,等生产者与消费方一起实现)。
237
+ if (message.role === "developer") {
238
+ throw new LlmError(
239
+ "The OpenAI-compatible chat-completions adapter cannot serialize developer messages.",
240
+ "UNSUPPORTED_CONTENT",
241
+ );
242
+ }
243
+ if (message.content.some((block) => block.type === "tool-addition" || block.type === "tool-removal")) {
244
+ throw new LlmError(
245
+ "The OpenAI-compatible chat-completions adapter cannot serialize tool-change blocks outside developer messages.",
246
+ "UNSUPPORTED_CONTENT",
247
+ );
248
+ }
230
249
  if (message.role === "system") {
231
250
  flushToolImages();
232
251
  prompt.push({ role: "system", content: flattenText(message.content) });
@@ -238,37 +257,38 @@ async function serializePrompt(
238
257
  if (parts.length > 0) prompt.push({ role: "assistant", content: parts });
239
258
  continue;
240
259
  }
241
- const regular = message.content.filter((block) => block.type !== "tool-result");
242
- const toolResults = message.content.filter((block) => block.type === "tool-result");
243
- const content = await userParts(regular, resolveImage, signal);
244
- if (content.length > 0 || toolResults.length === 0) {
245
- flushToolImages();
246
- prompt.push({ role: "user", content });
247
- }
248
- for (const result of toolResults) {
260
+ if (message.role === "tool") {
261
+ // 结果消息自己带 `toolCallId` 与结果块(V4 起结果不再是 user 消息里的一个块)。
249
262
  const images: UserContentPart[] = [];
250
263
  if (resolveImage !== void 0) {
251
- for (const block of result.content) {
264
+ for (const block of message.content) {
252
265
  if (block.type === "image") images.push(await resolveImage(block, signal));
253
266
  }
254
267
  }
268
+ flushToolImages();
255
269
  prompt.push({
256
270
  role: "tool",
257
271
  content: [
258
272
  {
259
273
  type: "tool-result",
260
- toolCallId: result.toolCallId,
261
- toolName: toolNames.get(result.toolCallId) ?? "",
274
+ toolCallId: message.toolCallId,
275
+ toolName: toolNames.get(message.toolCallId) ?? "",
262
276
  output: {
263
277
  type: "text",
264
278
  value:
265
- flattenText(result.content) ||
279
+ flattenText(message.content) ||
266
280
  (images.length > 0 ? "(see attached image)" : "(no output)"),
267
281
  },
268
282
  },
269
283
  ],
270
284
  });
271
285
  pendingToolImages.push(...images);
286
+ continue;
287
+ }
288
+ const content = await userParts(message.content, resolveImage, signal);
289
+ if (content.length > 0) {
290
+ flushToolImages();
291
+ prompt.push({ role: "user", content });
272
292
  }
273
293
  }
274
294
  flushToolImages();