@edge-sky/dsh-oauth-adapter 0.1.2-beta.1 → 0.1.2-dev.2

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/index.js CHANGED
@@ -3,45 +3,31 @@
3
3
  * `dsh-llm-pi-ai` plugin.
4
4
  * @module @edge-sky/dsh-oauth-adapter
5
5
  */
6
+ import { OAuthModelService } from './model-service.js';
7
+ import { ModelOperationError } from './model-discovery.js';
6
8
  import { randomUUID } from 'node:crypto';
7
9
  import { inspect } from 'node:util';
8
- import { settingsNamespace } from '@deepseek-ai/dsh-settings';
9
10
  import z from '@deepseek-ai/schemastery';
10
11
  import WebSocket, { WebSocketServer } from 'ws';
11
12
  import { MAX_FRAME_BYTES, OAUTH_SOCKET_PATH, OAUTH_SOCKET_PROTOCOL, PROVIDERS, parseClientCommand, } from './protocol.js';
12
13
  /** Runtime-validated plugin configuration. */
13
14
  export const Config = z.object({
14
15
  debug: z.boolean().default(false),
16
+ modelSyncTimeoutMs: z.number().min(1).max(2147483647).step(1).default(30000),
17
+ modelSyncMaxResponseBytes: z.number().min(1024).step(1).default(4194304),
18
+ modelSyncMaxPages: z.number().min(1).max(1000).step(1).default(100),
19
+ codexClientVersion: z.string().default('0.149.0'),
15
20
  });
16
21
  /** Cordis plugin name. */
17
22
  export const name = 'dsh-oauth-adapter';
18
23
  /** Official services required by the OAuth account surface. */
19
- export const inject = ['authorization', 'credentials', 'settings', 'webServer'];
20
- const MODEL_SETTINGS_NS = settingsNamespace('llm-pi-ai');
24
+ export const inject = ['authorization', 'credentials', 'settings', 'webServer', 'llm'];
21
25
  function providerMeta(providerId) {
22
26
  const provider = PROVIDERS.find(candidate => candidate.id === providerId);
23
27
  if (provider === undefined)
24
28
  throw new Error(`unsupported provider: ${providerId}`);
25
29
  return provider;
26
30
  }
27
- function isRecord(value) {
28
- return typeof value === 'object' && value !== null && !Array.isArray(value);
29
- }
30
- function modelRouteEnabled(settings, provider) {
31
- const section = settings.get(MODEL_SETTINGS_NS);
32
- if (!isRecord(section) || !isRecord(section.providers))
33
- return false;
34
- return Object.hasOwn(section.providers, provider);
35
- }
36
- function pluginOwnsModelRoute(settings, provider) {
37
- const descriptor = settings.describe().find(candidate => candidate.ns === MODEL_SETTINGS_NS);
38
- if (!isRecord(descriptor?.user) || !isRecord(descriptor.user.providers))
39
- return false;
40
- const profile = descriptor.user.providers[provider.id];
41
- return isRecord(profile)
42
- && Object.keys(profile).length === 1
43
- && profile.displayName === provider.modelGroupLabel;
44
- }
45
31
  const GITHUB_DOMAIN_PROMPT = 'GitHub Enterprise URL/domain (blank for github.com)';
46
32
  function promptView(prompt, provider) {
47
33
  if (prompt.kind === 'select') {
@@ -176,14 +162,17 @@ class OAuthConnection {
176
162
  socket;
177
163
  debug;
178
164
  changed;
165
+ models;
179
166
  active;
180
167
  queue = Promise.resolve();
181
168
  closed = false;
182
- constructor(ctx, socket, debug, changed) {
169
+ tasks = new Set();
170
+ constructor(ctx, socket, debug, changed, models) {
183
171
  this.ctx = ctx;
184
172
  this.socket = socket;
185
173
  this.debug = debug;
186
174
  this.changed = changed;
175
+ this.models = models;
187
176
  socket.on('message', (raw, isBinary) => {
188
177
  if (isBinary || rawLength(raw) > MAX_FRAME_BYTES) {
189
178
  this.sendError('invalid-message', 'message must be bounded UTF-8 JSON');
@@ -207,16 +196,23 @@ class OAuthConnection {
207
196
  this.debug('connection=opened');
208
197
  void this.sendSnapshot();
209
198
  }
199
+ modelChanged(provider) { this.send({ type: 'models-changed', provider }); }
210
200
  refresh() {
211
201
  void this.sendSnapshot();
212
202
  }
213
- dispose() {
203
+ track(task) {
204
+ this.tasks.add(task);
205
+ void task.finally(() => this.tasks.delete(task)).catch(() => { });
206
+ }
207
+ async dispose() {
214
208
  this.closed = true;
215
209
  this.withdrawActive('OAuth surface stopped');
216
210
  this.socket.terminate();
211
+ await this.queue;
212
+ await Promise.allSettled([...this.tasks]);
217
213
  }
218
214
  send(message) {
219
- if (this.socket.readyState === WebSocket.OPEN)
215
+ if (!this.closed && this.socket.readyState === WebSocket.OPEN)
220
216
  this.socket.send(JSON.stringify(message));
221
217
  }
222
218
  sendError(code, message, requestId, attemptId, detail) {
@@ -228,6 +224,8 @@ class OAuthConnection {
228
224
  });
229
225
  }
230
226
  async receive(raw) {
227
+ if (this.closed)
228
+ return;
231
229
  const parsed = parseClientCommand(raw);
232
230
  if (!parsed.ok) {
233
231
  this.sendError(parsed.message === 'provider is unsupported' ? 'unsupported-provider' : 'invalid-message', parsed.message, parsed.requestId);
@@ -236,6 +234,10 @@ class OAuthConnection {
236
234
  await this.handle(parsed.value);
237
235
  }
238
236
  async handle(command) {
237
+ if (command.type.startsWith('models-')) {
238
+ this.track(this.handleModels(command));
239
+ return;
240
+ }
239
241
  switch (command.type) {
240
242
  case 'refresh':
241
243
  await this.sendSnapshot(command.requestId);
@@ -253,41 +255,45 @@ class OAuthConnection {
253
255
  await this.forget(command);
254
256
  }
255
257
  }
258
+ async handleModels(command) {
259
+ try {
260
+ switch (command.type) {
261
+ case 'models-list': break;
262
+ case 'models-sync':
263
+ await this.models.sync(command.provider, command.revision);
264
+ break;
265
+ case 'models-migrate':
266
+ await this.models.migrate(command.provider, command.revision);
267
+ break;
268
+ case 'models-save':
269
+ await this.models.saveManual(command.provider, command.model, command.revision);
270
+ break;
271
+ case 'models-delete':
272
+ await this.models.deleteManual(command.provider, command.id, command.revision);
273
+ break;
274
+ }
275
+ if (!this.closed)
276
+ this.send({ type: 'models-page', requestId: command.requestId,
277
+ page: await this.models.page(command.provider, command.type === 'models-list' ? command.offset : 0) });
278
+ }
279
+ catch (error) {
280
+ if (!this.closed)
281
+ this.send({ type: 'models-error', requestId: command.requestId, provider: command.provider,
282
+ code: error instanceof ModelOperationError ? error.code : 'operation-failed',
283
+ message: error instanceof ModelOperationError ? error.message : 'The model operation failed. Refresh and retry.' });
284
+ }
285
+ }
256
286
  async account(provider) {
287
+ await this.models.ready;
257
288
  const entry = this.ctx.authorization.describe(provider.key);
258
289
  const credential = await this.ctx.credentials.describeRecord(provider.key);
259
- let modelsEnabled = modelRouteEnabled(this.ctx.settings, provider.id);
260
- if (credential.configured && !modelsEnabled && this.ctx.settings.writable) {
261
- try {
262
- modelsEnabled = await this.enableModelRoute(provider);
263
- }
264
- catch (error) {
265
- this.ctx.logger.warn('dsh-oauth-adapter: model route activation failed for %s', provider.id);
266
- this.ctx.logger.warn(error);
267
- }
268
- }
269
290
  return {
270
- provider: provider.id,
271
- label: entry?.label ?? provider.fallbackLabel,
291
+ provider: provider.id, label: entry?.label ?? provider.fallbackLabel,
272
292
  available: entry?.methods.some(method => method.id === 'oauth') === true,
273
- configured: credential.configured,
274
- modelsEnabled,
275
- writable: credential.writable,
276
- inFlight: entry?.inFlight ?? false,
293
+ configured: credential.configured, modelsEnabled: this.models.isManaged(provider.id),
294
+ writable: credential.writable, inFlight: entry?.inFlight ?? false,
277
295
  };
278
296
  }
279
- async enableModelRoute(provider) {
280
- if (modelRouteEnabled(this.ctx.settings, provider.id))
281
- return true;
282
- if (!this.ctx.settings.writable)
283
- return false;
284
- await this.ctx.settings.mutate(MODEL_SETTINGS_NS, [{
285
- op: 'set',
286
- path: ['providers', provider.id],
287
- value: { displayName: provider.modelGroupLabel },
288
- }]);
289
- return modelRouteEnabled(this.ctx.settings, provider.id);
290
- }
291
297
  async sendSnapshot(requestId) {
292
298
  try {
293
299
  const accounts = await Promise.all(PROVIDERS.map(provider => this.account(provider)));
@@ -318,11 +324,12 @@ class OAuthConnection {
318
324
  id: randomUUID(), provider: command.provider, key: provider.key,
319
325
  controller: new AbortController(), prompts: new Map(),
320
326
  };
327
+ this.models.loginStarted(provider.id);
321
328
  this.active = attempt;
322
329
  this.changed();
323
330
  this.send({ type: 'started', requestId: command.requestId, attemptId: attempt.id, provider: attempt.provider });
324
331
  this.debug(`provider=${attempt.provider} stage=started`);
325
- void this.ctx.authorization.begin({
332
+ this.track(this.ctx.authorization.begin({
326
333
  key: attempt.key,
327
334
  method: 'oauth',
328
335
  signal: attempt.controller.signal,
@@ -338,26 +345,20 @@ class OAuthConnection {
338
345
  prompt: prompt => this.openPrompt(attempt, prompt),
339
346
  },
340
347
  }).then(async (outcome) => {
341
- if (outcome.status === 'authorized') {
342
- try {
343
- const enabled = await this.enableModelRoute(provider);
344
- if (!enabled) {
345
- this.sendError('model-route-unavailable', 'sign-in completed, but the OAuth model route could not be enabled', undefined, attempt.id);
346
- }
347
- }
348
- catch (error) {
349
- this.ctx.logger.warn('dsh-oauth-adapter: model route activation failed for %s', provider.id);
350
- this.ctx.logger.warn(error);
351
- this.sendError('model-route-unavailable', 'sign-in completed, but the OAuth model route could not be enabled', undefined, attempt.id, formatErrorDetail(error));
352
- }
348
+ try {
349
+ await this.models.loginFinished(provider.id, outcome.status === 'authorized');
350
+ }
351
+ catch (error) {
352
+ this.sendError('model-route-unavailable', 'Sign-in completed, but model synchronization did not complete. Open Models to retry.', undefined, attempt.id, formatErrorDetail(error));
353
353
  }
354
354
  this.finish(attempt, outcome.status);
355
- }, (error) => {
355
+ }, async (error) => {
356
+ await this.models.loginFinished(provider.id, false).catch(() => { });
356
357
  this.ctx.logger.warn('dsh-oauth-adapter: provider sign-in failed for %s', attempt.provider);
357
358
  this.ctx.logger.warn(error);
358
359
  this.sendError('sign-in-failed', 'sign-in failed', undefined, attempt.id, formatErrorDetail(error));
359
360
  this.finish(attempt, 'failed');
360
- });
361
+ }));
361
362
  }
362
363
  openPrompt(attempt, prompt) {
363
364
  if (this.active !== attempt || this.closed)
@@ -428,12 +429,7 @@ class OAuthConnection {
428
429
  return;
429
430
  }
430
431
  try {
431
- await this.ctx.credentials.deleteRecord(provider.key);
432
- if (pluginOwnsModelRoute(this.ctx.settings, provider)) {
433
- await this.ctx.settings.mutate(MODEL_SETTINGS_NS, [{
434
- op: 'unset', path: ['providers', provider.id],
435
- }]);
436
- }
432
+ await this.models.forget(provider.id);
437
433
  this.send({ type: 'ack', requestId: command.requestId });
438
434
  await this.sendSnapshot();
439
435
  }
@@ -466,21 +462,28 @@ class OAuthConnection {
466
462
  attempt.prompts.clear();
467
463
  }
468
464
  }
469
- /** Mount the loopback-only OAuth WebSocket over official rc2 services. */
465
+ /** Mount the loopback-only OAuth WebSocket over official rc services. */
470
466
  export function apply(ctx, config) {
471
467
  const debug = config.debug === true
472
468
  ? (message) => { ctx.logger.debug('dsh-oauth-adapter: %s', message); }
473
469
  : () => { };
474
470
  const connections = new Set();
471
+ const models = new OAuthModelService(ctx, {
472
+ timeoutMs: config.modelSyncTimeoutMs ?? 30000,
473
+ maxResponseBytes: config.modelSyncMaxResponseBytes ?? 4194304,
474
+ maxPages: config.modelSyncMaxPages ?? 100,
475
+ codexClientVersion: config.codexClientVersion ?? '0.149.0',
476
+ }, provider => { for (const connection of connections)
477
+ connection.modelChanged(provider); });
475
478
  const server = new WebSocketServer({
476
479
  noServer: true,
477
480
  maxPayload: MAX_FRAME_BYTES,
478
481
  handleProtocols: protocols => protocols.has(OAUTH_SOCKET_PROTOCOL) ? OAUTH_SOCKET_PROTOCOL : false,
479
482
  });
480
483
  server.on('connection', (socket) => {
481
- const connection = new OAuthConnection(ctx, socket, debug, refresh);
484
+ const connection = new OAuthConnection(ctx, socket, debug, refresh, models);
482
485
  connections.add(connection);
483
- socket.once('close', () => { connections.delete(connection); });
486
+ socket.once('close', () => { void connection.dispose().then(() => { connections.delete(connection); }); });
484
487
  });
485
488
  server.on('error', (error) => {
486
489
  ctx.logger.warn('dsh-oauth-adapter: WebSocket server failed');
@@ -507,9 +510,10 @@ export function apply(ctx, config) {
507
510
  return async () => {
508
511
  credentialDisposer();
509
512
  unregister();
510
- for (const connection of connections)
511
- connection.dispose();
513
+ const closing = [...connections].map(connection => connection.dispose());
512
514
  connections.clear();
515
+ await models.dispose();
516
+ await Promise.allSettled(closing);
513
517
  await new Promise((resolve) => { server.close(() => { resolve(); }); });
514
518
  };
515
519
  }
@@ -0,0 +1,8 @@
1
+ import type { Credential, CredentialStore } from '@earendil-works/pi-ai';
2
+ import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials';
3
+ export declare function fromRecord(record: CredentialRecord | undefined): Credential | undefined;
4
+ /** Every refresh checks its cancellation generation while holding the credential writer lock. */
5
+ export declare function credentialBridge(credentials: CredentialProvider, check: (provider: string) => void): CredentialStore;
6
+ /** Only provider-authored stable account identifiers are persisted, as a one-way hash. */
7
+ export declare function accountIdentity(provider: string, credential: Credential | undefined): string | undefined;
8
+ //# sourceMappingURL=model-auth.d.ts.map
@@ -0,0 +1,48 @@
1
+ /** Bridge public pi-ai authentication to DSH's existing credential records. */
2
+ import { createHash } from 'node:crypto';
3
+ import { recordKeyFor } from '@deepseek-ai/dsh-llm-pi-ai';
4
+ import { ModelOperationError } from './model-discovery.js';
5
+ export function fromRecord(record) {
6
+ if (!record)
7
+ return undefined;
8
+ if (record.kind === 'api-key')
9
+ return { type: 'api_key', ...(record.key === undefined ? {} : { key: record.key }), ...(record.env === undefined ? {} : { env: record.env }) };
10
+ const value = record.payload;
11
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !('type' in value) || value.type !== 'oauth')
12
+ throw new ModelOperationError('credential-format', 'The stored OAuth credential is not supported; reconnect this account.');
13
+ return value;
14
+ }
15
+ function toRecord(value) {
16
+ if (value.type === 'api_key')
17
+ return { kind: 'api-key', ...(value.key === undefined ? {} : { key: value.key }), ...(value.env === undefined ? {} : { env: value.env }) };
18
+ // pi-ai includes undefined optional properties; the durable record is JSON.
19
+ return { kind: 'grant', payload: JSON.parse(JSON.stringify(value)) };
20
+ }
21
+ /** Every refresh checks its cancellation generation while holding the credential writer lock. */
22
+ export function credentialBridge(credentials, check) {
23
+ return {
24
+ async read(provider) { check(provider); return fromRecord(await credentials.readRecord(recordKeyFor(provider))); },
25
+ async list() { return []; },
26
+ async modify(provider, mutate) {
27
+ check(provider);
28
+ return fromRecord(await credentials.modifyRecord(recordKeyFor(provider), async (current) => {
29
+ check(provider);
30
+ if (!current)
31
+ throw new ModelOperationError('not-connected', 'This account was disconnected.');
32
+ const next = await mutate(fromRecord(current));
33
+ check(provider);
34
+ return next === undefined ? undefined : toRecord(next);
35
+ }));
36
+ },
37
+ async delete(provider) { check(provider); await credentials.deleteRecord(recordKeyFor(provider)); },
38
+ };
39
+ }
40
+ /** Only provider-authored stable account identifiers are persisted, as a one-way hash. */
41
+ export function accountIdentity(provider, credential) {
42
+ if (credential?.type !== 'oauth')
43
+ return undefined;
44
+ const value = credential;
45
+ const id = value.accountId ?? value.userId ?? value.email;
46
+ return typeof id === 'string' && id.length > 0 ? createHash('sha256').update(`${provider}:${id}`).digest('hex') : undefined;
47
+ }
48
+ //# sourceMappingURL=model-auth.js.map
@@ -0,0 +1,27 @@
1
+ import type { Provider } from '@earendil-works/pi-ai';
2
+ import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai';
3
+ import { type ProviderId } from './protocol.js';
4
+ import { type ManualModel, type DiscoveredModel, type ModelRow } from './model-types.js';
5
+ export declare const providerFactories: Record<ProviderId, () => Provider>;
6
+ export interface ProviderModels {
7
+ phase: 'pending' | 'managed';
8
+ restoredFromLegacy?: boolean;
9
+ legacy: PiAiProviderProfile;
10
+ manual: ManualModel[];
11
+ discovered?: DiscoveredModel[];
12
+ syncedAt?: string;
13
+ /** A hash of a stable provider account identity; never an access/refresh token. */
14
+ account?: string;
15
+ }
16
+ export interface ModelStore {
17
+ version: 1;
18
+ providers: Partial<Record<ProviderId, ProviderModels>>;
19
+ }
20
+ /** Validate data loaded from the settings file, including the opaque legacy profile. */
21
+ export declare function validateModelStore(value: unknown): ModelStore;
22
+ /** Exact IDs only: unrelated providers and display-name similarities confer no compatibility. */
23
+ export declare function materialize(provider: ProviderId, state: ProviderModels, catalog?: Provider): {
24
+ profile: ResolvedPiAiProviderProfile;
25
+ rows: ModelRow[];
26
+ };
27
+ //# sourceMappingURL=model-catalog.d.ts.map
@@ -0,0 +1,145 @@
1
+ /** Build immutable pi-ai profiles from account discovery and explicitly authored models. */
2
+ import { createProvider } from '@earendil-works/pi-ai';
3
+ import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
4
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
5
+ import { githubCopilotProvider } from '@earendil-works/pi-ai/providers/github-copilot';
6
+ import { openaiCodexProvider } from '@earendil-works/pi-ai/providers/openai-codex';
7
+ import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
8
+ import { kimiCodingProvider } from '@earendil-works/pi-ai/providers/kimi-coding';
9
+ import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
10
+ import { xaiProvider } from '@earendil-works/pi-ai/providers/xai';
11
+ import { Config as PiConfig } from '@deepseek-ai/dsh-llm-pi-ai';
12
+ import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
13
+ import { PROVIDERS } from './protocol.js';
14
+ import { MODEL_PROTOCOLS, modelText, validManual } from './model-types.js';
15
+ import { ModelOperationError } from './model-discovery.js';
16
+ export const providerFactories = {
17
+ 'github-copilot': githubCopilotProvider, 'openai-codex': openaiCodexProvider,
18
+ anthropic: anthropicProvider, 'kimi-coding': kimiCodingProvider,
19
+ openrouter: openrouterProvider, xai: xaiProvider,
20
+ };
21
+ /** Validate data loaded from the settings file, including the opaque legacy profile. */
22
+ export function validateModelStore(value) {
23
+ if (!value || typeof value !== 'object')
24
+ throw new Error('Invalid oauth-models settings');
25
+ const store = value;
26
+ if (store.version !== 1 || !store.providers || typeof store.providers !== 'object' || Array.isArray(store.providers))
27
+ throw new Error('Unsupported oauth-models format');
28
+ for (const [id, entry] of Object.entries(store.providers)) {
29
+ if (!PROVIDERS.some(p => p.id === id) || !entry || !['pending', 'managed'].includes(entry.phase))
30
+ throw new Error('Invalid OAuth model provider');
31
+ if (!Array.isArray(entry.manual) || entry.manual.some(m => !validManual(id, m)))
32
+ throw new Error('Invalid manual OAuth model');
33
+ if (new Set(entry.manual.map(m => m.id)).size !== entry.manual.length)
34
+ throw new Error('Duplicate manual OAuth model');
35
+ if (entry.discovered !== undefined && (!Array.isArray(entry.discovered) || entry.discovered.some(m => !m || !modelText(m.id) || (m.name !== undefined && !modelText(m.name)))))
36
+ throw new Error('Invalid discovered OAuth model');
37
+ if (entry.syncedAt !== undefined && (typeof entry.syncedAt !== 'string' || !Number.isFinite(Date.parse(entry.syncedAt))))
38
+ throw new Error('Invalid synchronization time');
39
+ if (entry.account !== undefined && (typeof entry.account !== 'string' || !/^[a-f0-9]{64}$/.test(entry.account)))
40
+ throw new Error('Invalid account identity');
41
+ if (!entry.legacy || typeof entry.legacy !== 'object' || Array.isArray(entry.legacy))
42
+ throw new Error('Invalid legacy profile');
43
+ PiConfig({ providers: { [id]: entry.legacy } });
44
+ }
45
+ return store;
46
+ }
47
+ /** Exact IDs only: unrelated providers and display-name similarities confer no compatibility. */
48
+ export function materialize(provider, state, catalog = providerFactories[provider]()) {
49
+ const defaults = new Map(catalog.getModels().map(m => [m.id, m]));
50
+ const parsed = PiConfig({ providers: { [provider]: state.legacy } }).providers?.[provider];
51
+ if (!parsed)
52
+ throw new Error('Missing provider profile');
53
+ const manual = new Map(state.manual.map(m => [m.id, m]));
54
+ const configured = new Map((parsed.models ?? []).map(m => [m.id, m]));
55
+ const candidates = state.discovered ?? (configured.size ? [...configured.values()] : [...defaults.values()]);
56
+ const ids = new Set([...candidates.map(m => m.id), ...manual.keys()]);
57
+ const rows = [];
58
+ const models = [];
59
+ const caps = new Map();
60
+ for (const id of ids) {
61
+ const base = defaults.get(id);
62
+ const authored = manual.get(id);
63
+ const previous = configured.get(id) ?? parsed.modelOverrides?.[id];
64
+ const candidate = candidates.find(m => m.id === id);
65
+ if (!base && !authored) {
66
+ rows.push({ id, name: candidate?.name ?? id, source: 'pending' });
67
+ continue;
68
+ }
69
+ const choices = MODEL_PROTOCOLS[provider];
70
+ const api = authored ? authored.api ?? (choices.length === 1 ? choices[0] : base?.api) : parsed.api ?? base?.api;
71
+ if (!api)
72
+ throw new ModelOperationError('model-protocol', `Model ${id} needs a protocol.`);
73
+ const peers = [...defaults.values()].filter(m => m.api === api);
74
+ // Only provider-wide shared transport metadata can default an unknown model.
75
+ const shared = (items) => {
76
+ const first = items[0];
77
+ if (!first)
78
+ return undefined;
79
+ return Object.fromEntries(Object.entries(first).filter(([k, v]) => items.every(item => item && JSON.stringify(item[k]) === JSON.stringify(v))));
80
+ };
81
+ const headers = shared(peers.map(m => m.headers));
82
+ const compat = shared(peers.map(m => m.compat));
83
+ const generic = {
84
+ id, name: id, provider, api: api, baseUrl: parsed.baseURL ?? (provider === 'openrouter' && api === 'anthropic-messages' ? 'https://openrouter.ai/api' : catalog.baseUrl) ?? '',
85
+ reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
86
+ contextWindow: parsed.defaultContextWindow ?? 262144, maxTokens: parsed.defaultMaxTokens ?? 32768,
87
+ ...(headers ? { headers } : {}), ...(compat ? { compat } : {}),
88
+ };
89
+ const model = {
90
+ ...(base?.api === api ? base : { ...generic, ...(base ? { input: base.input, reasoning: base.reasoning, contextWindow: base.contextWindow, maxTokens: base.maxTokens, cost: base.cost } : {}) }), ...previous, id, provider, api: api,
91
+ name: authored ? authored.name ?? id : previous?.name ?? base?.name ?? candidate?.name ?? id,
92
+ ...(parsed.baseURL === undefined ? {} : { baseUrl: parsed.baseURL }),
93
+ };
94
+ const accepted = api === 'openai-completions' ? ['supportsStore', 'supportsDeveloperRole', 'supportsReasoningEffort', 'supportsUsageInStreaming', 'supportsFinishReason', 'maxTokensField', 'requiresToolResultName', 'requiresAssistantAfterToolResult', 'requiresThinkingAsText', 'requiresReasoningContentOnAssistantMessages', 'thinkingFormat', 'chatTemplateKwargs', 'chatTemplateArgs', 'supportsThinkingTokenBudget', 'supportsStrictMode', 'cacheControlFormat', 'supportsLongCacheRetention'] : api === 'anthropic-messages' ? ['supportsEagerToolInputStreaming', 'supportsLongCacheRetention', 'supportsCacheControlOnTools', 'supportsTemperature', 'forceAdaptiveThinking', 'allowEmptySignature', 'supportsStrictTools'] : ['supportsDeveloperRole', 'supportsStrictMode', 'supportsLongCacheRetention'];
95
+ const routeCompat = Object.fromEntries(Object.entries(parsed.compat ?? {}).filter(([key]) => accepted.includes(key)));
96
+ for (const key of Object.keys(previous?.compat ?? {}))
97
+ if (!accepted.includes(key))
98
+ throw new ModelOperationError('model-compat', `Model ${id} has an incompatible ${key} option.`);
99
+ if (Object.keys(routeCompat).length || previous?.compat)
100
+ model.compat = { ...(base?.api === api ? base.compat : generic.compat), ...routeCompat, ...previous?.compat };
101
+ if (previous?.reasoningEfforts !== undefined) {
102
+ model.reasoning = previous.reasoningEfforts !== false;
103
+ if (previous.reasoningEfforts !== false) {
104
+ const efforts = previous.reasoningEfforts;
105
+ if (!Object.entries(efforts).some(([level, value]) => level !== 'off' && typeof value === 'string' && value.length))
106
+ throw new ModelOperationError('model-reasoning', `Model ${id} needs explicit reasoning levels.`);
107
+ model.thinkingLevelMap = Object.fromEntries(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].flatMap(level => level === 'off' && efforts.off === null ? [] : [[level, efforts[level] ?? null]]));
108
+ }
109
+ }
110
+ if (!model.baseUrl)
111
+ throw new ModelOperationError('model-endpoint', `Provider ${provider} has no endpoint.`);
112
+ if (previous?.maxTokens !== undefined)
113
+ caps.set(id, previous.maxTokens);
114
+ models.push(freezeModel(model));
115
+ rows.push({ id, name: model.name, api, source: authored ? 'manual' : state.discovered ? 'matched' : 'catalog' });
116
+ }
117
+ const { models: _models, displayName: _displayName, apiKeyEnv: _key, retryPolicy: _retry, ...rest } = parsed;
118
+ const piProvider = provider === 'openrouter' ? createProvider({
119
+ id: provider, name: catalog.name, baseUrl: catalog.baseUrl, auth: catalog.auth, models,
120
+ api: { 'openai-completions': openAICompletionsApi(), 'anthropic-messages': anthropicMessagesApi() },
121
+ }) : { ...catalog, getModels: () => models, filterModels: (current) => current };
122
+ return {
123
+ rows,
124
+ profile: {
125
+ ...rest, provider, displayName: parsed.displayName ?? PROVIDERS.find(p => p.id === provider).modelGroupLabel,
126
+ streamIdleTimeoutMs: parsed.streamIdleTimeoutMs ?? 300000,
127
+ maxRequestImageBytes: parsed.maxRequestImageBytes ?? 20971520,
128
+ requestImagePixelBudget: parsed.requestImagePixelBudget ?? 4194304,
129
+ requestImageMaxBytes: parsed.requestImageMaxBytes ?? 1048576,
130
+ retryPolicy: resolveRetryPolicy(parsed.retryPolicy, `oauth-models/${provider}`),
131
+ configuredMaxTokens: caps,
132
+ piProvider,
133
+ },
134
+ };
135
+ }
136
+ /** Freeze JSON model metadata recursively; prepared calls retain their captured descriptions. */
137
+ function freezeModel(value) {
138
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
139
+ for (const child of Object.values(value))
140
+ freezeModel(child);
141
+ Object.freeze(value);
142
+ }
143
+ return value;
144
+ }
145
+ //# sourceMappingURL=model-catalog.js.map
@@ -0,0 +1,22 @@
1
+ /** Account-scoped model discovery. Each provider owns its endpoint and response parser. */
2
+ import type { ModelAuth } from '@earendil-works/pi-ai';
3
+ import type { ProviderId } from './protocol.js';
4
+ import { type DiscoveredModel } from './model-types.js';
5
+ export declare class ModelOperationError extends Error {
6
+ readonly code: string;
7
+ constructor(code: string, message: string);
8
+ }
9
+ export interface DiscoveryOptions {
10
+ signal: AbortSignal;
11
+ maxBytes: number;
12
+ maxPages: number;
13
+ codexClientVersion: string;
14
+ fetch?: typeof globalThis.fetch;
15
+ }
16
+ export interface DiscoveryAuth {
17
+ auth: ModelAuth;
18
+ accountId?: string;
19
+ }
20
+ /** Fetch complete account-visible results; never substitute the public OpenRouter catalog. */
21
+ export declare function discoverAccountModels(provider: ProviderId, resolved: DiscoveryAuth, options: DiscoveryOptions): Promise<DiscoveredModel[]>;
22
+ //# sourceMappingURL=model-discovery.d.ts.map