dsh-gateway-provider 1.0.11 → 1.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.
package/README.md CHANGED
@@ -84,9 +84,11 @@ Everything lives in **Settings → Gateway Models**:
84
84
 
85
85
  ## Configuration reference
86
86
 
87
- Optional — everything below has a working default. Config lives in the
88
- `llm-newapi:` section of `~/.dsh/settings.yaml` (the settings page edits
89
- the same keys). The frequently used ones:
87
+ Optional — everything below has a working default. With dsh ≥ 0.2 config
88
+ lives in the `llm-newapi` entry of the profile
89
+ (`~/.dsh/profiles/<name>/cordis.patch.yml`; the settings page edits the same
90
+ data). A pre-0.2 `~/.dsh/settings.yaml` section is imported automatically on
91
+ the first start where the plugin loads. The frequently used ones:
90
92
 
91
93
  | Key | Default | Meaning |
92
94
  | --- | --- | --- |
package/docs/README.zh.md CHANGED
@@ -52,7 +52,7 @@ dsh --profile web
52
52
 
53
53
  ## 配置参考
54
54
 
55
- 全部可选 —— 下表每项都有可用默认值。配置写在 `~/.dsh/settings.yaml` 的 `llm-newapi:` 段(设置页改的就是同一批键)。常用项:
55
+ 全部可选 —— 下表每项都有可用默认值。dsh ≥ 0.2 配置存在 profile 的 `llm-newapi` entry(`~/.dsh/profiles/<name>/cordis.patch.yml`,设置页改的就是同一份数据);旧版 `~/.dsh/settings.yaml` 的 `llm-newapi:` 段会在插件可加载后的首次启动自动迁入。常用项:
56
56
 
57
57
  | 键 | 默认值 | 含义 |
58
58
  | --- | --- | --- |
package/index.js CHANGED
@@ -39,7 +39,11 @@ import { DEFAULT_ENDPOINT_PRIORITY, deriveProtocolURLs } from "./lib/protocols.j
39
39
  export const name = "llm-newapi";
40
40
  export const inject = ["llm"];
41
41
 
42
- /** User-settings namespace whose section overrides this entry. */
42
+ /**
43
+ * Settings namespace this plugin serves. dsh 0.2 keys settings (and model
44
+ * discovery) by the loader entry id; cordis.patch.yml declares the entry as
45
+ * `llm-newapi`, so the ids coincide and this remains the fallback name.
46
+ */
43
47
  const NS = "llm-newapi";
44
48
  /** The legacy single provider route (kept for backwards compatibility). */
45
49
  export const PROVIDER = "newapi";
@@ -60,7 +64,8 @@ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
60
64
  const DEFAULT_CATALOG_TTL_MS = 30 * 60 * 1000;
61
65
 
62
66
  /**
63
- * String field tolerant of hand-edited YAML scalars. `settings.yaml` is a
67
+ * String field tolerant of hand-edited YAML scalars. The profile patch
68
+ * (`cordis.patch.yml`, and pre-0.2 `settings.yaml` imports) is a
64
69
  * user-editable document parsed with YAML 1.2 core semantics: an unquoted
65
70
  * `true` / `false` / `True` / `FALSE` round-trips as a boolean and an
66
71
  * unquoted digit run as a number, long before the value reaches a strict
@@ -183,7 +188,7 @@ export const Config = z.object({
183
188
  // ---- Additional gateways ----
184
189
  /** Additional gateways; each becomes a `gateway:<id>` provider route. */
185
190
  gateways: z.array(GatewaySchema).default([]),
186
- });
191
+ }).volatile();
187
192
 
188
193
  /**
189
194
  * Normalize one gateway entry into the connection facts the adapter needs.
@@ -306,7 +311,12 @@ export function resolveGateways(config, environment) {
306
311
  }
307
312
 
308
313
  export function apply(ctx, config) {
309
- let current = () => config;
314
+ /** Settings namespace this entry serves: the loader entry id, else the legacy name. */
315
+ const settingsNs = ctx.fiber.entry?.options.id ?? NS;
316
+ // dsh 0.2 hands apply() a volatile config reference (a frozen { get } whose
317
+ // snapshot updates after every settings write); plain objects from older
318
+ // harnesses pass through unchanged.
319
+ const current = () => (typeof config?.get === "function" ? config.get() : config);
310
320
  let lastRaw;
311
321
  let resolved = null;
312
322
  /** Resolve (and cache) the gateway list from the current config snapshot. */
@@ -376,7 +386,7 @@ export function apply(ctx, config) {
376
386
  const entries = gateways.map((g, i) => ({
377
387
  provider: g.provider,
378
388
  displayName: g.label,
379
- settingsNs: NS,
389
+ settingsNs,
380
390
  settingsPath: i === 0 ? [] : ["gateways", String(g.id)],
381
391
  declared: i > 0,
382
392
  }));
@@ -390,7 +400,7 @@ export function apply(ctx, config) {
390
400
  ensureRegistration();
391
401
 
392
402
  /** Expose model discovery so the web UI can fetch a gateway's model list. */
393
- ctx.llm.registerModelDiscovery(NS, async (request) => {
403
+ ctx.llm.registerModelDiscovery(settingsNs, async (request) => {
394
404
  const env = launchEnvironmentOf(ctx);
395
405
  const gateways = resolve();
396
406
  const gw = request.provider !== undefined ? gateways.find((g) => g.provider === request.provider) : gateways[0];
@@ -412,12 +422,18 @@ export function apply(ctx, config) {
412
422
  }
413
423
  });
414
424
 
415
- ctx.inject(["settings"], (settingsCtx) => {
416
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
417
- setSource: (source) => {
418
- current = source;
419
- },
420
- onChange: ensureRegistration,
421
- });
425
+ // dsh 0.2 settings contract: the volatile-marked Config schema is picked
426
+ // up by the settings service automatically (installSection is gone). Settings
427
+ // writes land in the profile and the loader emits loader/volatile-update;
428
+ // re-resolve and re-register then. resolve() holds the last good snapshot
429
+ // when an update arrives invalid, so the catch only guards registration
430
+ // races (e.g. a provider-route conflict on a mid-flight update).
431
+ ctx.on("loader/volatile-update", () => {
432
+ try {
433
+ ensureRegistration();
434
+ } catch (error) {
435
+ ctx.logger.error("llm-newapi: keeping the last good configuration after a settings update");
436
+ ctx.logger.error(error);
437
+ }
422
438
  });
423
439
  }
package/lib/catalog.js CHANGED
@@ -121,15 +121,31 @@ async function fetchManagementModels(baseURL, apiKey, userId, signal) {
121
121
  return entries;
122
122
  }
123
123
 
124
+ /**
125
+ * Strip trailing slashes and a trailing version segment (`/v1`, `/v2`, …)
126
+ * from a gateway base URL. The catalog always appends its own full paths
127
+ * (`/v1/models`, `/api/user/models`), so a user-supplied base that already
128
+ * carries `/v1` (the OpenAI-SDK convention, which the chat path also accepts)
129
+ * must not leak through as `/v1/v1/models`. Mirrors the derivation the custom
130
+ * (protocol-URL) template performs in `protocols.js`.
131
+ */
132
+ function stripVersion(baseURL) {
133
+ return String(baseURL ?? "").replace(/\/+$/, "").replace(/\/v\d+$/, "");
134
+ }
135
+
124
136
  /**
125
137
  * The base URL model discovery fetches `/v1/models` against. Gateways with
126
138
  * protocol URL fields (custom template) derive it from their OpenAI-style
127
139
  * URL; `null` means discovery is impossible (e.g. only an Anthropic address
128
140
  * is configured) and the catalog is exactly the user-declared models.
141
+ * The returned base is version-stripped: the catalog suffixes below always
142
+ * append the version segment themselves.
129
143
  */
130
144
  function discoveryBase(connection) {
131
145
  const base = connection.catalogBase !== undefined ? connection.catalogBase : connection.baseURL;
132
- return typeof base === "string" && base.length > 0 ? base : null;
146
+ if (typeof base !== "string" || base.length === 0) return null;
147
+ const stripped = stripVersion(base);
148
+ return stripped.length > 0 ? stripped : null;
133
149
  }
134
150
 
135
151
  /**
package/lib/client.js CHANGED
@@ -77,7 +77,7 @@ window.__ModuleLoader__.load({
77
77
  responsesURLPlaceholder: "https://gw.example.com/v1/responses",
78
78
  anthropicURLPlaceholder: "https://gw.example.com/v1/messages",
79
79
  catalogHint: "auto:优先 /v1/models,鉴权失败时回退管理 API",
80
- baseURLClearHint: "留空则回退到环境变量 NEWAPI_BASE_URL 或官方云端地址",
80
+ baseURLClearHint: "留空则回退到环境变量 NEWAPI_BASE_URL 或官方云端地址;结尾带不带 /v1 均可",
81
81
  labelClearHint: "留空则回退为 gateway:<id>",
82
82
  keyEnvHint: "留空则继承默认网关的 Key(NEWAPI_API_KEY)",
83
83
  keyKeepHint: "留空保持不变;填写则覆盖存储的 Key",
@@ -137,7 +137,7 @@ window.__ModuleLoader__.load({
137
137
  responsesURLPlaceholder: "https://gw.example.com/v1/responses",
138
138
  anthropicURLPlaceholder: "https://gw.example.com/v1/messages",
139
139
  catalogHint: "auto: prefer /v1/models, fall back to the management API on auth failure",
140
- baseURLClearHint: "Empty falls back to NEWAPI_BASE_URL or the public cloud default",
140
+ baseURLClearHint: "Empty falls back to NEWAPI_BASE_URL or the public cloud default; a trailing /v1 is optional",
141
141
  labelClearHint: "Empty falls back to gateway:<id>",
142
142
  keyEnvHint: "Empty inherits the default gateway's key (NEWAPI_API_KEY)",
143
143
  keyKeepHint: "Empty keeps the stored key; type to overwrite",
@@ -820,8 +820,8 @@ window.__ModuleLoader__.load({
820
820
  var snapRef = React.useRef(null);
821
821
 
822
822
  var load = React.useCallback(function () {
823
- api.settings.describe({}).then(function (res) {
824
- var nsList = res && res.result && res.result.ok ? (res.result.value.namespaces || []) : [];
823
+ api.settings.describe().then(function (res) {
824
+ var nsList = res && res.ok ? (res.value.namespaces || []) : [];
825
825
  var desc = nsList.find(function (n) { return n.ns === NS; });
826
826
  var snap = desc ? desc.value || {} : {};
827
827
  setSnapshot(snap);
@@ -831,8 +831,8 @@ window.__ModuleLoader__.load({
831
831
  (snap.gateways || []).forEach(function (gw) {
832
832
  if (gw.apiKeyEnv && refs.indexOf(gw.apiKeyEnv) === -1) refs.push(gw.apiKeyEnv);
833
833
  });
834
- return api.credentials.describe({ refs: refs }).then(function (cres) {
835
- var creds = (cres && cres.result && cres.result.ok) ? (cres.result.value.credentials || {}) : {};
834
+ return api.credentials.describe(refs).then(function (cres) {
835
+ var creds = (cres && cres.ok) ? (cres.value || {}) : {};
836
836
  setCredentials(creds);
837
837
  }).catch(function () { setCredentials({}); });
838
838
  }).catch(function () { setSnapshot({}); });
@@ -849,9 +849,9 @@ window.__ModuleLoader__.load({
849
849
  /** Mutate one path op into the settings namespace; always reload
850
850
  * afterwards (success resyncs the ref, failure rolls it back). */
851
851
  var mutate = function (ops) {
852
- return api.settings.mutate({ ns: NS, ops: ops }).then(function (res) {
853
- if (!(res && res.result && res.result.ok)) {
854
- console.warn("llm-newapi: settings write failed", res && res.result && res.result.error);
852
+ return api.settings.mutate(NS, ops, undefined).then(function (res) {
853
+ if (!(res && res.ok)) {
854
+ console.warn("llm-newapi: settings write failed", res && res.error);
855
855
  load();
856
856
  return false;
857
857
  }
@@ -897,13 +897,13 @@ window.__ModuleLoader__.load({
897
897
  * An empty baseURL (URL-addressed custom gateway) is sent as
898
898
  * absent so the host uses its configured discovery base. */
899
899
  var discover = function (gwIdx, baseURL, provider) {
900
- return api.llm.discoverModels({ settingsNs: NS, provider: provider, baseURL: baseURL || undefined, apiKey: undefined }).then(function (res) {
901
- if (!(res && res.result && res.result.ok)) {
902
- var raw = res && res.result && res.result.error;
903
- var msg = typeof raw === "string" ? raw : (raw && raw.message) || "";
900
+ return api.llm.discoverModels(NS, { provider: provider, baseURL: baseURL || undefined }).then(function (res) {
901
+ if (!(res && res.ok)) {
902
+ var raw = res && res.error;
903
+ var msg = (raw && raw.message) || "";
904
904
  throw new Error(msg || T.testFail);
905
905
  }
906
- return (res.result.value.models || []);
906
+ return (res.value || []);
907
907
  });
908
908
  };
909
909
 
@@ -993,8 +993,8 @@ window.__ModuleLoader__.load({
993
993
  ? { op: "set", path: [k], value: changes[k] }
994
994
  : { op: "unset", path: [k] });
995
995
  });
996
- if (ops.length > 0) writes.push(api.settings.mutate({ ns: NS, ops: ops }));
997
- if (changes.apiKey !== undefined) writes.push(api.credentials.set({ ref: defaultEnv, value: changes.apiKey }));
996
+ if (ops.length > 0) writes.push(api.settings.mutate(NS, ops, undefined));
997
+ if (changes.apiKey !== undefined) writes.push(api.credentials.set(defaultEnv, changes.apiKey));
998
998
  // Optimistically sync the ref; load() reconciles after settle.
999
999
  var nextSnap = Object.assign({}, snap);
1000
1000
  if (changes.baseURL !== undefined) { if (changes.baseURL.length > 0) nextSnap.baseURL = changes.baseURL; else delete nextSnap.baseURL; }
@@ -1033,16 +1033,16 @@ window.__ModuleLoader__.load({
1033
1033
  if (touched) {
1034
1034
  var nextGateways = gws.slice();
1035
1035
  nextGateways[gwIdx] = updated;
1036
- writes.push(api.settings.mutate({ ns: NS, ops: [{ op: "set", path: ["gateways"], value: nextGateways }] }));
1036
+ writes.push(api.settings.mutate(NS, [{ op: "set", path: ["gateways"], value: nextGateways }], undefined));
1037
1037
  syncGateways(nextGateways);
1038
1038
  }
1039
1039
  if (changes.apiKey !== undefined) {
1040
1040
  var keyRef = changes.apiKeyEnv || gw.apiKeyEnv || defaultEnv;
1041
- writes.push(api.credentials.set({ ref: keyRef, value: changes.apiKey }));
1041
+ writes.push(api.credentials.set(keyRef, changes.apiKey));
1042
1042
  }
1043
1043
  }
1044
1044
  return Promise.all(writes).then(function (results) {
1045
- var ok = results.every(function (r) { return r && r.result && r.result.ok; });
1045
+ var ok = results.every(function (r) { return r === undefined || r === null || (r && r.ok) !== false; });
1046
1046
  load(); // resync the ref with the persisted truth (success or not)
1047
1047
  return { ok: ok, label: ok ? T.configSaved : T.configSaveFail };
1048
1048
  }).catch(function () {
@@ -1068,14 +1068,14 @@ window.__ModuleLoader__.load({
1068
1068
  var nextGateways = gws.concat([entry]);
1069
1069
  syncGateways(nextGateways);
1070
1070
  var writes = [
1071
- api.settings.mutate({ ns: NS, ops: [{ op: "set", path: ["gateways"], value: nextGateways }] }),
1071
+ api.settings.mutate(NS, [{ op: "set", path: ["gateways"], value: nextGateways }], undefined),
1072
1072
  ];
1073
1073
  if (spec.apiKey !== undefined && spec.apiKey.length > 0) {
1074
1074
  var keyEnv = (snapRef.current && snapRef.current.apiKeyEnv) || "NEWAPI_API_KEY";
1075
- writes.push(api.credentials.set({ ref: spec.apiKeyEnv || keyEnv, value: spec.apiKey }));
1075
+ writes.push(api.credentials.set(spec.apiKeyEnv || keyEnv, spec.apiKey));
1076
1076
  }
1077
1077
  return Promise.all(writes).then(function (results) {
1078
- var ok = results.every(function (r) { return r && r.result && r.result.ok; });
1078
+ var ok = results.every(function (r) { return r === undefined || r === null || (r && r.ok) !== false; });
1079
1079
  load();
1080
1080
  return { ok: ok, label: ok ? T.configSaved : T.configSaveFail };
1081
1081
  }).catch(function () {
@@ -1136,7 +1136,7 @@ window.__ModuleLoader__.load({
1136
1136
  );
1137
1137
  }
1138
1138
 
1139
- var inject = ["slots", "connection"];
1139
+ var inject = ["slots", "remote", "remote.settings", "remote.credentials", "remote.llm"];
1140
1140
 
1141
1141
  function apply(ctx) {
1142
1142
  var slots = ctx.get("slots");
@@ -1147,7 +1147,15 @@ window.__ModuleLoader__.load({
1147
1147
  id: "newapi-gateways",
1148
1148
  order: 20,
1149
1149
  label: function () { return T.nav; },
1150
- inject: function () { return { api: ctx.get("connection").api }; },
1150
+ inject: function () {
1151
+ // Typed Remote namespaces (dsh ≥ 0.1.2-rc.1): the old
1152
+ // `ctx.get("connection").api` RPC facade was removed.
1153
+ return { api: {
1154
+ settings: ctx.get("remote.settings"),
1155
+ credentials: ctx.get("remote.credentials"),
1156
+ llm: ctx.get("remote.llm"),
1157
+ } };
1158
+ },
1151
1159
  }, function (props) {
1152
1160
  return React.createElement(GatewayModelsSection, { api: props.api });
1153
1161
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gateway-provider",
3
- "version": "1.0.11",
3
+ "version": "1.1.0",
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",
@@ -29,6 +29,7 @@
29
29
  "smoke": "node test/smoke.mjs",
30
30
  "test:session": "node test/session-affinity.mjs",
31
31
  "test:client": "node test/client-render.mjs",
32
+ "test:client-api": "node test/client-api.mjs",
32
33
  "test:urls": "node test/protocol-urls.mjs",
33
34
  "test:schema": "node test/schema-coercion.mjs",
34
35
  "test:errors": "node test/error-codes.mjs"
@@ -53,8 +54,10 @@
53
54
  "dsh": {
54
55
  "client": {
55
56
  "inject": [
56
- "@deepseek-ai/dsh-client-runtime",
57
- "@deepseek-ai/dsh-client-web-react"
57
+ "@deepseek-ai/dsh-client-connection",
58
+ "@deepseek-ai/dsh-api-gateway",
59
+ "@deepseek-ai/dsh-api-remotes",
60
+ "@deepseek-ai/dsh-client-ui-settings"
58
61
  ],
59
62
  "platform": "web"
60
63
  },
@@ -67,10 +70,10 @@
67
70
  },
68
71
  "peerDependencies": {
69
72
  "@deepseek-ai/cordis": "^4.0.2",
70
- "@deepseek-ai/dsh-credentials": "^0.1.2-rc.1",
71
- "@deepseek-ai/dsh-launch-environment": "^0.1.2-rc.1",
72
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
73
- "@deepseek-ai/dsh-timeout": "^0.1.2-rc.1",
73
+ "@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
76
+ "@deepseek-ai/dsh-timeout": "0.2.0-rc.2",
74
77
  "@deepseek-ai/schemastery": "^3.18.1"
75
78
  }
76
79
  }