@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.
@@ -0,0 +1,147 @@
1
+ import { modelText } from './model-types.js';
2
+ export class ModelOperationError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ }
9
+ function record(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
11
+ }
12
+ function invalid() { throw new ModelOperationError('invalid-catalog', 'The provider returned an invalid model catalog.'); }
13
+ /** Read a bounded page; errors never include response bodies or token-bearing URLs. */
14
+ async function readPage(url, headers, options) {
15
+ const response = await (options.fetch ?? globalThis.fetch)(url, { headers, signal: options.signal, redirect: 'error' });
16
+ if (!response.ok) {
17
+ await response.body?.cancel();
18
+ throw new ModelOperationError(response.status === 401 || response.status === 403 ? 'discovery-auth' : response.status === 404 || response.status === 405 ? 'discovery-unsupported' : 'discovery-failed', `Model discovery returned HTTP ${response.status}.`);
19
+ }
20
+ if (!response.body)
21
+ invalid();
22
+ const reader = response.body.getReader();
23
+ const chunks = [];
24
+ let size = 0;
25
+ try {
26
+ for (;;) {
27
+ const part = await reader.read();
28
+ if (part.done)
29
+ break;
30
+ size += part.value.byteLength;
31
+ if (size > options.maxBytes)
32
+ throw new ModelOperationError('catalog-too-large', 'The model catalog exceeds the configured response limit.');
33
+ chunks.push(part.value);
34
+ }
35
+ }
36
+ finally {
37
+ await reader.cancel().catch(() => { });
38
+ }
39
+ const bytes = new Uint8Array(size);
40
+ let offset = 0;
41
+ for (const chunk of chunks) {
42
+ bytes.set(chunk, offset);
43
+ offset += chunk.byteLength;
44
+ }
45
+ let value;
46
+ try {
47
+ value = JSON.parse(new TextDecoder().decode(bytes));
48
+ }
49
+ catch {
50
+ invalid();
51
+ }
52
+ return record(value) ?? invalid();
53
+ }
54
+ /** Fetch complete account-visible results; never substitute the public OpenRouter catalog. */
55
+ export async function discoverAccountModels(provider, resolved, options) {
56
+ const headers = new Headers(resolved.auth.headers);
57
+ headers.set('accept', 'application/json');
58
+ if (resolved.auth.apiKey)
59
+ headers.set('authorization', `Bearer ${resolved.auth.apiKey}`);
60
+ let url;
61
+ switch (provider) {
62
+ case 'github-copilot':
63
+ url = new URL(`${(resolved.auth.baseUrl ?? 'https://api.individual.githubcopilot.com').replace(/\/+$/, '')}/models`);
64
+ headers.set('editor-version', 'vscode/1.107.0');
65
+ headers.set('editor-plugin-version', 'copilot-chat/0.35.0');
66
+ headers.set('copilot-integration-id', 'vscode-chat');
67
+ headers.set('user-agent', 'GitHubCopilotChat/0.35.0');
68
+ headers.set('x-github-api-version', '2026-06-01');
69
+ break;
70
+ case 'openai-codex':
71
+ if (!resolved.accountId)
72
+ throw new ModelOperationError('discovery-auth', 'The Codex credential has no account ID; reconnect this account.');
73
+ url = new URL('https://chatgpt.com/backend-api/codex/models');
74
+ url.searchParams.set('client_version', options.codexClientVersion);
75
+ headers.set('chatgpt-account-id', resolved.accountId);
76
+ headers.set('originator', 'pi');
77
+ break;
78
+ case 'anthropic':
79
+ url = new URL('https://api.anthropic.com/v1/models?limit=1000');
80
+ headers.set('anthropic-version', '2023-06-01');
81
+ headers.set('anthropic-beta', 'oauth-2025-04-20');
82
+ break;
83
+ case 'kimi-coding':
84
+ url = new URL('https://api.kimi.com/coding/v1/models');
85
+ break;
86
+ case 'openrouter':
87
+ url = new URL('https://openrouter.ai/api/v1/models/user?limit=500&offset=0');
88
+ break;
89
+ case 'xai':
90
+ url = new URL('https://api.x.ai/v1/models');
91
+ break;
92
+ }
93
+ const results = new Map();
94
+ const cursors = new Set();
95
+ for (let page = 0; page < options.maxPages; page += 1) {
96
+ options.signal.throwIfAborted();
97
+ const body = await readPage(url, headers, options);
98
+ const data = provider === 'openai-codex' ? body.models : body.data;
99
+ if (!Array.isArray(data))
100
+ invalid();
101
+ for (const raw of data) {
102
+ const item = record(raw) ?? invalid();
103
+ const id = provider === 'openai-codex' ? item.slug : item.id;
104
+ if (!modelText(id))
105
+ invalid();
106
+ if (provider === 'github-copilot') {
107
+ const capabilities = record(item.capabilities);
108
+ if (record(capabilities?.supports)?.tool_calls === false)
109
+ continue;
110
+ if (item.model_picker_enabled !== true || record(item.policy)?.state === 'disabled')
111
+ continue;
112
+ }
113
+ if (provider === 'openai-codex' && item.visibility !== 'list')
114
+ continue;
115
+ const name = item.display_name ?? item.name;
116
+ if (name !== undefined && !modelText(name))
117
+ invalid();
118
+ results.set(id, { id, ...(name === undefined ? {} : { name }) });
119
+ }
120
+ if (provider === 'openrouter') {
121
+ if (body.total_count !== undefined && (!Number.isSafeInteger(body.total_count) || body.total_count < 0))
122
+ invalid();
123
+ const consumed = Number(url.searchParams.get('offset')) + data.length;
124
+ const more = body.total_count !== undefined ? consumed < body.total_count : data.length === 500;
125
+ if (more) {
126
+ if (data.length === 0 || cursors.has(JSON.stringify(data)))
127
+ invalid();
128
+ cursors.add(JSON.stringify(data));
129
+ url = new URL(url);
130
+ url.searchParams.set('offset', String(consumed));
131
+ continue;
132
+ }
133
+ return [...results.values()];
134
+ }
135
+ if (body.has_more === undefined || body.has_more === false)
136
+ return [...results.values()];
137
+ if (provider !== 'anthropic')
138
+ invalid();
139
+ if (body.has_more !== true || !modelText(body.last_id) || cursors.has(body.last_id))
140
+ invalid();
141
+ cursors.add(body.last_id);
142
+ url = new URL(url);
143
+ url.searchParams.set('after_id', body.last_id);
144
+ }
145
+ throw new ModelOperationError('catalog-too-large', 'The model catalog exceeds the configured page limit.');
146
+ }
147
+ //# sourceMappingURL=model-discovery.js.map
@@ -0,0 +1,64 @@
1
+ /** Persistent OAuth model management and reversible ownership of existing provider routes. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ import type { CredentialProvider } from '@deepseek-ai/dsh-credentials';
4
+ import type { SettingsProvider } from '@deepseek-ai/dsh-settings';
5
+ import type LlmRuntime from '@deepseek-ai/dsh-llm';
6
+ import { type ProviderId } from './protocol.js';
7
+ import { type ModelPage, type ManualModel } from './model-types.js';
8
+ export interface ModelServiceOptions {
9
+ timeoutMs: number;
10
+ maxResponseBytes: number;
11
+ maxPages: number;
12
+ codexClientVersion: string;
13
+ }
14
+ export type ModelContext = Context & {
15
+ settings: SettingsProvider;
16
+ credentials: CredentialProvider;
17
+ llm: LlmRuntime;
18
+ };
19
+ export declare class OAuthModelService {
20
+ private readonly ctx;
21
+ private readonly options;
22
+ private readonly changed;
23
+ private profiles;
24
+ private handle;
25
+ private readonly queues;
26
+ private readonly syncs;
27
+ private readonly controllers;
28
+ private readonly generations;
29
+ private readonly loggingIn;
30
+ private readonly errors;
31
+ private readonly disposers;
32
+ private closed;
33
+ private readonly stop;
34
+ private readonly signedOut;
35
+ private readonly adapter;
36
+ readonly ready: Promise<void>;
37
+ constructor(ctx: ModelContext, options: ModelServiceOptions, changed: (provider: ProviderId) => void);
38
+ private state;
39
+ revision(): number;
40
+ isManaged(provider: ProviderId): boolean;
41
+ private checkRevision;
42
+ private checkOpen;
43
+ private save;
44
+ private report;
45
+ private enqueue;
46
+ private initialize;
47
+ private legacy;
48
+ private connected;
49
+ private publish;
50
+ private reconcile;
51
+ /** Wait on the actual route registry; settings persistence alone does not imply adapter release. */
52
+ private releaseLegacy;
53
+ private ensureManaged;
54
+ page(provider: ProviderId, offset: number): Promise<ModelPage>;
55
+ migrate(provider: ProviderId, revision: number): Promise<void>;
56
+ saveManual(provider: ProviderId, model: ManualModel, revision: number): Promise<void>;
57
+ deleteManual(provider: ProviderId, id: string, revision: number): Promise<void>;
58
+ sync(provider: ProviderId, revision: number): Promise<void>;
59
+ loginStarted(provider: ProviderId): void;
60
+ loginFinished(provider: ProviderId, authorized: boolean): Promise<void>;
61
+ forget(provider: ProviderId): Promise<void>;
62
+ dispose(): Promise<void>;
63
+ }
64
+ //# sourceMappingURL=model-service.d.ts.map
@@ -0,0 +1,371 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { createModels } from '@earendil-works/pi-ai';
3
+ import { SettingsConflictError } from '@deepseek-ai/dsh-settings';
4
+ import { resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm';
5
+ import { PiAiAdapter, recordKeyFor } from '@deepseek-ai/dsh-llm-pi-ai';
6
+ import { PROVIDERS } from './protocol.js';
7
+ import { MODEL_PAGE_SIZE, MODEL_PROTOCOLS, validManual } from './model-types.js';
8
+ import { accountIdentity, credentialBridge, fromRecord } from './model-auth.js';
9
+ import { materialize, providerFactories, validateModelStore } from './model-catalog.js';
10
+ import { discoverAccountModels, ModelOperationError } from './model-discovery.js';
11
+ const NS = 'oauth-models';
12
+ const LEGACY_NS = 'llm-pi-ai';
13
+ const Schema = z.object({ version: z.const(1).default(1), providers: z.dict(z.any()).default({}) });
14
+ function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; }
15
+ function failure(code, message) { throw new ModelOperationError(code, message); }
16
+ export class OAuthModelService {
17
+ ctx;
18
+ options;
19
+ changed;
20
+ profiles = new Map();
21
+ handle;
22
+ queues = new Map();
23
+ syncs = new Map();
24
+ controllers = new Map();
25
+ generations = new Map();
26
+ loggingIn = new Set();
27
+ errors = new Map();
28
+ disposers = [];
29
+ closed = false;
30
+ stop = new AbortController();
31
+ signedOut = new Set();
32
+ adapter;
33
+ ready;
34
+ constructor(ctx, options, changed) {
35
+ this.ctx = ctx;
36
+ this.options = options;
37
+ this.changed = changed;
38
+ const scope = ctx.settings.register(NS, Schema, { base: { version: 1, providers: {} }, validate: value => { validateModelStore(value); } });
39
+ this.adapter = new PiAiAdapter({
40
+ profiles: () => this.profiles,
41
+ resolveApiKey: async () => undefined,
42
+ auth: { credentials: credentialBridge(ctx.credentials, provider => { if (this.closed)
43
+ failure('disposed', 'OAuth models have stopped.'); if (this.signedOut.has(provider))
44
+ failure('not-connected', 'This account was disconnected.'); if (this.loggingIn.has(provider))
45
+ failure('busy', 'Account reconnection is in progress.'); }), authContext: { env: async () => undefined, fileExists: async () => false } },
46
+ resolveAttachments: () => ctx.get('attachments'),
47
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), ref),
48
+ });
49
+ this.ready = this.initialize();
50
+ this.disposers.push(scope.watch(() => {
51
+ if (this.closed)
52
+ return;
53
+ for (const p of PROVIDERS)
54
+ void this.enqueue(p.id, async () => { await this.reconcile(p.id); }).catch(error => this.report(p.id, error));
55
+ }));
56
+ this.disposers.push(ctx.on('credentials/record-updated', key => {
57
+ const provider = PROVIDERS.find(p => p.key === key)?.id;
58
+ if (!provider || this.closed || this.loggingIn.has(provider))
59
+ return;
60
+ void this.enqueue(provider, async () => { await this.reconcile(provider); }).catch(error => this.report(provider, error));
61
+ }));
62
+ }
63
+ state() { return validateModelStore(this.ctx.settings.get(NS)); }
64
+ revision() { return this.ctx.settings.describe().find(s => s.ns === NS)?.revision ?? 0; }
65
+ isManaged(provider) { return this.profiles.has(provider); }
66
+ checkRevision(revision) { if (revision !== this.revision())
67
+ failure('settings-conflict', 'Models changed in another operation. Refresh and retry.'); }
68
+ checkOpen() { if (this.closed)
69
+ failure('disposed', 'OAuth models have stopped.'); }
70
+ async save(provider, state, revision = this.revision()) {
71
+ this.checkOpen();
72
+ materialize(provider, state);
73
+ try {
74
+ await this.ctx.settings.mutate(NS, [{ op: 'set', path: ['providers', provider], value: state }], revision);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof SettingsConflictError)
78
+ failure('settings-conflict', 'Models changed in another operation. Refresh and retry.');
79
+ throw error;
80
+ }
81
+ }
82
+ report(provider, error) {
83
+ this.errors.set(provider, error instanceof ModelOperationError ? { code: error.code, message: error.message } : { code: 'operation-failed', message: 'Model operation failed. The previous configuration was retained.' });
84
+ this.changed(provider);
85
+ }
86
+ enqueue(provider, action) {
87
+ if (this.closed)
88
+ return Promise.reject(new ModelOperationError('disposed', 'OAuth models have stopped.'));
89
+ const previous = this.queues.get(provider) ?? Promise.resolve();
90
+ const task = previous.catch(() => { }).then(() => { this.checkOpen(); return action(); });
91
+ this.queues.set(provider, task);
92
+ void task.finally(() => { if (this.queues.get(provider) === task)
93
+ this.queues.delete(provider); }).catch(() => { });
94
+ return task;
95
+ }
96
+ async initialize() {
97
+ for (const p of PROVIDERS) {
98
+ try {
99
+ await this.enqueue(p.id, async () => { await this.ensureManaged(p.id, false); });
100
+ }
101
+ catch (error) {
102
+ this.report(p.id, error);
103
+ }
104
+ }
105
+ }
106
+ legacy(provider) {
107
+ const descriptor = this.ctx.settings.describe().find(s => s.ns === LEGACY_NS);
108
+ if (!descriptor)
109
+ failure('model-host', 'The DSH pi-ai settings namespace is unavailable.');
110
+ const profile = object(object(descriptor.user)?.providers)?.[provider];
111
+ const base = object(object(descriptor.base)?.providers)?.[provider];
112
+ const value = object(profile);
113
+ const label = PROVIDERS.find(p => p.id === provider).modelGroupLabel;
114
+ return { profile: value, revision: descriptor.revision,
115
+ custom: value !== undefined && !(Object.keys(value).length === 1 && value.displayName === label),
116
+ blocked: base !== undefined || (value !== undefined && value.apiKeyEnv !== undefined) };
117
+ }
118
+ async connected(provider) { return !this.signedOut.has(provider) && fromRecord(await this.ctx.credentials.readRecord(recordKeyFor(provider)))?.type === 'oauth'; }
119
+ async publish(provider, state) {
120
+ this.checkOpen();
121
+ const next = new Map(this.profiles);
122
+ if (state && await this.connected(provider))
123
+ next.set(provider, materialize(provider, state).profile);
124
+ else
125
+ next.delete(provider);
126
+ this.checkOpen();
127
+ const old = this.profiles;
128
+ this.profiles = next;
129
+ try {
130
+ if (this.handle)
131
+ this.handle.replace([...next.keys()]);
132
+ else if (next.size)
133
+ this.handle = this.ctx.llm.registerAdapter([...next.keys()], this.adapter);
134
+ }
135
+ catch (error) {
136
+ this.profiles = old;
137
+ throw error;
138
+ }
139
+ this.changed(provider);
140
+ }
141
+ async reconcile(provider) {
142
+ if (this.loggingIn.has(provider))
143
+ return;
144
+ const state = this.state().providers[provider];
145
+ await this.publish(provider, state?.phase === 'managed' ? state : undefined);
146
+ }
147
+ /** Wait on the actual route registry; settings persistence alone does not imply adapter release. */
148
+ async releaseLegacy(provider, revision) {
149
+ const signal = AbortSignal.any([this.stop.signal, AbortSignal.timeout(this.options.timeoutMs)]);
150
+ let wake;
151
+ const dispose = this.ctx.on('llm/adapters-updated', () => { wake?.(); });
152
+ try {
153
+ await this.ctx.settings.mutate(LEGACY_NS, [{ op: 'unset', path: ['providers', provider] }], revision);
154
+ while (this.ctx.llm.listProviders().some(p => p.id === provider)) {
155
+ signal.throwIfAborted();
156
+ await new Promise((resolve, reject) => {
157
+ const abort = () => { cleanup(); reject(new ModelOperationError('route-conflict', 'The previous model adapter did not release this provider.')); };
158
+ const cleanup = () => { signal.removeEventListener('abort', abort); wake = undefined; };
159
+ wake = () => { cleanup(); resolve(); };
160
+ signal.addEventListener('abort', abort, { once: true });
161
+ if (signal.aborted)
162
+ abort();
163
+ else if (!this.ctx.llm.listProviders().some(p => p.id === provider))
164
+ wake();
165
+ });
166
+ }
167
+ }
168
+ finally {
169
+ dispose();
170
+ }
171
+ }
172
+ async ensureManaged(provider, approve) {
173
+ if (!await this.connected(provider))
174
+ return;
175
+ let state = this.state().providers[provider];
176
+ if (state?.phase === 'managed') {
177
+ await this.publish(provider, state);
178
+ return;
179
+ }
180
+ const legacy = this.legacy(provider);
181
+ if (legacy.blocked)
182
+ failure('route-conflict', 'This provider has an API-key reference or a composition-owned profile; it was not changed.');
183
+ if (!state && legacy.custom && !approve)
184
+ failure('migration-required', 'Review and migrate the existing custom OAuth configuration before managing models.');
185
+ if (!state) {
186
+ state = { phase: 'pending', legacy: legacy.profile ?? { displayName: PROVIDERS.find(p => p.id === provider).modelGroupLabel }, manual: [], restoredFromLegacy: legacy.profile !== undefined };
187
+ const choices = MODEL_PROTOCOLS[provider];
188
+ if (state.legacy.api && !choices.includes(state.legacy.api))
189
+ failure('route-conflict', 'The existing protocol is not supported by this OAuth provider.');
190
+ state.manual = (state.legacy.models ?? []).map(model => {
191
+ const api = state.legacy.api ?? providerFactories[provider]().getModels().find(m => m.id === model.id)?.api ?? (choices.length === 1 ? choices[0] : undefined);
192
+ if (!api)
193
+ failure('model-protocol', `Existing model ${model.id} needs an explicit protocol.`);
194
+ return { id: model.id, ...(model.name ? { name: model.name } : {}), ...(choices.length > 1 ? { api: api } : {}) };
195
+ });
196
+ await this.save(provider, state);
197
+ }
198
+ try {
199
+ if (legacy.profile)
200
+ await this.releaseLegacy(provider, legacy.revision);
201
+ else if (this.ctx.llm.listProviders().some(p => p.id === provider) && !this.profiles.has(provider))
202
+ failure('route-conflict', 'Another adapter owns this provider; it was not changed.');
203
+ const managed = { ...state, phase: 'managed' };
204
+ await this.publish(provider, managed);
205
+ await this.save(provider, managed);
206
+ this.errors.delete(provider);
207
+ }
208
+ catch (error) {
209
+ await this.publish(provider, undefined);
210
+ // Restore only if the source is still absent. Never overwrite a concurrent profile edit.
211
+ const source = this.legacy(provider);
212
+ if (!source.profile && state.restoredFromLegacy)
213
+ await this.ctx.settings.mutate(LEGACY_NS, [{ op: 'set', path: ['providers', provider], value: state.legacy }], source.revision);
214
+ throw error;
215
+ }
216
+ }
217
+ async page(provider, offset) {
218
+ await this.ready;
219
+ const state = this.state().providers[provider];
220
+ const fallback = { phase: 'pending', legacy: this.legacy(provider).profile ?? {}, manual: [] };
221
+ const rows = materialize(provider, state ?? fallback).rows;
222
+ const legacy = this.legacy(provider);
223
+ offset = Math.min(offset, Math.max(0, Math.floor((rows.length - 1) / MODEL_PAGE_SIZE) * MODEL_PAGE_SIZE));
224
+ return {
225
+ provider, revision: this.revision(), offset, total: rows.length, rows: rows.slice(offset, offset + MODEL_PAGE_SIZE),
226
+ source: state?.discovered ? 'account' : 'catalog',
227
+ counts: { matched: rows.filter(r => r.source === 'matched' || r.source === 'catalog').length, pending: rows.filter(r => r.source === 'pending').length, manual: rows.filter(r => r.source === 'manual').length },
228
+ connected: await this.connected(provider), writable: this.ctx.settings.writable,
229
+ managed: this.isManaged(provider), busy: this.syncs.has(provider),
230
+ ...(state?.syncedAt === undefined ? {} : { syncedAt: state.syncedAt }),
231
+ ...(this.errors.has(provider) ? { error: this.errors.get(provider).message, errorCode: this.errors.get(provider).code } : {}),
232
+ ...((state?.phase === 'pending' || !state && legacy.custom) && !legacy.blocked ? { migration: { fields: Object.keys(state?.legacy ?? legacy.profile ?? {}), models: (state?.legacy ?? legacy.profile)?.models?.length ?? 0 } } : {}),
233
+ };
234
+ }
235
+ async migrate(provider, revision) {
236
+ await this.ready;
237
+ return this.enqueue(provider, async () => { this.checkRevision(revision); await this.ensureManaged(provider, true); });
238
+ }
239
+ async saveManual(provider, model, revision) {
240
+ if (!validManual(provider, model))
241
+ failure('invalid-model', 'Model fields or protocol are invalid.');
242
+ await this.ready;
243
+ return this.enqueue(provider, async () => {
244
+ this.checkRevision(revision);
245
+ await this.ensureManaged(provider, false);
246
+ const state = this.state().providers[provider];
247
+ if (!state || !await this.connected(provider))
248
+ failure('not-connected', 'Connect this provider before editing models.');
249
+ const next = { ...state, manual: [...state.manual.filter(m => m.id !== model.id), model] };
250
+ await this.save(provider, next);
251
+ await this.publish(provider, next);
252
+ });
253
+ }
254
+ async deleteManual(provider, id, revision) {
255
+ await this.ready;
256
+ return this.enqueue(provider, async () => {
257
+ this.checkRevision(revision);
258
+ const state = this.state().providers[provider];
259
+ if (!state)
260
+ failure('not-connected', 'This provider has no managed models.');
261
+ const next = { ...state, manual: state.manual.filter(m => m.id !== id) };
262
+ await this.save(provider, next);
263
+ await this.publish(provider, next);
264
+ });
265
+ }
266
+ async sync(provider, revision) {
267
+ await this.ready;
268
+ const running = this.syncs.get(provider);
269
+ if (running)
270
+ return running;
271
+ const generation = this.generations.get(provider) ?? 0;
272
+ const controller = new AbortController();
273
+ this.controllers.set(provider, controller);
274
+ const task = this.enqueue(provider, async () => {
275
+ this.checkRevision(revision);
276
+ await this.ensureManaged(provider, false);
277
+ const state = this.state().providers[provider];
278
+ if (!state || !await this.connected(provider))
279
+ failure('not-connected', 'Connect this provider before synchronizing models.');
280
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(this.options.timeoutMs)]);
281
+ const check = () => { this.checkOpen(); signal.throwIfAborted(); if ((this.generations.get(provider) ?? 0) !== generation)
282
+ failure('cancelled', 'The account changed during synchronization.'); };
283
+ const collection = createModels({ credentials: credentialBridge(this.ctx.credentials, check), authContext: { env: async () => undefined, fileExists: async () => false } });
284
+ collection.setProvider(providerFactories[provider]());
285
+ const resolved = await collection.getAuth(provider, { signal });
286
+ check();
287
+ if (!resolved)
288
+ failure('not-connected', 'The provider has no usable OAuth credential.');
289
+ const credential = fromRecord(await this.ctx.credentials.readRecord(recordKeyFor(provider)));
290
+ const accountId = credential?.type === 'oauth' && typeof credential.accountId === 'string' ? credential.accountId : undefined;
291
+ const discovered = await discoverAccountModels(provider, { auth: resolved.auth, ...(accountId ? { accountId } : {}) }, {
292
+ signal, maxBytes: this.options.maxResponseBytes, maxPages: this.options.maxPages, codexClientVersion: this.options.codexClientVersion,
293
+ });
294
+ check();
295
+ const next = { ...state, discovered, syncedAt: new Date().toISOString() };
296
+ const identity = accountIdentity(provider, credential);
297
+ if (identity)
298
+ next.account = identity;
299
+ const expected = this.revision();
300
+ // External edits while a network request was pending must not be overwritten.
301
+ if (JSON.stringify(this.state().providers[provider]) !== JSON.stringify(state))
302
+ failure('settings-conflict', 'Models changed during synchronization. Refresh and retry.');
303
+ await this.save(provider, next, expected);
304
+ check();
305
+ await this.publish(provider, next);
306
+ this.errors.delete(provider);
307
+ }).catch(error => {
308
+ const failure = error instanceof ModelOperationError ? error : error instanceof Error && error.name === 'TimeoutError' ? new ModelOperationError('discovery-timeout', 'Model discovery timed out; the previous list was retained.') : controller.signal.aborted ? new ModelOperationError('cancelled', 'Model synchronization was cancelled.') : new ModelOperationError('discovery-failed', 'Model discovery failed; the previous list was retained.');
309
+ this.report(provider, failure);
310
+ throw failure;
311
+ }).finally(() => {
312
+ if (this.controllers.get(provider) === controller)
313
+ this.controllers.delete(provider);
314
+ this.syncs.delete(provider);
315
+ if (!this.closed)
316
+ this.changed(provider);
317
+ });
318
+ this.syncs.set(provider, task);
319
+ this.changed(provider);
320
+ return task;
321
+ }
322
+ loginStarted(provider) {
323
+ this.loggingIn.add(provider);
324
+ this.generations.set(provider, (this.generations.get(provider) ?? 0) + 1);
325
+ this.controllers.get(provider)?.abort();
326
+ }
327
+ async loginFinished(provider, authorized) {
328
+ if (authorized)
329
+ this.signedOut.delete(provider);
330
+ this.loggingIn.delete(provider);
331
+ await this.ready;
332
+ await this.enqueue(provider, async () => {
333
+ if (authorized) {
334
+ const state = this.state().providers[provider];
335
+ const identity = accountIdentity(provider, fromRecord(await this.ctx.credentials.readRecord(recordKeyFor(provider))));
336
+ if (state && (!identity || identity !== state.account)) {
337
+ const { discovered: _discovered, syncedAt: _time, account: _account, ...rest } = state;
338
+ await this.save(provider, { ...rest, ...(identity ? { account: identity } : {}) });
339
+ }
340
+ await this.ensureManaged(provider, false);
341
+ }
342
+ else
343
+ await this.reconcile(provider);
344
+ });
345
+ if (authorized)
346
+ await this.sync(provider, this.revision());
347
+ }
348
+ async forget(provider) {
349
+ this.signedOut.add(provider);
350
+ this.generations.set(provider, (this.generations.get(provider) ?? 0) + 1);
351
+ this.controllers.get(provider)?.abort();
352
+ await this.ready;
353
+ await this.enqueue(provider, async () => {
354
+ await this.ctx.credentials.deleteRecord(recordKeyFor(provider));
355
+ await this.publish(provider, undefined);
356
+ this.errors.delete(provider);
357
+ });
358
+ }
359
+ async dispose() {
360
+ this.closed = true;
361
+ this.stop.abort();
362
+ for (const dispose of this.disposers)
363
+ dispose();
364
+ for (const controller of this.controllers.values())
365
+ controller.abort();
366
+ await Promise.allSettled([...this.queues.values()]);
367
+ this.handle?.();
368
+ this.profiles = new Map();
369
+ }
370
+ }
371
+ //# sourceMappingURL=model-service.js.map
@@ -0,0 +1,92 @@
1
+ /** Model-management messages shared by the browser and Host. No credentials travel here. */
2
+ import type { ProviderId } from './protocol.js';
3
+ export declare const MODEL_PAGE_SIZE = 10;
4
+ export declare const MODEL_PROTOCOLS: {
5
+ readonly 'github-copilot': readonly ["openai-completions", "openai-responses", "anthropic-messages"];
6
+ readonly openrouter: readonly ["openai-completions", "anthropic-messages"];
7
+ readonly 'openai-codex': readonly ["openai-codex-responses"];
8
+ readonly xai: readonly ["openai-responses"];
9
+ readonly anthropic: readonly ["anthropic-messages"];
10
+ readonly 'kimi-coding': readonly ["anthropic-messages"];
11
+ };
12
+ export type ModelProtocol = (typeof MODEL_PROTOCOLS)[ProviderId][number];
13
+ export interface ManualModel {
14
+ id: string;
15
+ name?: string;
16
+ api?: ModelProtocol;
17
+ }
18
+ export interface DiscoveredModel {
19
+ id: string;
20
+ name?: string;
21
+ }
22
+ export interface ModelRow {
23
+ id: string;
24
+ name: string;
25
+ source: 'catalog' | 'matched' | 'pending' | 'manual';
26
+ api?: string;
27
+ }
28
+ export interface ModelPage {
29
+ provider: ProviderId;
30
+ revision: number;
31
+ offset: number;
32
+ total: number;
33
+ rows: ModelRow[];
34
+ source: 'catalog' | 'account';
35
+ counts: {
36
+ matched: number;
37
+ pending: number;
38
+ manual: number;
39
+ };
40
+ connected: boolean;
41
+ writable: boolean;
42
+ managed: boolean;
43
+ busy: boolean;
44
+ syncedAt?: string;
45
+ error?: string;
46
+ errorCode?: string;
47
+ migration?: {
48
+ fields: string[];
49
+ models: number;
50
+ };
51
+ }
52
+ export type ModelCommand = {
53
+ type: 'models-list';
54
+ requestId: string;
55
+ provider: ProviderId;
56
+ offset: number;
57
+ } | {
58
+ type: 'models-sync' | 'models-migrate';
59
+ requestId: string;
60
+ provider: ProviderId;
61
+ revision: number;
62
+ } | {
63
+ type: 'models-save';
64
+ requestId: string;
65
+ provider: ProviderId;
66
+ revision: number;
67
+ model: ManualModel;
68
+ } | {
69
+ type: 'models-delete';
70
+ requestId: string;
71
+ provider: ProviderId;
72
+ revision: number;
73
+ id: string;
74
+ };
75
+ export type ModelMessage = {
76
+ type: 'models-page';
77
+ requestId: string;
78
+ page: ModelPage;
79
+ } | {
80
+ type: 'models-changed';
81
+ provider: ProviderId;
82
+ } | {
83
+ type: 'models-error';
84
+ requestId: string;
85
+ provider: ProviderId;
86
+ code: string;
87
+ message: string;
88
+ };
89
+ export declare function modelText(value: unknown): value is string;
90
+ /** Validate at the wire and durable-data boundaries; fixed-protocol routes accept no override. */
91
+ export declare function validManual(provider: ProviderId, value: unknown): value is ManualModel;
92
+ //# sourceMappingURL=model-types.d.ts.map