mixdog 1.0.9 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Open-source coding agent that matches Codex on Terminal-Bench 2.1 with 63% fewer tokens.",
@@ -49,6 +49,14 @@ export function mergeStoredProviders({ raw, defaults, includeSecrets }) {
49
49
  }
50
50
  }
51
51
  }
52
+ // Custom providers: the keychain account is the generated custom-<uuid> id.
53
+ if (includeSecrets) {
54
+ for (const [name, val] of Object.entries(mergedProviders)) {
55
+ if (val?.type !== 'custom' || !name.startsWith('custom-')) continue;
56
+ const kc = getAgentApiKey(name);
57
+ if (kc) mergedProviders[name] = { ...val, apiKey: kc, enabled: raw.providers?.[name]?.enabled !== false };
58
+ }
59
+ }
52
60
  // Cursor account access is OAuth-only. The dashboard's "API"
53
61
  // meter is a quota bucket on that account, not a separate provider.
54
62
  delete mergedProviders['cursor-api'];
@@ -0,0 +1,163 @@
1
+ // User-defined ("custom") providers. A custom provider is stored as a
2
+ // secret-free config entry keyed by a generated `custom-<uuid>` id; the API key
3
+ // lives in the keychain under that same id and is injected at load time. The
4
+ // runtime reuses the existing OpenAI-compat / Anthropic engines, constructed
5
+ // under the custom id so model routes and provider identity stay `custom-…`.
6
+
7
+ import { ANTHROPIC_VERSION } from './lib/anthropic-models.mjs';
8
+ import { assertSafeBaseURL } from './provider-base-url.mjs';
9
+ import { listCompatModels } from './openai-compat-models.mjs';
10
+
11
+ export const CUSTOM_PROVIDER_PROTOCOLS = Object.freeze(['openai-chat', 'openai-responses', 'anthropic']);
12
+
13
+ function fail(message) {
14
+ throw new Error(`[custom-provider] ${message}`);
15
+ }
16
+
17
+ function text(value) {
18
+ return typeof value === 'string' ? value.trim() : '';
19
+ }
20
+
21
+ function positiveInt(value, label) {
22
+ if (value === undefined || value === null || value === '') return undefined;
23
+ const n = Number(value);
24
+ if (!Number.isInteger(n) || n <= 0) fail(`${label} must be a positive integer`);
25
+ return n;
26
+ }
27
+
28
+ function normalizeBaseURL(raw) {
29
+ const value = text(raw);
30
+ if (!value) fail('baseURL is required');
31
+ let url;
32
+ try {
33
+ url = new URL(value);
34
+ } catch {
35
+ fail(`invalid baseURL: ${value}`);
36
+ }
37
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') fail('baseURL must use http or https');
38
+ if (url.username || url.password) fail('baseURL must not embed credentials');
39
+ if (url.search || url.hash || /[?#]/.test(value)) fail('baseURL must not contain a query or hash');
40
+ return assertSafeBaseURL(value.replace(/\/+$/, ''), 'custom');
41
+ }
42
+
43
+ function normalizeModels(raw, requireModels) {
44
+ if (raw === undefined || raw === null) raw = [];
45
+ if (!Array.isArray(raw)) fail('models must be an array');
46
+ const seen = new Set();
47
+ const models = raw.map((entry) => {
48
+ const id = text(entry?.id);
49
+ if (!id) fail('every model needs an id');
50
+ if (seen.has(id)) fail(`duplicate model id: ${id}`);
51
+ seen.add(id);
52
+ const name = text(entry.name);
53
+ const contextWindow = positiveInt(entry.contextWindow, `model ${id} contextWindow`);
54
+ const maxOutputTokens = positiveInt(entry.maxOutputTokens, `model ${id} maxOutputTokens`);
55
+ return {
56
+ id,
57
+ ...(name ? { name } : {}),
58
+ ...(contextWindow ? { contextWindow } : {}),
59
+ ...(maxOutputTokens ? { maxOutputTokens } : {}),
60
+ };
61
+ });
62
+ if (requireModels && models.length === 0) fail('at least one model is required');
63
+ return models;
64
+ }
65
+
66
+ export function normalizeCustomProviderConfig(input, { requireModels = true } = {}) {
67
+ if (!input || typeof input !== 'object') fail('config must be an object');
68
+ const name = text(input.name);
69
+ if (!name) fail('name is required');
70
+ const protocol = text(input.protocol);
71
+ if (!protocol) fail('protocol is required');
72
+ if (!CUSTOM_PROVIDER_PROTOCOLS.includes(protocol)) fail(`unsupported protocol: ${protocol}`);
73
+ return {
74
+ type: 'custom',
75
+ name,
76
+ protocol,
77
+ baseURL: normalizeBaseURL(input.baseURL),
78
+ models: normalizeModels(input.models, requireModels),
79
+ enabled: true,
80
+ };
81
+ }
82
+
83
+ function modelRow(id, m) {
84
+ return {
85
+ id: m.id,
86
+ name: m.name || m.id,
87
+ provider: id,
88
+ contextWindow: m.contextWindow || 0,
89
+ outputTokens: m.maxOutputTokens || null,
90
+ };
91
+ }
92
+
93
+ // Configured models win; an empty list falls back to protocol discovery.
94
+ function withConfiguredModels(Base, discover) {
95
+ return class extends Base {
96
+ _customModels = [];
97
+ async listModels() {
98
+ if (!this._customModels.length) return discover(this);
99
+ if ('_enrichedModels' in this) this._enrichedModels = this._customModels;
100
+ return this._customModels.map((m) => ({ ...m }));
101
+ }
102
+ async isAvailable() {
103
+ return this._customModels.length ? true : super.isAvailable();
104
+ }
105
+ getCachedModelInfo(model) {
106
+ return this._customModels.find((m) => m.id === model) || super.getCachedModelInfo?.(model) || null;
107
+ }
108
+ };
109
+ }
110
+
111
+ async function discoverAnthropicModels(provider) {
112
+ const res = await fetch(`${provider.config.baseURL}/v1/models`, {
113
+ method: 'GET',
114
+ signal: AbortSignal.timeout(10_000),
115
+ headers: { 'x-api-key': provider.apiKey || '', 'anthropic-version': ANTHROPIC_VERSION },
116
+ });
117
+ if (!res.ok) throw new Error(`${provider.name} models ${res.status}`);
118
+ const data = await res.json();
119
+ const items = Array.isArray(data?.data) ? data.data : [];
120
+ return items
121
+ .filter((m) => m?.id)
122
+ .map((m) => ({
123
+ id: m.id,
124
+ name: m.display_name || m.id,
125
+ provider: provider.name,
126
+ contextWindow: Number(m.max_input_tokens) > 0 ? Number(m.max_input_tokens) : 0,
127
+ outputTokens: Number(m.max_tokens) > 0 ? Number(m.max_tokens) : null,
128
+ }));
129
+ }
130
+
131
+ export async function createCustomProvider(id, config) {
132
+ const providerId = text(id);
133
+ if (!/^custom-.+/.test(providerId)) fail(`invalid custom provider id: ${id}`);
134
+ const cfg = normalizeCustomProviderConfig(config, { requireModels: false });
135
+ const engine = {
136
+ baseURL: cfg.baseURL,
137
+ apiKey: config.apiKey || undefined,
138
+ ...(config.preconnect === false ? { preconnect: false } : {}),
139
+ };
140
+ let provider;
141
+ if (cfg.protocol === 'anthropic') {
142
+ const { AnthropicProvider } = await import('./anthropic.mjs');
143
+ const Custom = withConfiguredModels(AnthropicProvider, discoverAnthropicModels);
144
+ // The Anthropic SDK appends /v1/messages itself; accept a /v1 base URL.
145
+ provider = new Custom({ ...engine, name: providerId, baseURL: cfg.baseURL.replace(/\/v1$/i, '') });
146
+ } else {
147
+ const { OpenAICompatProvider } = await import('./openai-compat.mjs');
148
+ const Base =
149
+ cfg.protocol === 'openai-responses'
150
+ ? class extends OpenAICompatProvider {
151
+ _doSend(messages, model, tools, sendOpts) {
152
+ return super._doSend(messages, model, tools, { ...(sendOpts || {}), compatWireApi: 'responses' });
153
+ }
154
+ }
155
+ : OpenAICompatProvider;
156
+ const Custom = withConfiguredModels(Base, (self) => listCompatModels(self, { throwOnError: true }));
157
+ provider = new Custom(providerId, engine);
158
+ if (cfg.models.length) provider.defaultModel = cfg.models[0].id;
159
+ }
160
+ provider._customModels = cfg.models.map((m) => modelRow(providerId, m));
161
+ provider.customConfig = cfg;
162
+ return provider;
163
+ }
@@ -11,6 +11,7 @@
11
11
  // a filtered + deduped subset of the SAME row objects (never mutated).
12
12
 
13
13
  import { getModelsDevProviderModelsSync, getModelsDevRowSync } from './model-catalog.mjs';
14
+ import { isRollingModelAlias } from '../../../shared/model-alias.mjs';
14
15
 
15
16
  // Id-level fallback for coding-unfit SKUs when the catalog row is missing.
16
17
  // search-preview (grounded-search) and audio/realtime preview SKUs are
@@ -213,6 +214,14 @@ function _stalenessFamily(row, id) {
213
214
  return family || null;
214
215
  }
215
216
 
217
+ // Explicit preview/experimental markers in the id. A stable
218
+ // model is not superseded by these; the catalog carries no structured status.
219
+ const PREVIEW_VARIANT_RE = /(^|[-_.:/\s])(preview|exp|experimental)([-_.:/\s]|$)/i;
220
+
221
+ function _isPreviewVariant(id) {
222
+ return PREVIEW_VARIANT_RE.test(String(id || ''));
223
+ }
224
+
216
225
  function _staleMonths() {
217
226
  const raw = process.env.MIXDOG_MODEL_STALE_MONTHS;
218
227
  if (raw == null || raw === '') return 9;
@@ -246,19 +255,28 @@ function _applyAutoStaleness(kept, provider, testCatalog) {
246
255
  // ANY model (kept or not) in the same family with a strictly newer date.
247
256
  // Compute the newest release_date per family from the FULL catalog so a
248
257
  // superseding model that isn't in this list still counts.
258
+ // Rolling aliases never count. Preview/experimental entries count only
259
+ // against other preview/experimental rows, never against a stable one.
249
260
  const familyNewest = new Map();
261
+ const familyNewestStable = new Map();
262
+ const bump = (map, fam, ep) => {
263
+ const prev = map.get(fam);
264
+ if (prev == null || ep > prev) map.set(fam, ep);
265
+ };
250
266
  for (const [id, cr] of Object.entries(catModels)) {
251
267
  const fam = _stalenessFamily(cr, id);
252
268
  const ep = _releaseEpoch(cr);
253
- if (!fam || ep == null) continue;
254
- const prev = familyNewest.get(fam);
255
- if (prev == null || ep > prev) familyNewest.set(fam, ep);
269
+ if (!fam || ep == null || isRollingModelAlias(id)) continue;
270
+ bump(familyNewest, fam, ep);
271
+ if (!_isPreviewVariant(id)) bump(familyNewestStable, fam, ep);
256
272
  }
257
273
 
258
274
  const dropped = new Set();
259
275
  for (const m of meta) {
260
276
  if (!m.cat || m.family == null || m.epoch == null) continue; // fallback: keep
261
- const newest = familyNewest.get(m.family);
277
+ if (isRollingModelAlias(m.row.id)) continue;
278
+ const newestByFamily = _isPreviewVariant(m.row.id) ? familyNewest : familyNewestStable;
279
+ const newest = newestByFamily.get(m.family);
262
280
  if (newest != null && newest > m.epoch) dropped.add(m.row);
263
281
  }
264
282
  // Never empty a family the user relies on: if supersession removed every
@@ -286,6 +304,7 @@ function _applyAutoStaleness(kept, provider, testCatalog) {
286
304
  for (const m of meta) {
287
305
  if (dropped.has(m.row)) continue;
288
306
  if (!m.cat || m.epoch == null) continue; // fallback: keep
307
+ if (isRollingModelAlias(m.row.id)) continue;
289
308
  if (m.epoch < cutoff) cutDropped.add(m.row);
290
309
  }
291
310
  // If applying the absolute cut would empty the entire provider list, skip
@@ -28,7 +28,7 @@ export async function fetchCompatModelItems(provider) {
28
28
  }
29
29
  }
30
30
 
31
- export async function listCompatModels(provider) {
31
+ export async function listCompatModels(provider, { throwOnError = false } = {}) {
32
32
  try {
33
33
  const list = await provider._fetchModelItems();
34
34
  const models = [];
@@ -56,7 +56,8 @@ export async function listCompatModels(provider) {
56
56
  const enriched = sanitizeModelList(await enrichModels(filtered), { provider: provider.name });
57
57
  provider._enrichedModels = enriched;
58
58
  return enriched;
59
- } catch {
59
+ } catch (error) {
60
+ if (throwOnError) throw error;
60
61
  return [];
61
62
  }
62
63
  }
@@ -4,6 +4,7 @@ import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs
4
4
  import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
5
5
  import { appendAgentTrace } from '../agent-trace.mjs';
6
6
  import { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
7
+ import { assertSafeBaseURL } from './provider-base-url.mjs';
7
8
  import { resolveResponsesTransportPolicy, RESPONSES_TRANSPORT_CAPABILITIES } from './openai-transport-policy.mjs';
8
9
  import { toResponsesTools, toXaiResponsesInput } from './openai-compat-wire.mjs';
9
10
  import {
@@ -74,34 +75,6 @@ export { compatReportedCostUsd } from './openai-compat-response-normalization.mj
74
75
 
75
76
  const PRESETS = OPENAI_COMPAT_PRESETS;
76
77
 
77
- // SSRF guard for provider baseURL. config.baseURL comes from user JSON;
78
- // reject non-http(s) schemes (file:/data:/ftp:/etc.) and require https for
79
- // any non-localhost host. The managed Local Provider and other loopback hosts
80
- // may use http. Throws a clear config error — no silent
81
- // fallback — so misconfig surfaces immediately instead of leaking apiKey.
82
- function assertSafeBaseURL(rawURL, providerName) {
83
- let parsed;
84
- try {
85
- parsed = new URL(String(rawURL));
86
- } catch {
87
- throw new Error(`[provider:${providerName}] invalid baseURL: ${rawURL}`);
88
- }
89
- const scheme = parsed.protocol.toLowerCase();
90
- if (scheme !== 'https:' && scheme !== 'http:') {
91
- throw new Error(`[provider:${providerName}] baseURL scheme not allowed: ${parsed.protocol} (only http/https)`);
92
- }
93
- if (scheme === 'http:') {
94
- const host = parsed.hostname.toLowerCase();
95
- const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1';
96
- if (!isLocal) {
97
- throw new Error(
98
- `[provider:${providerName}] baseURL must use https for non-localhost host (got ${parsed.protocol}//${parsed.hostname})`
99
- );
100
- }
101
- }
102
- return rawURL;
103
- }
104
-
105
78
  export class OpenAICompatProvider {
106
79
  // Chat Completions prompt_tokens is already the total (includes cached).
107
80
  // Covers grok-oauth and all OPENAI_COMPAT_PRESETS. See registry.mjs.
@@ -0,0 +1,24 @@
1
+ // Keep saved custom endpoints and OpenAI-compatible clients under the same
2
+ // transport policy: credentials may travel over HTTP only on loopback.
3
+ export function assertSafeBaseURL(rawURL, providerName) {
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(String(rawURL));
7
+ } catch {
8
+ throw new Error(`[provider:${providerName}] invalid baseURL`);
9
+ }
10
+ const scheme = parsed.protocol.toLowerCase();
11
+ if (scheme !== 'https:' && scheme !== 'http:') {
12
+ throw new Error(`[provider:${providerName}] baseURL scheme not allowed: ${parsed.protocol} (only http/https)`);
13
+ }
14
+ if (scheme === 'http:') {
15
+ const host = parsed.hostname.toLowerCase();
16
+ const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
17
+ if (!isLocal) {
18
+ throw new Error(
19
+ `[provider:${providerName}] baseURL must use https for non-localhost host (got ${parsed.protocol}//${parsed.hostname})`
20
+ );
21
+ }
22
+ }
23
+ return rawURL;
24
+ }
@@ -150,7 +150,16 @@ const PROVIDER_MODULES = {
150
150
  'opencode-go': ['./opencode-go.mjs', 'OpenCodeGoProvider'],
151
151
  };
152
152
 
153
- async function loadProviderCtor(name, signal = null) {
153
+ // User-defined providers (cfg.type === 'custom') are keyed by a generated
154
+ // `custom-<uuid>` id and never collide with the built-in names.
155
+ function isCustomProviderConfig(name, cfg) {
156
+ return cfg?.type === 'custom' && String(name).startsWith('custom-');
157
+ }
158
+
159
+ async function loadProviderCtor(name, signal = null, cfg = null) {
160
+ if (isCustomProviderConfig(name, cfg)) {
161
+ return loadProviderExport('custom-provider', './custom-provider.mjs', 'createCustomProvider', signal);
162
+ }
154
163
  if (Object.hasOwn(PROVIDER_MODULES, name)) {
155
164
  const [spec, exportName] = PROVIDER_MODULES[name];
156
165
  return loadProviderExport(name, spec, exportName, signal);
@@ -161,7 +170,8 @@ async function loadProviderCtor(name, signal = null) {
161
170
  throw new Error(`unknown enabled provider: ${name}`);
162
171
  }
163
172
 
164
- function instantiateProvider(name, Ctor, cfg) {
173
+ async function instantiateProvider(name, Ctor, cfg) {
174
+ if (isCustomProviderConfig(name, cfg)) return wrapProviderAdmission(await Ctor(name, cfg), name);
165
175
  if (Object.hasOwn(OPENAI_COMPAT_PRESETS, name) && name !== 'opencode-go') {
166
176
  return wrapProviderAdmission(new Ctor(name, cfg), name);
167
177
  }
@@ -239,9 +249,9 @@ async function _initProvidersUnsynchronized(config, signal = null) {
239
249
  return { name, inst: providers.get(name), sig };
240
250
  }
241
251
  try {
242
- const Ctor = await loadProviderCtor(name, signal);
252
+ const Ctor = await loadProviderCtor(name, signal, cfg);
243
253
  throwIfAborted(signal);
244
- const inst = instantiateProvider(name, Ctor, cfg);
254
+ const inst = await instantiateProvider(name, Ctor, cfg);
245
255
  return { name, inst, sig };
246
256
  } catch (err) {
247
257
  const msg = err instanceof Error ? err.message : String(err);
@@ -14,7 +14,7 @@ export function makeResolveDefaultProvider(isKnownProvider) {
14
14
  return function resolveDefaultProvider(config) {
15
15
  const selectedMain = findPreset(config, config?.default);
16
16
  const configured = clean(selectedMain?.provider);
17
- if (configured && isKnownProvider(configured)) return configured;
17
+ if (configured && isKnownProvider(configured, config)) return configured;
18
18
  return DEFAULT_PROVIDER;
19
19
  };
20
20
  }
@@ -124,7 +124,8 @@ export function isLikelyRawModelId(value) {
124
124
  const model = clean(value);
125
125
  if (!model || model.length > 160) return false;
126
126
  if (/\s/.test(model)) return false;
127
- return /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(model);
127
+ // OpenRouter's rolling model aliases start with a literal "~".
128
+ return /^~?[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(model);
128
129
  }
129
130
 
130
131
  export function validateRequestedModelSelector(config, requested = {}) {
@@ -439,6 +439,8 @@ class NativePatchServer {
439
439
 
440
440
  #contractPromise = null;
441
441
 
442
+ #contractPending = false;
443
+
442
444
  constructor(binPath) {
443
445
  this.binPath = binPath;
444
446
  // windowsHide: mixdog-patch.exe is a console binary; without this each spawn
@@ -544,15 +546,23 @@ class NativePatchServer {
544
546
  // the caller may respawn and retry without any risk of double-applying.
545
547
  // Contract handshake on THIS session, memoized per instance: N concurrent
546
548
  // callers await one exchange, and a respawned session proves itself again.
549
+ /** True while the handshake runs: between its exchanges no waiter is queued, yet the handles must stay referenced. */
550
+ get contractPending() {
551
+ return this.#contractPending;
552
+ }
553
+
547
554
  contract() {
548
555
  if (!this.#contractPromise) {
556
+ this.#contractPending = true;
549
557
  this.#contractPromise = verifyContractOverSession(this.#handshakeView()).then(
550
558
  (ok) => {
559
+ this.#contractPending = false;
551
560
  this.#contractVerified = ok === true;
552
561
  if (!ok) this.markContractFailed();
553
562
  return ok;
554
563
  },
555
564
  () => {
565
+ this.#contractPending = false;
556
566
  this.#contractVerified = false;
557
567
  this.markContractFailed();
558
568
  return false;
@@ -884,7 +894,11 @@ export function scheduleNativePatchPrewarm() {
884
894
  }
885
895
  }
886
896
  await getNativePatchServer().ping();
887
- if (!nativePatchPersistent() && (_nativePatchServer?.waiters?.length || 0) === 0) {
897
+ if (
898
+ !nativePatchPersistent() &&
899
+ (_nativePatchServer?.waiters?.length || 0) === 0 &&
900
+ !_nativePatchServer?.contractPending
901
+ ) {
888
902
  _nativePatchServer?.unref();
889
903
  }
890
904
  if (nativePatchTraceEnabled()) {
@@ -0,0 +1,5 @@
1
+ // Rolling aliases name the current model, not the version released on the
2
+ // alias's creation date. Shared by catalog filtering and browser/TUI pickers.
3
+ export function isRollingModelAlias(id) {
4
+ return /-latest$/i.test(String(id || '').trim());
5
+ }
@@ -273,6 +273,12 @@ function providerAuthApiFor(boot) {
273
273
  getUsageDashboard: boot.getUsageDashboard,
274
274
  consumeCodexRateLimitResetCredit: boot.consumeCodexRateLimitResetCredit,
275
275
  collectProviderModels: boot.collectProviderModels,
276
+ // A deleted custom provider must not stay reachable through the live route
277
+ // or the registry; stored routes naming it are filtered by the picker.
278
+ onCustomProviderRemoved: (id) => {
279
+ reg.disableProvider?.(id);
280
+ if (rt.route?.provider === id) rt.route = { ...rt.route, provider: '', model: '', preset: null };
281
+ },
276
282
  });
277
283
  }
278
284
 
@@ -8,9 +8,13 @@ import {
8
8
  saveOpenAIUsageSessionKey,
9
9
  saveOpenCodeGoUsageAuth,
10
10
  saveProviderApiKey,
11
+ saveCustomProvider,
12
+ removeCustomProvider,
13
+ testCustomProvider,
14
+ discoverCustomProviderModels,
11
15
  } from '../services/provider-admin.mjs';
12
16
 
13
- export function createLoginApi({ cfgMod, awaitKeychainPrewarm, reloadFullConfig }, refresh) {
17
+ export function createLoginApi({ cfgMod, awaitKeychainPrewarm, reloadFullConfig, onCustomProviderRemoved }, refresh) {
14
18
  async function completeOAuthLogin(login) {
15
19
  return {
16
20
  ...login,
@@ -69,6 +73,27 @@ export function createLoginApi({ cfgMod, awaitKeychainPrewarm, reloadFullConfig
69
73
  refresh.afterUsageAuthChange();
70
74
  return result;
71
75
  },
76
+ async saveCustomProvider(input) {
77
+ await awaitKeychainPrewarm();
78
+ const result = await saveCustomProvider(cfgMod, input);
79
+ refresh.afterCredentialChange();
80
+ return result;
81
+ },
82
+ async removeCustomProvider(providerId) {
83
+ await awaitKeychainPrewarm();
84
+ const result = removeCustomProvider(cfgMod, providerId);
85
+ onCustomProviderRemoved?.(result.provider);
86
+ refresh.afterCredentialChange();
87
+ return result;
88
+ },
89
+ async testCustomProvider(input) {
90
+ await awaitKeychainPrewarm();
91
+ return await testCustomProvider(cfgMod, input);
92
+ },
93
+ async discoverCustomProviderModels(input) {
94
+ await awaitKeychainPrewarm();
95
+ return await discoverCustomProviderModels(cfgMod, input);
96
+ },
72
97
  forgetProviderAuth(providerId, accountId) {
73
98
  const result = forgetProviderAuth(cfgMod, providerId, accountId);
74
99
  refresh.afterCredentialChange();
@@ -46,7 +46,7 @@ export function createQuickProviderRows({
46
46
  const addRoute = (routeLike = {}) => {
47
47
  const provider = clean(routeLike.provider);
48
48
  const model = clean(routeLike.model);
49
- if (!provider || !model) return;
49
+ if (!provider || !model || isDeletedCustom(provider)) return;
50
50
  const meta = metadataFor(provider, model);
51
51
  addModel(provider, {
52
52
  id: model,
@@ -64,8 +64,23 @@ export function createQuickProviderRows({
64
64
  });
65
65
  };
66
66
 
67
- for (const [provider, providerConfig] of Object.entries(pickerConfig.providers || {})) {
67
+ // A custom-* id with no live custom config entry was deleted: stale
68
+ // routes must not bring it back into the picker.
69
+ const customProviders = pickerConfig.providers || {};
70
+ const isDeletedCustom = (provider) =>
71
+ provider.startsWith('custom-') &&
72
+ !(customProviders[provider]?.type === 'custom' && customProviders[provider].enabled !== false);
73
+ for (const [provider, providerConfig] of Object.entries(customProviders)) {
68
74
  if (!providerConfig?.enabled) continue;
75
+ if (providerConfig.type === 'custom') {
76
+ // Configured models are listed at once, before any catalog refresh.
77
+ for (const model of providerConfig.models || []) {
78
+ addModel(provider, {
79
+ ...model,
80
+ outputTokens: model.maxOutputTokens || null,
81
+ });
82
+ }
83
+ }
69
84
  for (const model of providerCachedModelsSync(provider)) addModel(provider, model);
70
85
  }
71
86
  addRoute(route);
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import {
2
3
  AGENT_PROVIDER_ENV,
3
4
  SECRET_ACCOUNTS,
@@ -156,9 +157,14 @@ function availableOAuthProvider(id) {
156
157
  return oauth && isOAuthProviderAvailable(id) ? oauth : null;
157
158
  }
158
159
 
159
- export function isKnownProvider(provider) {
160
+ export function isKnownProvider(provider, config = {}) {
160
161
  const id = String(provider || '').trim();
161
- return id !== '' && (API_PROVIDER_IDS.has(id) || BUILTIN_PROVIDER_IDS.has(id) || !!availableOAuthProvider(id));
162
+ return id !== '' && (
163
+ API_PROVIDER_IDS.has(id) ||
164
+ BUILTIN_PROVIDER_IDS.has(id) ||
165
+ !!availableOAuthProvider(id) ||
166
+ (isCustomProviderId(id) && isCustomProviderConfig(config.providers?.[id]) && config.providers[id].enabled !== false)
167
+ );
162
168
  }
163
169
 
164
170
  function updateConfigProvider(cfgMod, providerId, patch) {
@@ -275,11 +281,57 @@ function oauthProviderSetup(p, providers, checkSecrets) {
275
281
  };
276
282
  }
277
283
 
284
+ const CUSTOM_ID_RE = /^custom-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
285
+ const CUSTOM_TEST_TIMEOUT_MS = 20_000;
286
+ const CUSTOM_DISCOVERY_TIMEOUT_MS = 15_000;
287
+
288
+ export function isCustomProviderConfig(entry) {
289
+ return Boolean(entry) && typeof entry === 'object' && entry.type === 'custom';
290
+ }
291
+
292
+ export function isCustomProviderId(id) {
293
+ return CUSTOM_ID_RE.test(String(id || ''));
294
+ }
295
+
296
+ const loadCustomProviderModule = () => import('../../runtime/agent/orchestrator/providers/custom-provider.mjs');
297
+
298
+ // Secret-free public view of a custom provider. Never carries an API key.
299
+ function customProviderRow(id, entry, checkSecrets = true) {
300
+ const configuredEnabled = entry.enabled !== false;
301
+ const stored = checkSecrets ? hasStoredSecret(SECRET_ACCOUNTS.agentApiKey(id)) : false;
302
+ return {
303
+ id,
304
+ name: entry.name,
305
+ protocol: entry.protocol,
306
+ baseURL: entry.baseURL,
307
+ models: Array.isArray(entry.models) ? entry.models.map((m) => ({ ...m })) : [],
308
+ custom: true,
309
+ group: 'api',
310
+ type: 'api-key',
311
+ enabled: configuredEnabled,
312
+ authenticated: checkSecrets ? stored : configuredEnabled,
313
+ stored,
314
+ env: false,
315
+ envName: null,
316
+ status: stored ? 'Set' : configuredEnabled ? 'No Key' : 'Off',
317
+ detail: stored ? 'stored in keychain' : 'custom endpoint',
318
+ };
319
+ }
320
+
321
+ function customProviderRows(providers, checkSecrets) {
322
+ return Object.entries(providers)
323
+ .filter(([id, entry]) => isCustomProviderId(id) && isCustomProviderConfig(entry))
324
+ .map(([id, entry]) => customProviderRow(id, entry, checkSecrets));
325
+ }
326
+
278
327
  export async function providerSetup(config = {}, options = {}) {
279
328
  const providers = config.providers || {};
280
329
  const checkSecrets = options?.checkSecrets !== false;
281
330
  return {
282
- api: API_PROVIDERS.map((p) => apiProviderSetup(p, providers, checkSecrets)),
331
+ api: [
332
+ ...API_PROVIDERS.map((p) => apiProviderSetup(p, providers, checkSecrets)),
333
+ ...customProviderRows(providers, checkSecrets),
334
+ ],
283
335
  oauth: availableOAuthProviders().map((p) => oauthProviderSetup(p, providers, checkSecrets)),
284
336
  local: [builtInLocalProviderSetup(config, options)],
285
337
  };
@@ -322,6 +374,16 @@ function oauthProviderStatusRow(p, config) {
322
374
  export function providerStatus(config = {}) {
323
375
  return [
324
376
  ...API_PROVIDERS.map((p) => apiProviderStatusRow(p, config)),
377
+ ...customProviderRows(config.providers || {}, true).map((row) => ({
378
+ id: row.id,
379
+ type: 'api-key',
380
+ enabled: row.enabled,
381
+ authenticated: row.authenticated,
382
+ stored: row.stored,
383
+ env: false,
384
+ envName: null,
385
+ label: row.name,
386
+ })),
325
387
  ...availableOAuthProviders().map((p) => oauthProviderStatusRow(p, config)),
326
388
  ];
327
389
  }
@@ -525,6 +587,142 @@ export function saveProviderApiKey(cfgMod, provider, secret) {
525
587
  return { provider: id, type: 'api-key', authenticated: true };
526
588
  }
527
589
 
590
+ function sanitizeCustomError(error, secrets = []) {
591
+ let text = String(error?.message || error || 'request failed');
592
+ for (const secret of secrets) if (secret) text = text.split(secret).join('[redacted]');
593
+ text = text
594
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
595
+ .replace(/\b(?:sk|key|tok)[-_][A-Za-z0-9_-]{8,}/gi, '[redacted]')
596
+ .replace(/(api[-_]?key|authorization|token)(["']?\s*[:=]\s*["']?)[^\s"',}]+/gi, '$1$2[redacted]')
597
+ .replace(/[\r\n]+/g, ' ')
598
+ .trim();
599
+ if (text.length > 300) text = `${text.slice(0, 300)}…`;
600
+ return new Error(text || 'request failed');
601
+ }
602
+
603
+ async function withTimeout(run, ms, label) {
604
+ const controller = new AbortController();
605
+ let timer;
606
+ const timeout = new Promise((_, reject) => {
607
+ timer = setTimeout(() => {
608
+ controller.abort();
609
+ reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`));
610
+ }, ms);
611
+ });
612
+ try {
613
+ return await Promise.race([run(controller.signal), timeout]);
614
+ } finally {
615
+ clearTimeout(timer);
616
+ }
617
+ }
618
+
619
+ function existingCustomEntry(cfgMod, id) {
620
+ const entry = cfgMod.loadConfig().providers?.[id];
621
+ if (!isCustomProviderId(id) || !isCustomProviderConfig(entry)) throw new Error(`unknown custom provider "${id}"`);
622
+ return entry;
623
+ }
624
+
625
+ function inputObject(input) {
626
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('custom provider is invalid');
627
+ return input;
628
+ }
629
+
630
+ // Validated, secret-free config for `input`; the key (typed, else stored for an
631
+ // existing provider) is resolved separately and never enters the config.
632
+ async function resolveCustomInput(cfgMod, rawInput, options) {
633
+ const input = inputObject(rawInput);
634
+ const id = input.id === undefined || input.id === null || input.id === '' ? null : String(input.id);
635
+ if (id) existingCustomEntry(cfgMod, id);
636
+ const { normalizeCustomProviderConfig } = await loadCustomProviderModule();
637
+ const config = normalizeCustomProviderConfig(input, options);
638
+ const typed = typeof input.apiKey === 'string' ? input.apiKey.trim() : '';
639
+ const apiKey = typed || (id ? getAgentApiKey(id) || '' : '');
640
+ return { id, config, typed, apiKey };
641
+ }
642
+
643
+ export async function saveCustomProvider(cfgMod, rawInput) {
644
+ const { id: existingId, config, typed, apiKey } = await resolveCustomInput(cfgMod, rawInput, { requireModels: false });
645
+ if (!apiKey) throw new Error('API key is required');
646
+ const id = existingId || `custom-${randomUUID()}`;
647
+ const entry = {
648
+ type: 'custom',
649
+ name: config.name,
650
+ protocol: config.protocol,
651
+ baseURL: config.baseURL,
652
+ models: config.models,
653
+ enabled: true,
654
+ };
655
+ const account = SECRET_ACCOUNTS.agentApiKey(id);
656
+ const previousKey = typed && existingId ? getAgentApiKey(id) : null;
657
+ if (typed) saveSecret(account, typed);
658
+ try {
659
+ const current = cfgMod.loadConfig();
660
+ cfgMod.saveConfig({ ...current, providers: { ...(current.providers || {}), [id]: entry } }, { baseConfig: current });
661
+ } catch (error) {
662
+ if (typed) {
663
+ if (previousKey) saveSecret(account, previousKey);
664
+ else deleteSecret(account);
665
+ }
666
+ throw error;
667
+ }
668
+ return customProviderRow(id, entry);
669
+ }
670
+
671
+ export function removeCustomProvider(cfgMod, rawId) {
672
+ const id = String(rawId || '').trim();
673
+ existingCustomEntry(cfgMod, id);
674
+ const current = cfgMod.loadConfig();
675
+ const { [id]: _removed, ...providers } = current.providers || {};
676
+ cfgMod.saveConfig({ ...current, providers }, { baseConfig: current });
677
+ deleteSecret(SECRET_ACCOUNTS.agentApiKey(id));
678
+ return { provider: id, type: 'custom', removed: true };
679
+ }
680
+
681
+ export async function testCustomProvider(cfgMod, rawInput) {
682
+ const { id, config, apiKey } = await resolveCustomInput(cfgMod, rawInput, { requireModels: false });
683
+ try {
684
+ const models = config.models.length ? config.models : (await discoverCustomProviderModels(cfgMod, rawInput)).models;
685
+ const model = models[0]?.id;
686
+ if (!model) throw new Error('No models were found for this provider');
687
+ const { createCustomProvider } = await loadCustomProviderModule();
688
+ const provider = await createCustomProvider(id || 'custom-test', { ...config, apiKey });
689
+ await withTimeout(
690
+ (signal) => provider.send([{ role: 'user', content: 'Reply with OK.' }], model, [], { signal, maxTokens: 16 }),
691
+ CUSTOM_TEST_TIMEOUT_MS,
692
+ 'connection test'
693
+ );
694
+ return { ok: true };
695
+ } catch (error) {
696
+ throw sanitizeCustomError(error, [apiKey]);
697
+ }
698
+ }
699
+
700
+ export async function discoverCustomProviderModels(cfgMod, rawInput) {
701
+ const { id, config, apiKey } = await resolveCustomInput(cfgMod, rawInput, { requireModels: false });
702
+ try {
703
+ const { createCustomProvider } = await loadCustomProviderModule();
704
+ const provider = await createCustomProvider(id || 'custom-discovery', { ...config, models: [], apiKey });
705
+ const listed = await withTimeout(() => provider.listModels(), CUSTOM_DISCOVERY_TIMEOUT_MS, 'model discovery');
706
+ const seen = new Set();
707
+ const models = [];
708
+ for (const item of Array.isArray(listed) ? listed : []) {
709
+ const modelId = String(item?.id || '').trim();
710
+ if (!modelId || seen.has(modelId)) continue;
711
+ seen.add(modelId);
712
+ const model = { id: modelId };
713
+ const name = String(item.name || item.display || '').trim();
714
+ if (name && name !== modelId) model.name = name;
715
+ if (Number(item.contextWindow) > 0) model.contextWindow = Math.floor(Number(item.contextWindow));
716
+ const out = Number(item.maxOutputTokens ?? item.outputTokens);
717
+ if (out > 0) model.maxOutputTokens = Math.floor(out);
718
+ models.push(model);
719
+ }
720
+ return { models };
721
+ } catch (error) {
722
+ throw sanitizeCustomError(error, [apiKey]);
723
+ }
724
+ }
725
+
528
726
  export function saveOpenAIUsageSessionKey(cfgMod, secret) {
529
727
  const value = String(secret || '').trim();
530
728
  if (!value) throw new Error('OpenAI usage session key is required for credit lookup');
@@ -93,6 +93,10 @@ export const SESSION_CONFIGURE_ACTIONS = Object.freeze([
93
93
  'deleteMediaAsset',
94
94
  'deleteMediaAssets',
95
95
  'deleteSchedule',
96
+ 'saveCustomProvider',
97
+ 'removeCustomProvider',
98
+ 'testCustomProvider',
99
+ 'discoverCustomProviderModels',
96
100
  'deleteSession',
97
101
  'deleteWebhook',
98
102
  'deleteWorkflow',
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { theme } from '../theme.mjs';
9
9
  import { displayModelName } from '../../runtime/shared/model-display.mjs';
10
+ import { isRollingModelAlias } from '../../runtime/shared/model-alias.mjs';
10
11
 
11
12
  export const parsedModelVersion = (id) => {
12
13
  const text = String(id || '').toLowerCase();
@@ -147,7 +148,15 @@ export const normalizeModelOptions = (models) => {
147
148
  const providerModels = [];
148
149
  for (const [family, group] of families.entries()) {
149
150
  const limit = modelFamilyLimit(provider, family);
150
- providerModels.push(...group.slice().sort(compareModelRecency).slice(0, limit));
151
+ // Version limits must not hide rolling routes, even when several
152
+ // vendors' "~..." IDs share the fallback family.
153
+ let versions = 0;
154
+ providerModels.push(
155
+ ...group
156
+ .slice()
157
+ .sort(compareModelRecency)
158
+ .filter((model) => isRollingModelAlias(model.id) || versions++ < limit)
159
+ );
151
160
  }
152
161
  normalized.push(...providerModels.sort(compareModelRecency));
153
162
  }
@@ -55203,6 +55203,11 @@ function createMaintenancePickers({
55203
55203
  };
55204
55204
  }
55205
55205
 
55206
+ // src/runtime/shared/model-alias.mjs
55207
+ function isRollingModelAlias(id) {
55208
+ return /-latest$/i.test(String(id || "").trim());
55209
+ }
55210
+
55206
55211
  // src/tui/app/model-options.mjs
55207
55212
  var parsedModelVersion = (id) => {
55208
55213
  const text = String(id || "").toLowerCase();
@@ -55322,7 +55327,10 @@ var normalizeModelOptions = (models) => {
55322
55327
  const providerModels = [];
55323
55328
  for (const [family, group] of families.entries()) {
55324
55329
  const limit = modelFamilyLimit(provider, family);
55325
- providerModels.push(...group.slice().sort(compareModelRecency).slice(0, limit));
55330
+ let versions = 0;
55331
+ providerModels.push(
55332
+ ...group.slice().sort(compareModelRecency).filter((model) => isRollingModelAlias(model.id) || versions++ < limit)
55333
+ );
55326
55334
  }
55327
55335
  normalized.push(...providerModels.sort(compareModelRecency));
55328
55336
  }
@@ -159,6 +159,18 @@ export function createSessionIntegrationsApi(bag, { oauthFlows }) {
159
159
  pushNotice(`provider api key saved: ${result.provider}`, 'info');
160
160
  return true;
161
161
  },
162
+ saveCustomProvider: announced(
163
+ (input) => runtime.saveCustomProvider(input),
164
+ (result) => `custom provider saved: ${result.id}`
165
+ ),
166
+ removeCustomProvider: async (id) => {
167
+ const result = await runtime.removeCustomProvider(id);
168
+ refreshRouteStats();
169
+ pushNotice(`custom provider removed: ${result.provider}`, 'info');
170
+ return result;
171
+ },
172
+ testCustomProvider: (input) => runtime.testCustomProvider(input),
173
+ discoverCustomProviderModels: (input) => runtime.discoverCustomProviderModels(input),
162
174
  saveOpenCodeGoUsageAuth: (opts) => {
163
175
  runtime.saveOpenCodeGoUsageAuth(opts);
164
176
  pushNotice('OpenCode Go usage auth saved', 'info');