dsh-gateway-provider 1.0.2 → 1.0.3

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
@@ -51,7 +51,7 @@ re-deploy.
51
51
 
52
52
  ```bash
53
53
  dsh plugin --profile web add dsh-gateway-provider # latest
54
- dsh plugin --profile web add dsh-gateway-provider@1.0.2 # pinned
54
+ dsh plugin --profile web add dsh-gateway-provider@<version> # pinned
55
55
  ```
56
56
 
57
57
  `dsh plugin add` forwards to `pnpm add` in the profile directory.
@@ -125,6 +125,21 @@ The **Gateway Models** page exposes the same operations with a UI: search,
125
125
  hidden/custom filters with counts, a per-model override editor (placeholders
126
126
  show discovered values), connection test, and save/cancel semantics.
127
127
 
128
+ > ⚠️ **Hand-editing YAML?** `settings.yaml` is parsed with YAML 1.2 core
129
+ > semantics: an unquoted `true` / `false` (any casing) becomes a boolean and
130
+ > an unquoted digit run becomes a number. Every plain string field of the
131
+ > section (`id`, `name`, `label`, `baseURL`, `apiKeyEnv`, `modelsUrl`,
132
+ > `userId`, `reasoningLevels` / `excludePatterns` / `endpointPriority`
133
+ > entries, …) tolerates the coerced form, but the cleanest habit is quoting
134
+ > intent-significant tokens — write `reasoningLevels: ["off", "low",
135
+ > "high"]`, not `reasoningLevels: [off, low, high]` — and never `false`
136
+ > where a level name is meant (it survives as the junk level `"false"` and
137
+ > is silently dropped from the picker). A structurally invalid section
138
+ > (objects where strings belong, etc.) is refused at registration: the
139
+ > namespace stays unregistered and every settings write from the web UI
140
+ > then answers `settings-rejected: settings namespace "llm-newapi" is not
141
+ > registered`.
142
+
128
143
  ## Configuration reference
129
144
 
130
145
  Section `llm-newapi:` of `$DSH_HOME/settings.yaml`. The flat fields
@@ -151,6 +166,7 @@ the `gateways` array support most of the same fields per gateway
151
166
  | `excludePatterns` | image/speech/embed/… | Regex patterns excluding models from the picker |
152
167
  | `endpointPriority` | `["openai-response","anthropic","openai","gemini"]` | Wire-format preference order (first match wins per model) |
153
168
  | `userId` | `1` | `New-Api-User` header for the management API |
169
+ | `headers` | – | Extra HTTP headers (name → value) sent with every provider request; attribution headers (`user-agent`) are filtered out |
154
170
  | `maxTokens` | `32768` | Output cap fallback when models.dev lacks data |
155
171
  | `defaultContextWindow` | `128000` | Context window fallback when models.dev lacks data |
156
172
  | `streamIdleTimeoutMs` | `300000` | Stream idle watchdog |
package/docs/README.zh.md CHANGED
@@ -45,7 +45,7 @@ provider。如果你的模型都在网关后面,就得手工维护一份静态
45
45
 
46
46
  ```bash
47
47
  dsh plugin --profile web add dsh-gateway-provider # 最新版
48
- dsh plugin --profile web add dsh-gateway-provider@1.0.2 # 钉住版本
48
+ dsh plugin --profile web add dsh-gateway-provider@<version> # 钉住版本
49
49
  ```
50
50
 
51
51
  `dsh plugin add` 会在 profile 目录里转发执行 `pnpm add`。bundle 补丁
@@ -113,6 +113,18 @@ llm-newapi:
113
113
  **网关模型**设置页提供同样的操作:搜索、带计数的已隐藏/自定义筛选、逐模型覆写
114
114
  编辑器(placeholder 显示发现值)、连接测试、保存/取消语义。
115
115
 
116
+ > ⚠️ **手工编辑 YAML?** `settings.yaml` 按 YAML 1.2 core 语义解析:不带引号的
117
+ > `true` / `false`(任意大小写)会变成布尔值,不带引号的数字串会变成数值。本节
118
+ > 所有纯字符串字段(`id`、`name`、`label`、`baseURL`、`apiKeyEnv`、`modelsUrl`、
119
+ > `userId`,以及 `reasoningLevels` / `excludePatterns` / `endpointPriority` 的
120
+ > 条目……)都可容忍这些被强转的形式,但最稳妥的习惯是给有含义的 token 加引号
121
+ > ——写 `reasoningLevels: ["off", "low", "high"]`,而不是
122
+ > `reasoningLevels: [off, low, high]`——更不要在该写级别名的地方写 `false`(它会
123
+ > 以垃圾级别 `"false"` 存活,并被静默排除出选择器)。结构上非法的分节(比如字符
124
+ > 串位置出现对象)会在注册时被拒绝:namespace 保持未注册状态,Web 设置页的每次
125
+ > 写入都会得到
126
+ > `settings-rejected: settings namespace "llm-newapi" is not registered`。
127
+
116
128
  ## 配置参考
117
129
 
118
130
  `$DSH_HOME/settings.yaml` 的 `llm-newapi:` 节。扁平字段(`baseURL` /
@@ -139,6 +151,7 @@ llm-newapi:
139
151
  | `excludePatterns` | image/speech/embed/… | 选择器剔除的正则列表 |
140
152
  | `endpointPriority` | `["openai-response","anthropic","openai","gemini"]` | wire 格式优先级(每模型取首个匹配) |
141
153
  | `userId` | `1` | 管理 API 的 `New-Api-User` 头 |
154
+ | `headers` | – | 随每个 provider 请求附带的额外 HTTP 头(名称 → 值);归属头(`user-agent`)会被过滤掉 |
142
155
  | `maxTokens` | `32768` | 无 models.dev 数据时的输出上限兜底 |
143
156
  | `defaultContextWindow` | `128000` | 无 models.dev 数据时的上下文兜底 |
144
157
  | `streamIdleTimeoutMs` | `300000` | 流空闲看门狗 |
package/index.js CHANGED
@@ -60,12 +60,30 @@ export const DEFAULT_MODELS_URL = "https://models.dev/models.json";
60
60
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
61
61
  const DEFAULT_CATALOG_TTL_MS = 30 * 60 * 1000;
62
62
 
63
+ /**
64
+ * String field tolerant of hand-edited YAML scalars. `settings.yaml` is a
65
+ * user-editable document parsed with YAML 1.2 core semantics: an unquoted
66
+ * `true` / `false` / `True` / `FALSE` round-trips as a boolean and an
67
+ * unquoted digit run as a number, long before the value reaches a strict
68
+ * `z.string()` — and because the settings seam refuses to register a
69
+ * namespace whose stored section fails its schema, one such scalar anywhere
70
+ * in the section bricks the whole namespace (the web UI then reports every
71
+ * write as `settings-rejected: settings namespace "llm-newapi" is not
72
+ * registered`). Booleans and finite numbers coerce to their string form;
73
+ * anything else still fails loud with schemastery's own union diagnostic.
74
+ */
75
+ const coercedString = () => z.union([
76
+ z.string(),
77
+ z.transform(z.boolean(), (value) => String(value)),
78
+ z.transform(z.number(), (value) => String(value)),
79
+ ]);
80
+
63
81
  /** Schema for one model-level override on a gateway (all fields optional). */
64
82
  const ModelOverrideSchema = z.object({
65
83
  /** Model id exactly as the gateway accepts it. */
66
- id: z.string(),
84
+ id: coercedString().required(),
67
85
  /** Display name override. */
68
- name: z.string(),
86
+ name: coercedString(),
69
87
  /** Hide this model from the picker. */
70
88
  disabled: z.boolean(),
71
89
  /** Force the wire protocol for this model (openai/anthropic/gemini/…). */
@@ -75,7 +93,7 @@ const ModelOverrideSchema = z.object({
75
93
  /** Override the output-token cap. */
76
94
  maxTokens: z.number().step(1).min(1),
77
95
  /** Reasoning level set (e.g. ["off","low","medium","high"]). */
78
- reasoningLevels: z.array(z.string()),
96
+ reasoningLevels: z.array(coercedString()),
79
97
  });
80
98
 
81
99
  /** Gateway-type templates the settings UI offers (informational labels). */
@@ -88,49 +106,49 @@ const FLAVORS = ["newapi", "litellm", "higress", "openai-compatible", "custom"];
88
106
  */
89
107
  const GatewaySchema = z.object({
90
108
  /** Short stable id; becomes the provider route suffix (`gateway:<id>`). */
91
- id: z.string().required(),
109
+ id: coercedString().required(),
92
110
  /** Human-readable gateway name. */
93
- label: z.string(),
111
+ label: coercedString(),
94
112
  /** Gateway base URL (unused when protocol URL fields are set). */
95
- baseURL: z.string(),
113
+ baseURL: coercedString(),
96
114
  /** Full chat-completions endpoint URL (custom template). */
97
- openaiURL: z.string(),
115
+ openaiURL: coercedString(),
98
116
  /** Full Responses endpoint URL (custom template). */
99
- responsesURL: z.string(),
117
+ responsesURL: coercedString(),
100
118
  /** Full Anthropic messages endpoint URL (custom template). */
101
- anthropicURL: z.string(),
119
+ anthropicURL: coercedString(),
102
120
  /** Environment-variable name (credential ref) holding the API key. */
103
- apiKeyEnv: z.string().role("credential-ref"),
121
+ apiKeyEnv: coercedString().role("credential-ref"),
104
122
  /** Gateway type label (newapi/litellm/higress/openai-compatible/custom). */
105
123
  flavor: z.union(FLAVORS),
106
124
  /** Model-list source: `auto` (prefer /v1/models), `v1`, or `management`. */
107
125
  catalogMode: z.union(["auto", "v1", "management"]),
108
126
  /** User id sent to the management API when it is used. */
109
- userId: z.string(),
127
+ userId: coercedString(),
110
128
  /** Per-model overrides and custom models. */
111
129
  models: z.array(ModelOverrideSchema),
112
130
  /** Wire-format preference order for this gateway. */
113
- endpointPriority: z.array(z.string()),
131
+ endpointPriority: z.array(coercedString()),
114
132
  });
115
133
 
116
134
  export const Config = z.object({
117
135
  // ---- Legacy single-connection fields (build the default `newapi` route) ----
118
136
  /** Display name of the default gateway route (defaults to "NewAPI"). */
119
- label: z.string(),
137
+ label: coercedString(),
120
138
  /** Environment-variable name (credential ref) holding the default gateway API key. */
121
- apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
139
+ apiKeyEnv: coercedString().role("credential-ref").default(DEFAULT_API_KEY_ENV),
122
140
  /** Default gateway base URL; resolved from NEWAPI_BASE_URL / NEWAPI_API_URL then the public cloud default. */
123
- baseURL: z.string(),
141
+ baseURL: coercedString(),
124
142
  /** Full chat-completions endpoint URL (custom template for the default gateway). */
125
- openaiURL: z.string(),
143
+ openaiURL: coercedString(),
126
144
  /** Full Responses endpoint URL (custom template for the default gateway). */
127
- responsesURL: z.string(),
145
+ responsesURL: coercedString(),
128
146
  /** Full Anthropic messages endpoint URL (custom template for the default gateway). */
129
- anthropicURL: z.string(),
147
+ anthropicURL: coercedString(),
130
148
  /** Gateway type label shown in the UI (newapi/litellm/higress/openai-compatible/custom). */
131
149
  flavor: z.union(FLAVORS).default("newapi"),
132
150
  /** models.dev catalog URL (any fetch-able URL; file: works for offline mirrors). */
133
- modelsUrl: z.string().default(DEFAULT_MODELS_URL),
151
+ modelsUrl: coercedString().default(DEFAULT_MODELS_URL),
134
152
  /** Enrich gateway models with models.dev parameters. */
135
153
  useModelsDev: z.boolean().default(true),
136
154
  /** Widen the unknown-model reasoning fallback to the full off~max set. */
@@ -144,11 +162,17 @@ export const Config = z.object({
144
162
  /** Exclude non-chat model families from the picker. */
145
163
  includeChatOnly: z.boolean().default(true),
146
164
  /** Regex patterns excluding models from the picker. */
147
- excludePatterns: z.array(z.string()).default(DEFAULT_EXCLUDE_PATTERNS),
165
+ excludePatterns: z.array(coercedString()).default(DEFAULT_EXCLUDE_PATTERNS),
148
166
  /** Wire-format preference order, intersected with each model's supported types. */
149
- endpointPriority: z.array(z.string()).default(DEFAULT_ENDPOINT_PRIORITY),
167
+ endpointPriority: z.array(coercedString()).default(DEFAULT_ENDPOINT_PRIORITY),
150
168
  /** User id sent to the management API when it is used. */
151
- userId: z.string().default("1"),
169
+ userId: coercedString().default("1"),
170
+ /**
171
+ * Extra HTTP headers sent with every provider request to the default
172
+ * gateway (name → value). Reserved attribution headers (user-agent) are
173
+ * filtered out at request time; per-request values are unaffected.
174
+ */
175
+ headers: z.dict(coercedString()),
152
176
  /** Fallback output-token cap for models without models.dev data. */
153
177
  maxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
154
178
  /** Fallback context window for models without models.dev data. */
package/lib/pi-bridge.js CHANGED
@@ -344,10 +344,25 @@ export function toPiContext(options, attachments) {
344
344
  // Stream translation (pi-ai EventStream → harness StreamChunks)
345
345
  // ---------------------------------------------------------------------------
346
346
 
347
- /** Classify a pi-ai error message into a harness error code. */
347
+ /**
348
+ * Classify a pi-ai error message into a harness error code.
349
+ * Lifted from dsh-llm-pi-ai/stream (rc.7): the coarse codes matter because
350
+ * the harness retry stack (dsh-llm-retry) retries exactly RATE_LIMIT /
351
+ * SERVER / TIMEOUT / TRANSPORT / EMPTY_RESPONSE — a generic fallback code
352
+ * would make gateway blips unretryable.
353
+ */
348
354
  function classifyPiAiError(text) {
355
+ if (/\b(?:401|403)\b/.test(text)) return "AUTH";
349
356
  if (isQuotaExceededError(text)) return QUOTA_EXCEEDED_CODE;
350
- return "PROVIDER_ERROR";
357
+ if (/\b429\b|rate.?limit/i.test(text)) return "RATE_LIMIT";
358
+ if (/\b400\b|invalid.?request/i.test(text)) return "INVALID_REQUEST";
359
+ if (/\b5\d\d\b/.test(text)) return "SERVER";
360
+ if (/\btime(?:d)?\s*out\b|timeout/i.test(text)) return "TIMEOUT";
361
+ if (/stream ended (?:before|without)\b/i.test(text)) return "TRANSPORT";
362
+ if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(text)
363
+ || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(text)
364
+ || /\bterminated\b|premature close/i.test(text)) return "TRANSPORT";
365
+ return "PI_AI_ERROR";
351
366
  }
352
367
 
353
368
  /** Map pi-ai usage (reasoning folded into output by pi-ai). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gateway-provider",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Generic LLM gateway model provider plugin for DeepSeek Harness: mounts newapi / LiteLLM / Higress / any OpenAI-compatible gateway as provider routes, auto-discovers the model list, enriches every model with models.dev parameters, and dispatches each model over its native wire protocol (OpenAI / Anthropic / Gemini) via the pi-ai SDK",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,9 @@
27
27
  "link": "bash scripts/link.sh",
28
28
  "smoke": "node test/smoke.mjs",
29
29
  "test:client": "node test/client-render.mjs",
30
- "test:urls": "node test/protocol-urls.mjs"
30
+ "test:urls": "node test/protocol-urls.mjs",
31
+ "test:schema": "node test/schema-coercion.mjs",
32
+ "test:errors": "node test/error-codes.mjs"
31
33
  },
32
34
  "keywords": [
33
35
  "deepseek-harness",
@@ -61,12 +63,11 @@
61
63
  "dependencies": {},
62
64
  "peerDependencies": {
63
65
  "@deepseek-ai/cordis": "^4.0.1",
64
- "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6",
65
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
66
- "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6",
67
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
68
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
69
- "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6",
66
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.7",
67
+ "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.7",
68
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
69
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
70
+ "@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
70
71
  "@deepseek-ai/schemastery": "^3.18.1",
71
72
  "@earendil-works/pi-ai": "^0.82.1"
72
73
  }