chainlesschain 0.166.132 → 0.166.133

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.
@@ -77,8 +77,16 @@ function captureEmbeddingTransport(stage, sdk) {
77
77
  };
78
78
  }
79
79
 
80
- export function createGovernedHubResolver(ports, factory, sdkPorts) {
81
- if (typeof factory !== "function")
80
+ export function createGovernedHubResolver(
81
+ ports,
82
+ factory,
83
+ sdkPorts,
84
+ options = {},
85
+ ) {
86
+ if (
87
+ typeof factory !== "function" &&
88
+ !(factory === null && options.evolutionDisabled === true)
89
+ )
82
90
  throw new TypeError("A resolver evolution factory is required");
83
91
  const {
84
92
  resolver,
@@ -102,13 +110,14 @@ export function createGovernedHubResolver(ports, factory, sdkPorts) {
102
110
  const turn = await prepareGovernedModelTurn(factory, {
103
111
  mode: "hub-embedding",
104
112
  messages: [{ role: "user", content: profile }],
113
+ evolutionDisabled: options.evolutionDisabled === true,
105
114
  });
106
115
  // Chat projection prepends host provenance. Embed only the projected
107
116
  // profile: per-Run evidence IDs must not change semantic similarity.
108
- const profileMessage = turn.messages[1];
117
+ const profileMessage = turn.messages[turn.governed ? 1 : 0];
109
118
  if (
110
- turn.messages.length !== 2 ||
111
- turn.messages[0].role !== "system" ||
119
+ turn.messages.length !== (turn.governed ? 2 : 1) ||
120
+ (turn.governed && turn.messages[0].role !== "system") ||
112
121
  profileMessage?.role !== "user" ||
113
122
  typeof profileMessage.content !== "string"
114
123
  )
@@ -133,7 +142,7 @@ export function createGovernedHubResolver(ports, factory, sdkPorts) {
133
142
  const model =
134
143
  resolver._llmStage &&
135
144
  new LLMStage({
136
- llm: createGovernedHubLlm(llm, factory, {}, sdk),
145
+ llm: createGovernedHubLlm(llm, factory, options, sdk),
137
146
  acceptNonLocal: false,
138
147
  });
139
148
  return new EntityResolver({
@@ -9,6 +9,7 @@ export async function runGovernedHubSkill(
9
9
  name,
10
10
  options,
11
11
  sdkPorts,
12
+ modelTurnOptions = {},
12
13
  ) {
13
14
  let failure;
14
15
  let failed = false;
@@ -16,6 +17,7 @@ export async function runGovernedHubSkill(
16
17
  hub.llm,
17
18
  factory,
18
19
  {
20
+ evolutionDisabled: modelTurnOptions.evolutionDisabled === true,
19
21
  onFailure(error) {
20
22
  failure = error;
21
23
  failed = true;
@@ -12,9 +12,20 @@ export async function loadConfiguredUiEvolutionFactory(options = {}) {
12
12
  }
13
13
 
14
14
  export async function resolveServerEvolutionFactory(server) {
15
- return typeof server.resolveEvolutionCompositionFactory === "function"
15
+ return typeof server?.resolveEvolutionCompositionFactory === "function"
16
16
  ? server.resolveEvolutionCompositionFactory()
17
- : (server.evolutionCompositionFactory ?? null);
17
+ : (server?.evolutionCompositionFactory ?? null);
18
+ }
19
+
20
+ /** Apply the host's current UI policy for every turn, including old sessions. */
21
+ export async function prepareServerUiModelTurn(server, context) {
22
+ if (typeof server?.prepareUiModelTurn === "function") {
23
+ return server.prepareUiModelTurn(context);
24
+ }
25
+ return prepareGovernedModelTurn(
26
+ await resolveServerEvolutionFactory(server),
27
+ context,
28
+ );
18
29
  }
19
30
 
20
31
  export async function prepareConfiguredUiModelTurn(context, options = {}) {
@@ -35,7 +35,12 @@
35
35
  * so an intentionally-custom endpoint is left untouched.
36
36
  */
37
37
  export async function reconcileConfigLlmProvider(cfgLlm = {}) {
38
- if (!cfgLlm || !cfgLlm.provider || !cfgLlm.baseUrl) {
38
+ if (
39
+ !cfgLlm ||
40
+ !cfgLlm.provider ||
41
+ !cfgLlm.baseUrl ||
42
+ cfgLlm.provider === "custom"
43
+ ) {
39
44
  return { llm: cfgLlm, changed: false };
40
45
  }
41
46
  const { inferProviderFromBaseUrl, modelForeignToProvider } = await import(
@@ -71,22 +76,29 @@ export function applyConfigLlmDefaults(options = {}, cfgLlm = {}, opts = {}) {
71
76
  // credentials are exactly right. Mixing config into a DIFFERENT explicit
72
77
  // provider is still avoided (handled by the early return below).
73
78
  if (options.provider && configMatchesTarget) {
74
- if (!options.baseUrl && cfgLlm.baseUrl) options.baseUrl = connection.baseUrl;
79
+ if (!options.baseUrl && cfgLlm.baseUrl)
80
+ options.baseUrl = connection.baseUrl;
75
81
  if (options.serviceTier == null && cfgLlm.serviceTier != null)
76
82
  options.serviceTier = cfgLlm.serviceTier;
77
- if (!options.apiKey && connection.apiKey) options.apiKey = connection.apiKey;
83
+ if (!options.apiKey && connection.apiKey)
84
+ options.apiKey = connection.apiKey;
78
85
  if (!options.model && !opts.explicitModel && cfgLlm.model) {
79
86
  options.model = cfgLlm.model;
80
87
  }
81
88
  return options;
82
89
  }
83
90
  if (options.provider) {
84
- if (!options.apiKey && connection.apiKey) options.apiKey = connection.apiKey;
91
+ if (!options.apiKey && connection.apiKey)
92
+ options.apiKey = connection.apiKey;
85
93
  return options;
86
94
  }
87
95
  if (!cfgLlm.provider) return options;
88
96
  options.provider = connection.provider;
89
- if (configMatchesTarget && options.serviceTier == null && cfgLlm.serviceTier != null)
97
+ if (
98
+ configMatchesTarget &&
99
+ options.serviceTier == null &&
100
+ cfgLlm.serviceTier != null
101
+ )
90
102
  options.serviceTier = cfgLlm.serviceTier;
91
103
  if (opts.explicitModel) {
92
104
  options.model = opts.explicitModel;
@@ -31,7 +31,10 @@ export function normalizeLlmConnection(input) {
31
31
  if (value[key].length > (key === "apiKey" ? 8192 : 2048))
32
32
  throw new Error(`${key} is too long`);
33
33
  }
34
- if (!Object.hasOwn(BUILT_IN_PROVIDERS, value.provider))
34
+ if (
35
+ value.provider !== "custom" &&
36
+ !Object.hasOwn(BUILT_IN_PROVIDERS, value.provider)
37
+ )
35
38
  throw new Error("Choose a supported provider/protocol");
36
39
  if (!value.model || /[\r\n\0]/.test(value.model + value.visionModel))
37
40
  throw new Error("A valid model name is required");
@@ -84,7 +87,7 @@ export function saveLlmConnection(input, { storage = "auto" } = {}) {
84
87
  current.baseUrl || BUILT_IN_PROVIDERS[current.provider]?.baseUrl || "",
85
88
  ).replace(/\/+$/, "") === value.baseUrl;
86
89
  if (
87
- value.provider !== "ollama" &&
90
+ !["ollama", "custom"].includes(value.provider) &&
88
91
  !value.apiKey &&
89
92
  (!sameEndpoint || !current.apiKey)
90
93
  )
@@ -98,6 +101,11 @@ export function saveLlmConnection(input, { storage = "auto" } = {}) {
98
101
  baseUrl: value.baseUrl,
99
102
  visionModel: value.visionModel || null,
100
103
  };
104
+ // A keyless custom endpoint must never receive the previous target's key.
105
+ if (!sameEndpoint && !value.apiKey && value.provider === "custom") {
106
+ delete config.llm.apiKey;
107
+ delete config.llm.apiKeyHelper;
108
+ }
101
109
  };
102
110
  // A single lock and atomic rename bind endpoint and credential together.
103
111
  // An interrupted save cannot route the old credential to the new endpoint.
@@ -3,6 +3,7 @@ import { estimateCost } from "./llm-pricing.js";
3
3
  import { providerFetch } from "./formal-budget-provider-scope.js";
4
4
  import { isFormalBudgetError } from "./formal-budget-errors.js";
5
5
  import { normalizeOllamaBaseUrl } from "./ollama-base-url.js";
6
+ import { assertCustomProviderConnection } from "./provider-protocol.js";
6
7
  import { resolveModelCapabilityProfile } from "./model-capabilities.js";
7
8
  import {
8
9
  projectEnvelopePricingContext,
@@ -159,6 +160,7 @@ export async function probeLlmConnection(
159
160
  target,
160
161
  { fetchImpl = fetch, timeoutMs = 20_000, includeUsage = false } = {},
161
162
  ) {
163
+ assertCustomProviderConnection(target.provider, target);
162
164
  const base =
163
165
  target.provider === "ollama"
164
166
  ? normalizeOllamaBaseUrl(target.baseUrl)
@@ -183,7 +185,8 @@ export async function probeLlmConnection(
183
185
  options: { num_predict: PROBE_OUTPUT_TOKENS },
184
186
  };
185
187
  } else {
186
- if (!target.apiKey) throw new Error("API key required");
188
+ if (!target.apiKey && target.provider !== "custom")
189
+ throw new Error("API key required");
187
190
  if (target.provider === "anthropic") {
188
191
  url = `${base}/messages`;
189
192
  headers["x-api-key"] = target.apiKey;
@@ -211,7 +214,7 @@ export async function probeLlmConnection(
211
214
  });
212
215
  } else {
213
216
  url = `${base}/chat/completions`;
214
- headers.Authorization = `Bearer ${target.apiKey}`;
217
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
215
218
  body = {
216
219
  model: target.model,
217
220
  messages: [{ role: "user", content: "Say hi in one word." }],
@@ -1,6 +1,9 @@
1
1
  import { BUILT_IN_PROVIDERS } from "./llm-providers.js";
2
2
  import { normalizeOllamaBaseUrl } from "./ollama-base-url.js";
3
- import { normalizeProviderBaseUrl } from "./provider-protocol.js";
3
+ import {
4
+ assertCustomProviderConnection,
5
+ normalizeProviderBaseUrl,
6
+ } from "./provider-protocol.js";
4
7
  import { resolveApiKeyFromHelper } from "./api-key-helper.js";
5
8
 
6
9
  const DEFAULT_PROVIDER = "ollama";
@@ -33,9 +36,8 @@ export function resolveLlmConnection(
33
36
  defaultBaseUrl = null,
34
37
  } = {},
35
38
  ) {
36
- const sources = (Array.isArray(configuredSources)
37
- ? configuredSources
38
- : [configuredSources]
39
+ const sources = (
40
+ Array.isArray(configuredSources) ? configuredSources : [configuredSources]
39
41
  ).filter((source) => source && typeof source === "object");
40
42
  const provider =
41
43
  (nonEmpty(options.provider) && options.provider) ||
@@ -67,6 +69,7 @@ export function resolveLlmConnection(
67
69
  defaultModel ||
68
70
  definition?.models?.[0] ||
69
71
  null;
72
+ assertCustomProviderConnection(provider, { baseUrl, model });
70
73
  let apiKey =
71
74
  (nonEmpty(options.apiKey) && options.apiKey) ||
72
75
  (nonEmpty(env?.CC_API_KEY) && env.CC_API_KEY) ||
@@ -127,6 +127,19 @@ export const BUILT_IN_PROVIDERS = {
127
127
  },
128
128
  };
129
129
 
130
+ /** Custom platforms use Chat Completions with an explicit endpoint and model. */
131
+ export function resolveProviderDefinition(provider) {
132
+ return provider === "custom"
133
+ ? {
134
+ name: "custom",
135
+ baseUrl: null,
136
+ models: [],
137
+ apiKeyEnv: "CC_API_KEY",
138
+ custom: true,
139
+ }
140
+ : BUILT_IN_PROVIDERS[provider];
141
+ }
142
+
130
143
  // A connectivity probe (`testProvider`) sends a tiny "Hi" and expects a quick
131
144
  // reply, so it must fail fast — a probe that hangs forever defeats its purpose
132
145
  // (it backs `cc llm test`, provider selection, doctor). Bound it with a total
@@ -402,17 +415,17 @@ export class LLMProviderRegistry {
402
415
  const res = await _fetchWithTimeout(
403
416
  `${normalizeProviderBaseUrl(provider.baseUrl)}/messages`,
404
417
  {
405
- method: "POST",
406
- headers: {
407
- "Content-Type": "application/json",
408
- "x-api-key": key,
409
- "anthropic-version": "2023-06-01",
410
- },
411
- body: JSON.stringify({
412
- model: testModel,
413
- max_tokens: 10,
414
- messages: [{ role: "user", content: "Hi" }],
415
- }),
418
+ method: "POST",
419
+ headers: {
420
+ "Content-Type": "application/json",
421
+ "x-api-key": key,
422
+ "anthropic-version": "2023-06-01",
423
+ },
424
+ body: JSON.stringify({
425
+ model: testModel,
426
+ max_tokens: 10,
427
+ messages: [{ role: "user", content: "Hi" }],
428
+ }),
416
429
  },
417
430
  );
418
431
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -103,7 +103,8 @@ function sanitizeReasoningItems(items) {
103
103
  type: "reasoning",
104
104
  ...(id ? { id } : {}),
105
105
  ...(encryptedContent ? { encrypted_content: encryptedContent } : {}),
106
- ...(summary.length ? { summary } : {}),
106
+ // Responses requires this field even when no summary was requested.
107
+ summary,
107
108
  ...(status ? { status } : {}),
108
109
  });
109
110
  }
@@ -644,35 +644,44 @@ async function initHub() {
644
644
  // collector can pull WebView cookies from the user's Android Bilibili
645
645
  // App. The factory is a pure function — no side effects until the
646
646
  // handler is invoked, so cost of always-registering is zero.
647
- const { createBilibiliCookiesExtension } =
648
- await import("@chainlesschain/personal-data-hub/adapters/social-bilibili-adb");
647
+ const { createBilibiliCookiesExtension } = await import(
648
+ "@chainlesschain/personal-data-hub/adapters/social-bilibili-adb"
649
+ );
649
650
  // Phase 2a: register `douyin.pull-im-db` extension so the
650
651
  // douyinAdbSync hub method can pull <uid>_im.db cohort from the
651
652
  // user's Android Douyin App.
652
653
  const { createDouyinDbExtension, createDouyinWatchExtension } =
653
- await import("@chainlesschain/personal-data-hub/adapters/social-douyin-adb");
654
+ await import(
655
+ "@chainlesschain/personal-data-hub/adapters/social-douyin-adb"
656
+ );
654
657
  // Phase 3a: register `weibo.cookies` extension for the Weibo
655
658
  // C-path collector (m.weibo.cn cookies + 4 HTTP endpoints).
656
- const { createWeiboCookiesExtension } =
657
- await import("@chainlesschain/personal-data-hub/adapters/social-weibo-adb");
659
+ const { createWeiboCookiesExtension } = await import(
660
+ "@chainlesschain/personal-data-hub/adapters/social-weibo-adb"
661
+ );
658
662
  // Phase 3c: register `xhs.cookies` extension for the Xhs C-path
659
663
  // collector (xiaohongshu.com cookies + 4 endpoints with X-S sign).
660
- const { createXhsCookiesExtension } =
661
- await import("@chainlesschain/personal-data-hub/adapters/social-xiaohongshu-adb");
664
+ const { createXhsCookiesExtension } = await import(
665
+ "@chainlesschain/personal-data-hub/adapters/social-xiaohongshu-adb"
666
+ );
662
667
  // Phase 6c: register `toutiao.cookies` extension for the Toutiao
663
668
  // C-path collector (www.toutiao.com cookies + 4 endpoints, 3 of
664
669
  // which need _signature signing — CLI context falls back to -99
665
670
  // short-circuit, desktop wiring upgrades via ToutiaoSignBridge).
666
671
  const { createToutiaoCookiesExtension, createToutiaoAccountExtension } =
667
- await import("@chainlesschain/personal-data-hub/adapters/social-toutiao-adb");
672
+ await import(
673
+ "@chainlesschain/personal-data-hub/adapters/social-toutiao-adb"
674
+ );
668
675
  // Phase 6d: register `kuaishou.cookies` extension. Profile from
669
676
  // cookie's api_ph payload (no HTTP); 3 GraphQL POST endpoints need
670
677
  // __NS_sig3 + kpf/kpn — CLI short-circuits, desktop upgrades via
671
678
  // KuaishouSignBridge.
672
- const { createKuaishouCookiesExtension } =
673
- await import("@chainlesschain/personal-data-hub/adapters/social-kuaishou-adb");
674
- const { createWhatsAppBackupExtension } =
675
- await import("@chainlesschain/personal-data-hub/adapters/messaging-whatsapp");
679
+ const { createKuaishouCookiesExtension } = await import(
680
+ "@chainlesschain/personal-data-hub/adapters/social-kuaishou-adb"
681
+ );
682
+ const { createWhatsAppBackupExtension } = await import(
683
+ "@chainlesschain/personal-data-hub/adapters/messaging-whatsapp"
684
+ );
676
685
  hostAdbBridge = createHostAdbBridge({
677
686
  extensions: {
678
687
  "bilibili.cookies": createBilibiliCookiesExtension(),
@@ -1105,16 +1114,20 @@ async function initHub() {
1105
1114
  alipayAccountsPath,
1106
1115
  entityResolver,
1107
1116
  aichatAccountsStore,
1108
- async drainResolver(options = {}, factory = null) {
1109
- if (typeof factory !== "function") {
1117
+ async drainResolver(options = {}, factory = null, modelTurnOptions = {}) {
1118
+ if (
1119
+ typeof factory !== "function" &&
1120
+ !(factory === null && modelTurnOptions.evolutionDisabled === true)
1121
+ ) {
1110
1122
  const error = new Error(
1111
1123
  "Personal Data Hub resolver egress requires an authenticated evolution composition factory",
1112
1124
  );
1113
1125
  error.code = "CC_AGENT_EVOLUTION_INGRESS_FAILED";
1114
1126
  throw error;
1115
1127
  }
1116
- const { createGovernedHubResolver } =
1117
- await import("./evolution/governed-hub-resolver.js");
1128
+ const { createGovernedHubResolver } = await import(
1129
+ "./evolution/governed-hub-resolver.js"
1130
+ );
1118
1131
  const scoped = createGovernedHubResolver(
1119
1132
  {
1120
1133
  resolver: entityResolver,
@@ -1125,6 +1138,8 @@ async function initHub() {
1125
1138
  LLMStage: EntityResolverLLMStage,
1126
1139
  },
1127
1140
  factory,
1141
+ undefined,
1142
+ modelTurnOptions,
1128
1143
  );
1129
1144
  return scoped.drain(options);
1130
1145
  },
@@ -1656,8 +1671,9 @@ async function initHub() {
1656
1671
  }
1657
1672
  let collector;
1658
1673
  try {
1659
- const mod =
1660
- await import("@chainlesschain/personal-data-hub/adapters/social-bilibili-adb");
1674
+ const mod = await import(
1675
+ "@chainlesschain/personal-data-hub/adapters/social-bilibili-adb"
1676
+ );
1661
1677
  collector = mod.default ? mod.default : mod;
1662
1678
  } catch (err) {
1663
1679
  return {
@@ -1708,8 +1724,9 @@ async function initHub() {
1708
1724
  }
1709
1725
  let collector;
1710
1726
  try {
1711
- const mod =
1712
- await import("@chainlesschain/personal-data-hub/adapters/social-douyin-adb");
1727
+ const mod = await import(
1728
+ "@chainlesschain/personal-data-hub/adapters/social-douyin-adb"
1729
+ );
1713
1730
  collector = mod.default ? mod.default : mod;
1714
1731
  } catch (err) {
1715
1732
  return {
@@ -1752,8 +1769,9 @@ async function initHub() {
1752
1769
  }
1753
1770
  let collector;
1754
1771
  try {
1755
- const mod =
1756
- await import("@chainlesschain/personal-data-hub/adapters/social-douyin-adb");
1772
+ const mod = await import(
1773
+ "@chainlesschain/personal-data-hub/adapters/social-douyin-adb"
1774
+ );
1757
1775
  collector = mod.default ? mod.default : mod;
1758
1776
  } catch (err) {
1759
1777
  return {
@@ -1799,8 +1817,9 @@ async function initHub() {
1799
1817
  }
1800
1818
  let collector;
1801
1819
  try {
1802
- const mod =
1803
- await import("@chainlesschain/personal-data-hub/adapters/social-weibo-adb");
1820
+ const mod = await import(
1821
+ "@chainlesschain/personal-data-hub/adapters/social-weibo-adb"
1822
+ );
1804
1823
  collector = mod.default ? mod.default : mod;
1805
1824
  } catch (err) {
1806
1825
  return {
@@ -1852,8 +1871,9 @@ async function initHub() {
1852
1871
  }
1853
1872
  let collector;
1854
1873
  try {
1855
- const mod =
1856
- await import("@chainlesschain/personal-data-hub/adapters/social-xiaohongshu-adb");
1874
+ const mod = await import(
1875
+ "@chainlesschain/personal-data-hub/adapters/social-xiaohongshu-adb"
1876
+ );
1857
1877
  collector = mod.default ? mod.default : mod;
1858
1878
  } catch (err) {
1859
1879
  return {
@@ -1905,8 +1925,9 @@ async function initHub() {
1905
1925
  }
1906
1926
  let collector;
1907
1927
  try {
1908
- const mod =
1909
- await import("@chainlesschain/personal-data-hub/adapters/social-toutiao-adb");
1928
+ const mod = await import(
1929
+ "@chainlesschain/personal-data-hub/adapters/social-toutiao-adb"
1930
+ );
1910
1931
  collector = mod.default ? mod.default : mod;
1911
1932
  } catch (err) {
1912
1933
  return {
@@ -1960,8 +1981,9 @@ async function initHub() {
1960
1981
  }
1961
1982
  let collector;
1962
1983
  try {
1963
- const mod =
1964
- await import("@chainlesschain/personal-data-hub/adapters/social-kuaishou-adb");
1984
+ const mod = await import(
1985
+ "@chainlesschain/personal-data-hub/adapters/social-kuaishou-adb"
1986
+ );
1965
1987
  collector = mod.default ? mod.default : mod;
1966
1988
  } catch (err) {
1967
1989
  return {
@@ -2149,11 +2171,19 @@ export async function getHub() {
2149
2171
  return _initPromise;
2150
2172
  }
2151
2173
 
2152
- export async function getGovernedAnalysisHub(evolutionCompositionFactory) {
2174
+ export async function getGovernedAnalysisHub(
2175
+ evolutionCompositionFactory,
2176
+ modelTurnOptions = {},
2177
+ ) {
2153
2178
  const hub = await getHub();
2154
- const { createGovernedHubLlm } =
2155
- await import("./evolution/governed-hub-llm.js");
2156
- const llm = createGovernedHubLlm(hub.engine.llm, evolutionCompositionFactory);
2179
+ const { createGovernedHubLlm } = await import(
2180
+ "./evolution/governed-hub-llm.js"
2181
+ );
2182
+ const llm = createGovernedHubLlm(
2183
+ hub.engine.llm,
2184
+ evolutionCompositionFactory,
2185
+ modelTurnOptions,
2186
+ );
2157
2187
  const engine = new AnalysisEngine({
2158
2188
  vault: hub.engine.vault,
2159
2189
  llm,
@@ -2162,8 +2192,9 @@ export async function getGovernedAnalysisHub(evolutionCompositionFactory) {
2162
2192
  maxQueryLimit: hub.engine.maxQueryLimit,
2163
2193
  systemPrompt: hub.engine.systemPrompt,
2164
2194
  });
2165
- const { runGovernedHubSkill } =
2166
- await import("./evolution/governed-hub-skill.js");
2195
+ const { runGovernedHubSkill } = await import(
2196
+ "./evolution/governed-hub-skill.js"
2197
+ );
2167
2198
  return Object.freeze({
2168
2199
  ...hub,
2169
2200
  llm,
@@ -2175,6 +2206,8 @@ export async function getGovernedAnalysisHub(evolutionCompositionFactory) {
2175
2206
  runAnalysisSkill,
2176
2207
  name,
2177
2208
  options,
2209
+ undefined,
2210
+ modelTurnOptions,
2178
2211
  ),
2179
2212
  });
2180
2213
  }
@@ -4,6 +4,29 @@ export function normalizeProviderBaseUrl(baseUrl) {
4
4
  return String(baseUrl || "").replace(/\/+$/u, "");
5
5
  }
6
6
 
7
+ /** Validate custom targets without assuming a vendor, path, model, or key. */
8
+ export function assertCustomProviderConnection(provider, { baseUrl, model }) {
9
+ if (provider !== "custom") return;
10
+ let url;
11
+ try {
12
+ url = new URL(baseUrl);
13
+ } catch {
14
+ throw new Error("Custom provider requires an HTTP(S) API base URL");
15
+ }
16
+ if (
17
+ !["http:", "https:"].includes(url.protocol) ||
18
+ url.username ||
19
+ url.password ||
20
+ url.search ||
21
+ url.hash
22
+ )
23
+ throw new Error(
24
+ "Custom provider requires an HTTP(S) API base URL without credentials, query or fragment",
25
+ );
26
+ if (typeof model !== "string" || !model.trim())
27
+ throw new Error("Custom provider requires an explicit model name");
28
+ }
29
+
7
30
  /**
8
31
  * Google's OpenAI-compatible API lives below `/v1beta/openai`. The provider
9
32
  * registry stores the native API root (`/v1beta`), so adapt only that exact
@@ -28,7 +51,11 @@ export function openAICompatibleBaseUrl(provider, baseUrl) {
28
51
  return base;
29
52
  }
30
53
 
31
- export function geminiGenerateContentUrl(baseUrl, model, { stream = false } = {}) {
54
+ export function geminiGenerateContentUrl(
55
+ baseUrl,
56
+ model,
57
+ { stream = false } = {},
58
+ ) {
32
59
  const action = stream ? "streamGenerateContent" : "generateContent";
33
60
  const endpoint = `${normalizeProviderBaseUrl(baseUrl)}/models/${encodeURIComponent(model)}:${action}`;
34
61
  return stream ? `${endpoint}?alt=sse` : endpoint;
@@ -58,7 +85,10 @@ export function geminiRequestBody(
58
85
  continue;
59
86
  }
60
87
  contents.push({
61
- role: message.role === "assistant" || message.role === "model" ? "model" : "user",
88
+ role:
89
+ message.role === "assistant" || message.role === "model"
90
+ ? "model"
91
+ : "user",
62
92
  parts,
63
93
  });
64
94
  }
@@ -66,7 +96,9 @@ export function geminiRequestBody(
66
96
  if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
67
97
  return {
68
98
  contents,
69
- ...(systemParts.length ? { systemInstruction: { parts: systemParts } } : {}),
99
+ ...(systemParts.length
100
+ ? { systemInstruction: { parts: systemParts } }
101
+ : {}),
70
102
  generationConfig,
71
103
  };
72
104
  }
@@ -8,10 +8,11 @@ import {
8
8
  * of token deltas suitable for piping through session-core StreamRouter.
9
9
  */
10
10
 
11
- import { BUILT_IN_PROVIDERS } from "./llm-providers.js";
11
+ import { resolveProviderDefinition } from "./llm-providers.js";
12
12
  import { normalizeOllamaBaseUrl } from "./ollama-base-url.js";
13
13
  import { ollamaProviderFetch } from "./ollama-provider-fetch.js";
14
14
  import {
15
+ assertCustomProviderConnection,
15
16
  geminiGenerateContentUrl,
16
17
  geminiRequestBody,
17
18
  normalizeProviderBaseUrl,
@@ -111,7 +112,7 @@ export async function* openAIStream({
111
112
  method: "POST",
112
113
  headers: {
113
114
  "Content-Type": "application/json",
114
- Authorization: `Bearer ${apiKey}`,
115
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
115
116
  },
116
117
  body: JSON.stringify({
117
118
  model,
@@ -276,6 +277,7 @@ export async function* anthropicTokenStream({
276
277
  * Throws on unsupported provider / missing API key.
277
278
  */
278
279
  export function buildProviderSource(provider, opts = {}) {
280
+ assertCustomProviderConnection(provider, opts);
279
281
  const {
280
282
  model,
281
283
  baseUrl,
@@ -295,11 +297,11 @@ export function buildProviderSource(provider, opts = {}) {
295
297
  requireCompletion,
296
298
  });
297
299
  }
298
- const def = BUILT_IN_PROVIDERS[provider];
300
+ const def = resolveProviderDefinition(provider);
299
301
  if (!def) throw new Error(`Unsupported provider: ${provider}`);
300
302
  const finalKey =
301
303
  apiKey || (def.apiKeyEnv ? process.env[def.apiKeyEnv] : null);
302
- if (!finalKey) {
304
+ if (!finalKey && provider !== "custom") {
303
305
  throw new Error(
304
306
  `API key required for ${provider} (--api-key or ${def.apiKeyEnv})`,
305
307
  );
@@ -146,6 +146,8 @@ export function makeRunnableProviderFallback(
146
146
  // react to an auth failure AND pre-empt the known-bad attempt on later turns.
147
147
  const computeFallback = (options) => {
148
148
  const failed = options.provider;
149
+ // A custom protocol selection is intentional, including on localhost.
150
+ if (failed === "custom") return null;
149
151
  const inferred = inferProviderFromBaseUrl(options.baseUrl);
150
152
  if (inferred && inferred !== failed) {
151
153
  return {
@@ -269,6 +271,8 @@ export function hasUsableKey(
269
271
  provider,
270
272
  { apiKey, isActive = true, env = process.env } = {},
271
273
  ) {
274
+ // Custom platforms may be keyless; connection validation checks their target.
275
+ if (provider === "custom") return true;
272
276
  const def = BUILT_IN_PROVIDERS[provider];
273
277
  if (!def) return false;
274
278
  if (!def.apiKeyEnv) return true; // keyless local provider (e.g. ollama)