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 +1 -1
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +163 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +23 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-models.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +1 -28
- package/src/runtime/agent/orchestrator/providers/provider-base-url.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +14 -4
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +15 -1
- package/src/runtime/shared/model-alias.mjs +5 -0
- package/src/session-runtime/boot/apis.mjs +6 -0
- package/src/session-runtime/provider-auth/login.mjs +26 -1
- package/src/session-runtime/quick-model-rows/provider-rows.mjs +17 -2
- package/src/session-runtime/services/provider-admin.mjs +201 -3
- package/src/standalone/session-protocol.mjs +4 -0
- package/src/tui/app/model-options.mjs +10 -1
- package/src/tui/dist/index.mjs +9 -1
- package/src/tui/session/session-api/integrations.mjs +12 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
255
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
897
|
+
if (
|
|
898
|
+
!nativePatchPersistent() &&
|
|
899
|
+
(_nativePatchServer?.waiters?.length || 0) === 0 &&
|
|
900
|
+
!_nativePatchServer?.contractPending
|
|
901
|
+
) {
|
|
888
902
|
_nativePatchServer?.unref();
|
|
889
903
|
}
|
|
890
904
|
if (nativePatchTraceEnabled()) {
|
|
@@ -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
|
-
|
|
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 !== '' && (
|
|
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:
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|