@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/README.i18n.yaml +2 -2
- package/README.md +63 -28
- package/README.zh.md +63 -28
- package/lib/client.js +500 -7
- package/lib/index.d.ts +7 -3
- package/lib/index.js +82 -78
- package/lib/model-auth.d.ts +8 -0
- package/lib/model-auth.js +48 -0
- package/lib/model-catalog.d.ts +27 -0
- package/lib/model-catalog.js +145 -0
- package/lib/model-discovery.d.ts +22 -0
- package/lib/model-discovery.js +147 -0
- package/lib/model-service.d.ts +64 -0
- package/lib/model-service.js +371 -0
- package/lib/model-types.d.ts +92 -0
- package/lib/model-types.js +25 -0
- package/lib/protocol.d.ts +3 -2
- package/lib/protocol.js +27 -0
- package/lib/types/client/OAuthModelsSection.d.ts +11 -0
- package/lib/types/client/index.d.ts +2 -2
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/store.d.ts +17 -0
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/model-types.d.ts +92 -0
- package/lib/types/protocol.d.ts +3 -2
- package/package.json +62 -35
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|