dsh-newapi-provider 0.0.0-stage → 0.8.4

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/lib/adapter.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * LLM adapter for the New API gateway route.
3
+ *
4
+ * Wire protocols are served by the pi-ai SDK that DeepSeek Harness itself
5
+ * ships; the harness ↔ pi-ai conversion is delegated to the official
6
+ * `PiAiAdapter` from `@deepseek-ai/dsh-llm-pi-ai`, so message history,
7
+ * replay state, image handling and error classification stay identical to the
8
+ * built-in providers and follow DSH upgrades automatically.
9
+ *
10
+ * This module only turns the gateway catalog into one pi-ai provider profile
11
+ * per refresh, and answers model metadata from the catalog (context window,
12
+ * output cap, modalities, reasoning) with user overrides applied.
13
+ */
14
+ import { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
15
+ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai';
16
+ import { createProvider } from '@earendil-works/pi-ai';
17
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
18
+ import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
19
+ import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
20
+ import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy';
21
+ import { chatModels, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS } from './catalog.js';
22
+ import { gatewayRoot, openaiBase } from './util.js';
23
+
24
+ const ENDPOINT_TO_API = {
25
+ 'openai-response': 'openai-responses',
26
+ anthropic: 'anthropic-messages',
27
+ openai: 'openai-completions',
28
+ gemini: 'google-generative-ai',
29
+ };
30
+ export const DEFAULT_ENDPOINT_PRIORITY = ['openai', 'anthropic', 'openai-response', 'gemini'];
31
+ const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
32
+
33
+ export function pickApi(endpointTypes, priority = DEFAULT_ENDPOINT_PRIORITY) {
34
+ const advertised = endpointTypes?.length ? endpointTypes : ['openai'];
35
+ for (const t of priority) if (advertised.includes(t) && ENDPOINT_TO_API[t]) return ENDPOINT_TO_API[t];
36
+ for (const t of advertised) if (ENDPOINT_TO_API[t]) return ENDPOINT_TO_API[t];
37
+ return 'openai-completions';
38
+ }
39
+
40
+ function baseUrlFor(baseURL, api) {
41
+ return api === 'openai-completions' || api === 'openai-responses' ? openaiBase(baseURL) : gatewayRoot(baseURL);
42
+ }
43
+
44
+ /** Reasoning level map: explicit override levels, else "reasoning on" defaults. */
45
+ function thinkingLevelMap(entry) {
46
+ if (!entry.reasoning) return undefined;
47
+ if (!entry.reasoningLevels?.length) return undefined;
48
+ const allowed = new Set(entry.reasoningLevels);
49
+ return Object.fromEntries(LEVELS.map((l) => [l, allowed.has(l) ? l : null]));
50
+ }
51
+
52
+ export function toPiModel(entry, conn, priority) {
53
+ const api = pickApi(entry.endpointTypes, priority);
54
+ const levels = thinkingLevelMap(entry);
55
+ return {
56
+ id: entry.id,
57
+ name: entry.name,
58
+ api,
59
+ provider: conn.providerId,
60
+ baseUrl: baseUrlFor(conn.baseURL, api),
61
+ reasoning: Boolean(entry.reasoning),
62
+ input: entry.input.filter((m) => m === 'text' || m === 'image'),
63
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
64
+ contextWindow: entry.contextWindow ?? conn.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
65
+ maxTokens: entry.maxTokens ?? conn.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
66
+ ...(levels ? { thinkingLevelMap: levels } : {}),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Override only the serialized request. Native model/history identities stay
72
+ * local, preserving same-model replay signatures and prepared-call snapshots.
73
+ * All four SDK protocols invoke onPayload after serialization; streamSimple
74
+ * forwards it through buildBaseOptions. No history copying/traversal is added.
75
+ */
76
+ export function withUpstreamModels(api, mapping) {
77
+ const wrap = (method) => (model, context, options) => {
78
+ const upstreamModel = mapping.get(model.id);
79
+ if (!upstreamModel || upstreamModel === model.id) return api[method](model, context, options);
80
+ return api[method](model, context, {
81
+ ...options,
82
+ onPayload: async (payload, nativeModel) => {
83
+ const next = await options?.onPayload?.(payload, nativeModel);
84
+ return { ...(next ?? payload), model: upstreamModel };
85
+ },
86
+ });
87
+ };
88
+ return { ...api, stream: wrap('stream'), streamSimple: wrap('streamSimple') };
89
+ }
90
+
91
+ function piProviderFor(conn, models, entries) {
92
+ const mapping = new Map(entries.map((e) => [e.id, e.upstreamModel || e.id]));
93
+ return createProvider({
94
+ id: conn.providerId,
95
+ name: conn.displayName,
96
+ baseUrl: openaiBase(conn.baseURL),
97
+ auth: {
98
+ apiKey: {
99
+ name: conn.displayName,
100
+ resolve: ({ credential }) => Promise.resolve({
101
+ auth: credential?.key === undefined ? {} : { apiKey: credential.key },
102
+ source: conn.displayName,
103
+ }),
104
+ },
105
+ },
106
+ models,
107
+ api: {
108
+ 'openai-completions': withUpstreamModels(openAICompletionsApi(), mapping),
109
+ 'openai-responses': withUpstreamModels(openAIResponsesApi(), mapping),
110
+ 'anthropic-messages': withUpstreamModels(anthropicMessagesApi(), mapping),
111
+ 'google-generative-ai': withUpstreamModels(googleGenerativeAIApi(), mapping),
112
+ },
113
+ });
114
+ }
115
+
116
+ /**
117
+ * The adapter registered for the `newapi` route. It owns catalog-driven
118
+ * metadata and delegates streaming to an inner PiAiAdapter that reads one
119
+ * immutable profile map per catalog generation.
120
+ */
121
+ export class NewApiAdapter extends LlmAdapter {
122
+ constructor(host) { super(); this.host = host; }
123
+
124
+ providerInfo(provider) { return { id: provider, name: this.host.connection().displayName }; }
125
+ providerRetryPolicy() { return this.host.connection().retryPolicy; }
126
+
127
+ /** Each operation owns an immutable profile and key, including prepared streams. */
128
+ async bind(signal) {
129
+ const bound = this.host.capture();
130
+ const conn = bound.connection;
131
+ const apiKey = await bound.requireApiKey();
132
+ const catalog = await bound.catalog({ signal });
133
+ const entries = chatModels(catalog);
134
+ const profile = {
135
+ provider: conn.providerId, displayName: conn.displayName, apiKeyEnv: conn.apiKeyRef,
136
+ headers: conn.headers, streamIdleTimeoutMs: conn.streamIdleTimeoutMs,
137
+ maxRequestImageBytes: 20 * 1024 * 1024, requestImagePixelBudget: 2048 * 2048,
138
+ requestImageMaxBytes: 1024 * 1024, retryPolicy: conn.retryPolicy,
139
+ configuredMaxTokens: new Map(entries.filter((e) => e.source.overridden.includes('maxTokens')).map((e) => [e.id, e.maxTokens])),
140
+ modelErrors: new Map(),
141
+ piProvider: piProviderFor(conn, entries.map((e) => toPiModel(e, conn, conn.endpointPriority)), entries),
142
+ };
143
+ const profiles = new Map([[conn.providerId, profile]]);
144
+ const inner = new PiAiAdapter({
145
+ profiles: () => profiles,
146
+ resolveApiKey: async () => apiKey,
147
+ auth: this.host.piAuth,
148
+ resolveAttachments: this.host.resolveAttachments,
149
+ resolveImageAccess: this.host.resolveImageAccess,
150
+ onReplayDegrade: ({ model, reason }) => this.host.logger?.warn?.(`newapi-provider: replay degraded for ${model}: ${reason}`),
151
+ });
152
+ return { inner, catalog };
153
+ }
154
+
155
+ async listModels(provider) {
156
+ const bound = this.host.capture();
157
+ let catalog;
158
+ try { catalog = await bound.catalog(); }
159
+ catch (error) { this.host.reportError('catalog', error); return []; }
160
+ return chatModels(catalog).map((e) => ({ provider, id: e.id, name: e.name, inputModalities: e.input.filter((m) => m === 'text' || m === 'image') }));
161
+ }
162
+
163
+ async modelInfo(bound, provider, model, signal) {
164
+ const info = await bound.inner.resolveModel(provider, model, signal);
165
+ const entry = bound.catalog.entries.find((e) => e.id === model);
166
+ if (entry?.reasoningLevels?.length) info.reasoning = {
167
+ efforts: entry.reasoningLevels.map((l) => ({ id: ReasoningEffortId(l), name: l.charAt(0).toUpperCase() + l.slice(1) })),
168
+ };
169
+ if (entry?.maxTokens) info.defaultMaxTokens = entry.maxTokens;
170
+ return info;
171
+ }
172
+
173
+ async resolveModel(provider, model, signal) {
174
+ return this.modelInfo(await this.bind(signal), provider, model, signal);
175
+ }
176
+
177
+ async prepareCall(provider, model, signal) {
178
+ const bound = await this.bind(signal);
179
+ const prepared = await bound.inner.prepareCall(provider, model, signal);
180
+ const resolved = await this.modelInfo(bound, provider, model, signal);
181
+ return { model: resolved, stream: (options) => prepared.stream(options) };
182
+ }
183
+
184
+ async *stream(options) {
185
+ const bound = await this.bind(options.signal);
186
+ if (!chatModels(bound.catalog).some((e) => e.id === options.model)) {
187
+ throw new LlmError(`New API gateway has no chat model "${options.model}"`, 'UNKNOWN_MODEL');
188
+ }
189
+ yield* bound.inner.stream(options);
190
+ }
191
+ }
package/lib/balance.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Balance and quota for the configured New API token.
3
+ *
4
+ * GET /api/status (no auth) → quota_per_unit, quota_display_type, usd_exchange_rate, custom currency
5
+ * GET /api/usage/token/ (token) → token quota; `user_quota` too when the token allows user-quota queries
6
+ *
7
+ * Conversion mirrors New API's own billing controller: USD = quota / quota_per_unit,
8
+ * CNY = USD × usd_exchange_rate, TOKENS = raw quota. `price` (top-up unit price)
9
+ * is not an exchange rate and is never used.
10
+ */
11
+ import { fetchJson, gatewayRoot, GatewayError } from './util.js';
12
+
13
+ const DEFAULT_QUOTA_PER_UNIT = 500_000;
14
+
15
+ export function displayOf(status) {
16
+ const perUnit = Number(status?.quota_per_unit) > 0 ? Number(status.quota_per_unit) : DEFAULT_QUOTA_PER_UNIT;
17
+ const type = String(status?.quota_display_type ?? (status?.display_in_currency === false ? 'TOKENS' : 'USD')).toUpperCase();
18
+ const rate = Number(status?.usd_exchange_rate) > 0 ? Number(status.usd_exchange_rate) : 7.3;
19
+ if (type === 'CNY') return { unit: 'CNY', symbol: '¥', factor: rate / perUnit, perUnit, estimated: status === undefined };
20
+ if (type === 'TOKENS') return { unit: 'TOKENS', symbol: '', factor: 1, perUnit, estimated: status === undefined };
21
+ if (type === 'CUSTOM') {
22
+ const customRate = Number(status?.custom_currency_exchange_rate) > 0 ? Number(status.custom_currency_exchange_rate) : 1;
23
+ return { unit: 'CUSTOM', symbol: status?.custom_currency_symbol || '¤', factor: customRate / perUnit, perUnit, estimated: false };
24
+ }
25
+ return { unit: 'USD', symbol: '$', factor: 1 / perUnit, perUnit, estimated: status === undefined };
26
+ }
27
+
28
+ function amount(quota, display) {
29
+ if (!Number.isFinite(quota)) return null;
30
+ return Math.round(quota * display.factor * 10000) / 10000;
31
+ }
32
+
33
+ export class BalanceService {
34
+ constructor({ capture, connection, apiKey, logger }) {
35
+ this.capture = capture ?? (() => { const conn = connection(); return { connection: conn, requireApiKey: () => apiKey(conn) }; });
36
+ this.logger = logger;
37
+ this.state = { status: 'idle' };
38
+ this.statusDoc = undefined;
39
+ this.statusAt = 0;
40
+ this.backoffUntil = 0;
41
+ this.inflight = undefined;
42
+ }
43
+
44
+ async serverStatus(root, headers) {
45
+ if (this.statusDoc && this.statusRoot === root && Date.now() - this.statusAt < 30 * 60 * 1000) return this.statusDoc;
46
+ if (this.statusRoot !== root) this.statusDoc = undefined;
47
+ try {
48
+ // Same gateway, same extra headers as every other request (header-gated proxies).
49
+ const doc = await fetchJson(`${root}/api/status`, { headers });
50
+ this.statusDoc = doc?.data ?? doc;
51
+ this.statusRoot = root;
52
+ this.statusAt = Date.now();
53
+ } catch (error) {
54
+ this.logger?.warn?.(`newapi-provider: /api/status unavailable: ${error.message}`);
55
+ }
56
+ return this.statusDoc;
57
+ }
58
+
59
+ /** Refresh unless rate-limited; concurrent callers share one request. */
60
+ refresh({ force = false } = {}) {
61
+ if (!force && Date.now() < this.backoffUntil) return Promise.resolve(this.state);
62
+ this.inflight ??= this.load().finally(() => { this.inflight = undefined; });
63
+ return this.inflight;
64
+ }
65
+
66
+ async load() {
67
+ let apiKey;
68
+ let root;
69
+ let headers;
70
+ try {
71
+ const bound = this.capture();
72
+ root = gatewayRoot(bound.connection.baseURL);
73
+ headers = bound.connection.headers;
74
+ apiKey = await bound.requireApiKey();
75
+ } catch (error) {
76
+ this.state = { status: 'error', code: 'MISSING_CREDENTIAL', message: error.message, at: Date.now() };
77
+ return this.state;
78
+ }
79
+ try {
80
+ const status = await this.serverStatus(root, headers);
81
+ const display = displayOf(status);
82
+ const usage = await fetchJson(`${root}/api/usage/token/`, { apiKey, headers });
83
+ const d = usage?.data ?? usage;
84
+ if (usage?.code === false || !d || typeof d !== 'object') throw new GatewayError(usage?.message || 'unexpected /api/usage/token response', 'BAD_RESPONSE');
85
+ const unlimited = d.unlimited_quota === true;
86
+ const token = {
87
+ name: d.name ?? null,
88
+ unlimited,
89
+ used: amount(d.total_used, display),
90
+ granted: unlimited ? null : amount(d.total_granted, display),
91
+ remaining: unlimited ? null : amount(d.total_available, display),
92
+ expiresAt: d.expires_at > 0 ? d.expires_at * (d.expires_at < 1e12 ? 1000 : 1) : null,
93
+ modelLimits: d.model_limits_enabled ? Object.keys(d.model_limits ?? {}) : null,
94
+ };
95
+ const user = d.user_quota && typeof d.user_quota === 'object'
96
+ ? { remaining: amount(d.user_quota.total_available, display), used: amount(d.user_quota.total_used, display) }
97
+ : null;
98
+ this.state = {
99
+ status: 'ok',
100
+ at: Date.now(),
101
+ currency: { unit: display.unit, symbol: display.symbol, estimated: display.estimated },
102
+ token,
103
+ user,
104
+ userQuotaHint: user ? null : 'enable-allow-user-quota-query',
105
+ serverVersion: status?.version ?? null,
106
+ systemName: status?.system_name ?? null,
107
+ };
108
+ this.backoffUntil = 0;
109
+ } catch (error) {
110
+ if (error.code === 'RATE_LIMIT') this.backoffUntil = Date.now() + (error.retryAfterMs ?? 60_000);
111
+ this.state = {
112
+ ...(this.state.status === 'ok' ? this.state : {}),
113
+ status: this.state.status === 'ok' ? 'stale' : 'error',
114
+ code: error.code ?? 'ERROR',
115
+ message: error.message,
116
+ errorAt: Date.now(),
117
+ };
118
+ }
119
+ return this.state;
120
+ }
121
+ }
package/lib/catalog.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Gateway model catalog.
3
+ *
4
+ * Sources, lowest to highest precedence:
5
+ * 1. GET /v1/models — ids and supported_endpoint_types
6
+ * 2. GET /api/pricing — vendor, quota type, ratios, per-call price
7
+ * 3. models.dev — context window, output cap, modalities, reasoning
8
+ * 4. user overrides (config) — anything the user pins
9
+ *
10
+ * Every entry carries a `kind` (chat / image / video / audio / embedding /
11
+ * other) so chat models go to the picker and media models go to the tools.
12
+ */
13
+ import { fetchJson, gatewayRoot } from './util.js';
14
+
15
+ export const DEFAULT_CONTEXT_WINDOW = 128_000;
16
+ export const DEFAULT_MAX_TOKENS = 32_768;
17
+ const CATALOG_TTL_MS = 10 * 60 * 1000;
18
+
19
+ /** Name rules used only when models.dev has no output-modality data. */
20
+ const KIND_PATTERNS = [
21
+ ['video', /(^|[-_/])(sora|veo|kling|vidu|hailuo|seedance|wan\d|wanx|cogvideo|happyhorse|video|t2v|i2v|runway|luma|pika)/i],
22
+ ['image', /(^|[-_/])(gpt-image|dall-e|imagen|flux|midjourney|mj[-_]|seedream|cogview|stable-diffusion|sdxl|sd3|recraft|ideogram|image)/i],
23
+ ['audio', /(^|[-_/])(tts|whisper|speech|audio|suno|voice)/i],
24
+ ['embedding', /(embed|embedding|rerank|bge-|text-embedding)/i],
25
+ ];
26
+
27
+ export function classify(id, dev) {
28
+ const out = dev?.outputModalities ?? [];
29
+ if (out.includes('video')) return 'video';
30
+ if (out.includes('image') && !out.includes('text')) return 'image';
31
+ if (out.includes('audio') && !out.includes('text')) return 'audio';
32
+ for (const [kind, re] of KIND_PATTERNS) if (re.test(id)) return kind;
33
+ if (out.includes('text') || out.length === 0) return 'chat';
34
+ return 'other';
35
+ }
36
+
37
+ /** One-hop wire identity. Empty legacy/config values retain the local id. */
38
+ export function upstreamModelOf(entry) {
39
+ return typeof entry?.upstreamModel === 'string' && entry.upstreamModel.trim() ? entry.upstreamModel.trim() : entry.id;
40
+ }
41
+
42
+ function prettyName(id) {
43
+ return id;
44
+ }
45
+
46
+ export class Catalog {
47
+ constructor({ modelsDev, logger }) {
48
+ this.modelsDev = modelsDev;
49
+ this.logger = logger;
50
+ this.cache = new Map();
51
+ this.epoch = 0;
52
+ }
53
+
54
+ invalidate() {
55
+ this.epoch++;
56
+ this.cache.clear();
57
+ }
58
+
59
+ /**
60
+ * Fetch the raw gateway lists. /v1/models is required; /api/pricing is
61
+ * optional because some deployments hide the pricing page.
62
+ */
63
+ async fetchGateway(conn, apiKey, signal) {
64
+ const root = gatewayRoot(conn.baseURL);
65
+ const models = await fetchJson(`${root}/v1/models`, { apiKey, signal, headers: conn.headers });
66
+ let pricing;
67
+ let pricingError;
68
+ try {
69
+ pricing = await fetchJson(`${root}/api/pricing`, { apiKey, signal, headers: conn.headers });
70
+ } catch (error) {
71
+ pricingError = String(error?.message ?? error);
72
+ }
73
+ return { models, pricing, pricingError };
74
+ }
75
+
76
+ /** Full catalog for one connection, cached per base URL + key fingerprint. */
77
+ async get(conn, apiKey, { signal, force = false } = {}) {
78
+ const key = this.key(conn);
79
+ const epoch = this.epoch;
80
+ const hit = this.cache.get(key);
81
+ if (!force && hit && Date.now() - hit.at < CATALOG_TTL_MS && hit.overridesRev === conn.overridesRev) return hit.value;
82
+ void this.modelsDev.refresh();
83
+ const raw = await this.fetchGateway(conn, apiKey, signal);
84
+ const value = this.build(conn, raw);
85
+ if (epoch === this.epoch) this.cache.set(key, { at: Date.now(), value, overridesRev: conn.overridesRev });
86
+ return value;
87
+ }
88
+
89
+ /** Last good catalog for a connection without network, if any. */
90
+ key(conn) {
91
+ return JSON.stringify([conn.channelId, gatewayRoot(conn.baseURL), conn.keyFingerprint, conn.overridesRev, conn.headers, conn.enabledModels ?? null]);
92
+ }
93
+
94
+ peek(conn) {
95
+ return this.cache.get(this.key(conn))?.value;
96
+ }
97
+
98
+ build(conn, { models, pricing, pricingError }) {
99
+ const priced = new Map();
100
+ for (const p of pricing?.data ?? []) priced.set(p.model_name, p);
101
+ const vendors = new Map((pricing?.vendors ?? []).map((v) => [v.id, v.name]));
102
+ const overrides = conn.overrides;
103
+ const entries = [];
104
+ const seen = new Set();
105
+
106
+ const gateway = new Map();
107
+ for (const m of models?.data ?? []) if (m?.id && !gateway.has(m.id)) gateway.set(m.id, m);
108
+ const make = (id, localGatewayEntry) => {
109
+ // Own-property read: ids like `toString` or `constructor` are legal model names.
110
+ const ov = (Object.hasOwn(overrides ?? {}, id) ? overrides[id] : undefined) ?? {};
111
+ const upstreamModel = upstreamModelOf({ id, upstreamModel: ov.upstreamModel });
112
+ const targetGatewayEntry = gateway.get(upstreamModel);
113
+ const targetPrice = priced.get(upstreamModel);
114
+ const price = targetPrice ?? priced.get(id);
115
+ const targetDev = this.modelsDev.match(upstreamModel);
116
+ const dev = targetDev ?? (upstreamModel !== id ? this.modelsDev.match(id) : undefined);
117
+ const kind = ov.kind ?? classify(upstreamModel, dev);
118
+ const endpointTypes = ov.protocol
119
+ ? [ov.protocol]
120
+ : (targetGatewayEntry?.supported_endpoint_types ?? targetPrice?.supported_endpoint_types ?? localGatewayEntry?.supported_endpoint_types ?? price?.supported_endpoint_types ?? []);
121
+ const devInput = (dev?.input ?? []).filter((m) => typeof m === 'string');
122
+ const input = ov.input ?? (devInput.length ? devInput : ['text']);
123
+ return {
124
+ id,
125
+ upstreamModel,
126
+ availability: gateway.has(upstreamModel) ? 'listed' : ov.custom === true ? 'custom' : 'missing',
127
+ automaticContextWindow: dev?.context > 0 ? dev.context : conn.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
128
+ automaticMaxTokens: dev?.output > 0 ? dev.output : conn.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
129
+ name: ov.name ?? prettyName(id),
130
+ kind,
131
+ hidden: ov.hidden === true,
132
+ endpointTypes,
133
+ vendor: price ? (vendors.get(price.vendor_id) ?? null) : null,
134
+ pricing: price ? {
135
+ quotaType: price.quota_type === 1 ? 'per-call' : 'per-token',
136
+ modelRatio: price.model_ratio,
137
+ completionRatio: price.completion_ratio,
138
+ modelPrice: price.model_price,
139
+ cacheRatio: price.create_cache_ratio,
140
+ groups: price.enable_groups ?? [],
141
+ } : null,
142
+ contextWindow: ov.contextWindow ?? (dev?.context > 0 ? dev.context : undefined),
143
+ maxTokens: ov.maxTokens ?? (dev?.output > 0 ? dev.output : undefined),
144
+ input,
145
+ automaticInput: devInput.length ? devInput : ['text'],
146
+ automaticReasoning: dev?.reasoning ?? false,
147
+ outputModalities: dev?.outputModalities ?? [],
148
+ reasoning: ov.reasoning ?? dev?.reasoning ?? false,
149
+ reasoningLevels: ov.reasoningLevels,
150
+ source: {
151
+ gateway: Boolean(localGatewayEntry),
152
+ upstreamGateway: gateway.has(upstreamModel),
153
+ metadataModel: targetDev ? upstreamModel : id,
154
+ pricingModel: price ? (priced.has(upstreamModel) ? upstreamModel : id) : null,
155
+ modelsDev: dev ? { provider: dev.provider, match: dev.match } : null,
156
+ overridden: Object.keys(ov).filter((k) => ov[k] !== undefined),
157
+ },
158
+ };
159
+ };
160
+
161
+ const enabled = Array.isArray(conn.enabledModels) ? conn.enabledModels : undefined;
162
+
163
+ if (enabled) {
164
+ // List mode: exactly the saved list, in saved order. Ids the gateway no
165
+ // longer returns stay visible and marked, so the user decides.
166
+ for (const id of enabled) {
167
+ if (seen.has(id)) continue;
168
+ seen.add(id);
169
+ const g = gateway.get(id);
170
+ const entry = make(id, g);
171
+ entries.push(entry);
172
+ }
173
+ } else {
174
+ // Automatic mode (configs from before the list existed): every gateway model.
175
+ for (const [id, g] of gateway) {
176
+ seen.add(id);
177
+ const entry = make(id, g);
178
+ entries.push(entry);
179
+ }
180
+ for (const id of Object.keys(overrides)) {
181
+ if (seen.has(id) || !Object.hasOwn(overrides, id) || overrides[id]?.custom !== true) continue;
182
+ seen.add(id);
183
+ const entry = make(id, undefined);
184
+ entries.push(entry);
185
+ }
186
+ }
187
+ return {
188
+ fetchedAt: Date.now(),
189
+ listMode: enabled ? 'list' : 'auto',
190
+ gatewayIds: [...gateway.keys()],
191
+ entries,
192
+ pricingAvailable: Boolean(pricing),
193
+ pricingError: pricingError ?? null,
194
+ supportedEndpoints: pricing?.supported_endpoint ?? null,
195
+ groupRatio: pricing?.group_ratio ?? null,
196
+ };
197
+ }
198
+ }
199
+
200
+ /** Chat models visible in the picker, in gateway order. */
201
+ export function chatModels(catalog) {
202
+ return (catalog?.entries ?? []).filter((e) => e.kind === 'chat' && !e.hidden);
203
+ }
204
+
205
+ export function mediaModels(catalog, kind) {
206
+ return (catalog?.entries ?? []).filter((e) => e.kind === kind && !e.hidden);
207
+ }