@clinebot/core 0.0.0 → 0.0.3

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.
Files changed (59) hide show
  1. package/README.md +7 -7
  2. package/dist/default-tools/definitions.d.ts +1 -1
  3. package/dist/default-tools/executors/index.d.ts +1 -1
  4. package/dist/default-tools/index.d.ts +2 -1
  5. package/dist/default-tools/model-tool-routing.d.ts +33 -0
  6. package/dist/default-tools/schemas.d.ts +13 -7
  7. package/dist/index.browser.d.ts +1 -0
  8. package/dist/index.browser.js +220 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +47 -47
  11. package/dist/index.node.d.ts +36 -0
  12. package/dist/index.node.js +622 -0
  13. package/dist/providers/local-provider-service.d.ts +37 -0
  14. package/dist/session/default-session-manager.d.ts +3 -1
  15. package/dist/session/session-host.d.ts +2 -2
  16. package/dist/session/session-manager.d.ts +8 -0
  17. package/dist/session/unified-session-persistence-service.d.ts +1 -1
  18. package/dist/session/utils/helpers.d.ts +11 -0
  19. package/dist/session/utils/types.d.ts +42 -0
  20. package/dist/session/utils/usage.d.ts +9 -0
  21. package/dist/storage/provider-settings-manager.d.ts +2 -0
  22. package/dist/types/config.d.ts +8 -1
  23. package/dist/types.d.ts +1 -1
  24. package/package.json +11 -32
  25. package/src/default-tools/definitions.test.ts +130 -1
  26. package/src/default-tools/definitions.ts +7 -3
  27. package/src/default-tools/executors/editor.ts +10 -9
  28. package/src/default-tools/executors/file-read.test.ts +1 -1
  29. package/src/default-tools/executors/file-read.ts +11 -6
  30. package/src/default-tools/executors/index.ts +1 -1
  31. package/src/default-tools/index.ts +6 -1
  32. package/src/default-tools/model-tool-routing.test.ts +86 -0
  33. package/src/default-tools/model-tool-routing.ts +132 -0
  34. package/src/default-tools/schemas.ts +49 -52
  35. package/src/index.browser.ts +1 -0
  36. package/src/{server/index.ts → index.node.ts} +51 -109
  37. package/src/index.ts +41 -2
  38. package/src/input/file-indexer.ts +28 -2
  39. package/src/providers/local-provider-service.ts +591 -0
  40. package/src/runtime/runtime-builder.test.ts +69 -0
  41. package/src/runtime/runtime-builder.ts +20 -0
  42. package/src/runtime/runtime-parity.test.ts +20 -9
  43. package/src/session/default-session-manager.e2e.test.ts +11 -1
  44. package/src/session/default-session-manager.test.ts +270 -0
  45. package/src/session/default-session-manager.ts +109 -191
  46. package/src/session/index.ts +7 -2
  47. package/src/session/session-host.ts +30 -18
  48. package/src/session/session-manager.ts +11 -0
  49. package/src/session/unified-session-persistence-service.ts +11 -5
  50. package/src/session/utils/helpers.ts +148 -0
  51. package/src/session/utils/types.ts +46 -0
  52. package/src/session/utils/usage.ts +32 -0
  53. package/src/storage/provider-settings-legacy-migration.test.ts +3 -3
  54. package/src/storage/provider-settings-manager.test.ts +34 -0
  55. package/src/storage/provider-settings-manager.ts +22 -1
  56. package/src/types/config.ts +13 -0
  57. package/src/types.ts +1 -0
  58. package/dist/server/index.d.ts +0 -47
  59. package/dist/server/index.js +0 -641
package/src/index.ts CHANGED
@@ -4,6 +4,47 @@
4
4
  * Runtime-agnostic core contracts and shared state utilities.
5
5
  */
6
6
 
7
+ // Shared contracts and path helpers re-exported for app consumers.
8
+ export type {
9
+ AgentMode,
10
+ BasicLogger,
11
+ ConnectorHookEvent,
12
+ HookSessionContext,
13
+ RpcAddProviderActionRequest,
14
+ RpcChatMessage,
15
+ RpcChatRunTurnRequest,
16
+ RpcChatRuntimeConfigBase,
17
+ RpcChatRuntimeLoggerConfig,
18
+ RpcChatStartSessionArtifacts,
19
+ RpcChatStartSessionRequest,
20
+ RpcChatTurnResult,
21
+ RpcClineAccountActionRequest,
22
+ RpcOAuthProviderId,
23
+ RpcProviderActionRequest,
24
+ RpcProviderCapability,
25
+ RpcProviderCatalogResponse,
26
+ RpcProviderListItem,
27
+ RpcProviderModel,
28
+ RpcProviderOAuthLoginResponse,
29
+ RpcSaveProviderSettingsActionRequest,
30
+ SessionLineage,
31
+ TeamProgressProjectionEvent,
32
+ ToolPolicy,
33
+ } from "@clinebot/shared";
34
+ export {
35
+ normalizeUserInput,
36
+ RPC_TEAM_LIFECYCLE_EVENT_TYPE,
37
+ RPC_TEAM_PROGRESS_EVENT_TYPE,
38
+ resolveHookLogPath,
39
+ } from "@clinebot/shared";
40
+ export {
41
+ ensureHookLogDir,
42
+ ensureParentDir,
43
+ resolveClineDataDir,
44
+ resolveSessionDataDir,
45
+ setHomeDir,
46
+ setHomeDirIfUnset,
47
+ } from "@clinebot/shared/storage";
7
48
  export {
8
49
  type ClineAccountBalance,
9
50
  type ClineAccountOperations,
@@ -76,7 +117,6 @@ export {
76
117
  resolveDefaultMcpSettingsPath,
77
118
  resolveMcpServerRegistrations,
78
119
  } from "./mcp";
79
-
80
120
  export { ProviderSettingsManager } from "./storage/provider-settings-manager";
81
121
  export {
82
122
  SqliteTeamStore,
@@ -86,7 +126,6 @@ export {
86
126
  buildTeamProgressSummary,
87
127
  toTeamProgressLifecycleEvent,
88
128
  } from "./team";
89
-
90
129
  // Compatibility barrel (legacy imports).
91
130
  export type { RuntimeEnvironment, SessionEvent, StoredMessages } from "./types";
92
131
  export type { SessionStatus } from "./types/common";
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { isMainThread, parentPort, Worker } from "node:worker_threads";
5
5
 
6
6
  const DEFAULT_INDEX_TTL_MS = 15_000;
7
+ const WORKER_INDEX_REQUEST_TIMEOUT_MS = 1_000;
7
8
  const DEFAULT_EXCLUDE_DIRS = new Set([
8
9
  ".git",
9
10
  "node_modules",
@@ -196,7 +197,21 @@ class FileIndexWorkerClient {
196
197
  requestIndex(cwd: string): Promise<string[]> {
197
198
  const requestId = ++this.nextRequestId;
198
199
  const result = new Promise<string[]>((resolve, reject) => {
199
- this.pending.set(requestId, { resolve, reject });
200
+ const timeout = setTimeout(() => {
201
+ this.pending.delete(requestId);
202
+ reject(new Error("Timed out waiting for file index worker response"));
203
+ }, WORKER_INDEX_REQUEST_TIMEOUT_MS);
204
+ timeout.unref();
205
+ this.pending.set(requestId, {
206
+ resolve: (files) => {
207
+ clearTimeout(timeout);
208
+ resolve(files);
209
+ },
210
+ reject: (reason) => {
211
+ clearTimeout(timeout);
212
+ reject(reason);
213
+ },
214
+ });
200
215
  });
201
216
 
202
217
  const message: IndexRequestMessage = {
@@ -218,9 +233,20 @@ class FileIndexWorkerClient {
218
233
 
219
234
  startWorkerServer();
220
235
 
221
- const workerClient = isMainThread ? new FileIndexWorkerClient() : null;
236
+ let workerClient: FileIndexWorkerClient | null | undefined;
237
+
238
+ function getWorkerClient(): FileIndexWorkerClient | null {
239
+ if (!isMainThread) {
240
+ return null;
241
+ }
242
+ if (workerClient === undefined) {
243
+ workerClient = new FileIndexWorkerClient();
244
+ }
245
+ return workerClient;
246
+ }
222
247
 
223
248
  async function buildIndexInBackground(cwd: string): Promise<Set<string>> {
249
+ const workerClient = getWorkerClient();
224
250
  if (!workerClient) {
225
251
  return buildIndex(cwd);
226
252
  }
@@ -0,0 +1,591 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { providers as LlmsProviders } from "@clinebot/llms";
4
+ import { models } from "@clinebot/llms";
5
+ import type {
6
+ RpcAddProviderActionRequest,
7
+ RpcOAuthProviderId,
8
+ RpcProviderCapability,
9
+ RpcProviderListItem,
10
+ RpcProviderModel,
11
+ RpcSaveProviderSettingsActionRequest,
12
+ } from "@clinebot/shared";
13
+ import { createOAuthClientCallbacks } from "../auth/client";
14
+ import { loginClineOAuth } from "../auth/cline";
15
+ import { loginOpenAICodex } from "../auth/codex";
16
+ import { loginOcaOAuth } from "../auth/oca";
17
+ import type { ProviderSettingsManager } from "../storage/provider-settings-manager";
18
+
19
+ type StoredModelsFile = {
20
+ version: 1;
21
+ providers: Record<
22
+ string,
23
+ {
24
+ provider: {
25
+ name: string;
26
+ baseUrl: string;
27
+ defaultModelId?: string;
28
+ capabilities?: RpcProviderCapability[];
29
+ modelsSourceUrl?: string;
30
+ };
31
+ models: Record<
32
+ string,
33
+ {
34
+ id: string;
35
+ name: string;
36
+ supportsVision?: boolean;
37
+ supportsAttachments?: boolean;
38
+ }
39
+ >;
40
+ }
41
+ >;
42
+ };
43
+
44
+ function resolveVisibleApiKey(settings: {
45
+ apiKey?: string;
46
+ auth?: {
47
+ apiKey?: string;
48
+ };
49
+ }): string | undefined {
50
+ return settings.apiKey ?? settings.auth?.apiKey;
51
+ }
52
+
53
+ function hasOAuthAccessToken(settings: {
54
+ auth?: {
55
+ accessToken?: string;
56
+ };
57
+ }): boolean {
58
+ return (settings.auth?.accessToken?.trim() ?? "").length > 0;
59
+ }
60
+
61
+ function titleCaseFromId(id: string): string {
62
+ return id
63
+ .split(/[-_]/)
64
+ .filter(Boolean)
65
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
66
+ .join(" ");
67
+ }
68
+
69
+ function createLetter(name: string): string {
70
+ const parts = name
71
+ .split(/\s+/)
72
+ .map((part) => part.trim())
73
+ .filter(Boolean);
74
+ if (parts.length === 0) {
75
+ return "?";
76
+ }
77
+ if (parts.length === 1) {
78
+ return parts[0].slice(0, 2).toUpperCase();
79
+ }
80
+ return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
81
+ }
82
+
83
+ function stableColor(id: string): string {
84
+ const palette = [
85
+ "#c4956a",
86
+ "#6b8aad",
87
+ "#e8963a",
88
+ "#5b9bd5",
89
+ "#6bbd7b",
90
+ "#9b7dd4",
91
+ "#d07f68",
92
+ "#57a6a1",
93
+ ];
94
+ let hash = 0;
95
+ for (const ch of id) {
96
+ hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
97
+ }
98
+ return palette[hash % palette.length];
99
+ }
100
+
101
+ function resolveModelsRegistryPath(manager: ProviderSettingsManager): string {
102
+ return join(dirname(manager.getFilePath()), "models.json");
103
+ }
104
+
105
+ function emptyModelsFile(): StoredModelsFile {
106
+ return { version: 1, providers: {} };
107
+ }
108
+
109
+ async function readModelsFile(filePath: string): Promise<StoredModelsFile> {
110
+ try {
111
+ const raw = await readFile(filePath, "utf8");
112
+ const parsed = JSON.parse(raw) as Partial<StoredModelsFile>;
113
+ if (
114
+ parsed &&
115
+ parsed.version === 1 &&
116
+ parsed.providers &&
117
+ typeof parsed.providers === "object"
118
+ ) {
119
+ return { version: 1, providers: parsed.providers };
120
+ }
121
+ } catch {
122
+ // Invalid or missing files fall back to an empty registry.
123
+ }
124
+ return emptyModelsFile();
125
+ }
126
+
127
+ async function writeModelsFile(
128
+ filePath: string,
129
+ state: StoredModelsFile,
130
+ ): Promise<void> {
131
+ await mkdir(dirname(filePath), { recursive: true });
132
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
133
+ }
134
+
135
+ function toProviderCapabilities(
136
+ capabilities: RpcProviderCapability[] | undefined,
137
+ ): Array<"reasoning" | "prompt-cache" | "tools"> | undefined {
138
+ if (!capabilities || capabilities.length === 0) {
139
+ return undefined;
140
+ }
141
+ const next = new Set<"reasoning" | "prompt-cache" | "tools">();
142
+ if (capabilities.includes("reasoning")) {
143
+ next.add("reasoning");
144
+ }
145
+ if (capabilities.includes("prompt-cache")) {
146
+ next.add("prompt-cache");
147
+ }
148
+ if (capabilities.includes("tools")) {
149
+ next.add("tools");
150
+ }
151
+ return next.size > 0 ? [...next] : undefined;
152
+ }
153
+
154
+ function toModelCapabilities(
155
+ capabilities: RpcProviderCapability[] | undefined,
156
+ ): Array<
157
+ "streaming" | "tools" | "reasoning" | "prompt-cache" | "images" | "files"
158
+ > {
159
+ const next = new Set<
160
+ "streaming" | "tools" | "reasoning" | "prompt-cache" | "images" | "files"
161
+ >();
162
+ if (!capabilities || capabilities.length === 0) {
163
+ return [...next];
164
+ }
165
+ if (capabilities.includes("streaming")) next.add("streaming");
166
+ if (capabilities.includes("tools")) next.add("tools");
167
+ if (capabilities.includes("reasoning")) next.add("reasoning");
168
+ if (capabilities.includes("prompt-cache")) next.add("prompt-cache");
169
+ if (capabilities.includes("vision")) {
170
+ next.add("images");
171
+ next.add("files");
172
+ }
173
+ return [...next];
174
+ }
175
+
176
+ function registerCustomProvider(
177
+ providerId: string,
178
+ entry: StoredModelsFile["providers"][string],
179
+ ): void {
180
+ const modelCapabilities = toModelCapabilities(entry.provider.capabilities);
181
+ const modelEntries = Object.values(entry.models)
182
+ .map((model) => model.id.trim())
183
+ .filter((modelId) => modelId.length > 0);
184
+ const defaultModelId =
185
+ entry.provider.defaultModelId?.trim() || modelEntries[0] || "default";
186
+ const normalizedModels = Object.fromEntries(
187
+ modelEntries.map((modelId) => [
188
+ modelId,
189
+ {
190
+ id: modelId,
191
+ name: entry.models[modelId]?.name ?? modelId,
192
+ capabilities:
193
+ modelCapabilities.length > 0 ? modelCapabilities : undefined,
194
+ status: "active" as const,
195
+ },
196
+ ]),
197
+ );
198
+
199
+ models.registerProvider({
200
+ provider: {
201
+ id: providerId,
202
+ name: entry.provider.name.trim() || titleCaseFromId(providerId),
203
+ protocol: "openai-chat",
204
+ baseUrl: entry.provider.baseUrl,
205
+ defaultModelId,
206
+ capabilities: toProviderCapabilities(entry.provider.capabilities),
207
+ },
208
+ models: normalizedModels,
209
+ });
210
+ }
211
+
212
+ let customProvidersLoaded = false;
213
+
214
+ export async function ensureCustomProvidersLoaded(
215
+ manager: ProviderSettingsManager,
216
+ ): Promise<void> {
217
+ if (customProvidersLoaded) {
218
+ return;
219
+ }
220
+ const modelsPath = resolveModelsRegistryPath(manager);
221
+ const state = await readModelsFile(modelsPath);
222
+ for (const [providerId, entry] of Object.entries(state.providers)) {
223
+ registerCustomProvider(providerId, entry);
224
+ }
225
+ customProvidersLoaded = true;
226
+ }
227
+
228
+ function parseModelIdList(input: unknown): string[] {
229
+ if (Array.isArray(input)) {
230
+ return input
231
+ .map((item) => {
232
+ if (typeof item === "string") return item.trim();
233
+ if (item && typeof item === "object" && "id" in item) {
234
+ const id = (item as { id?: unknown }).id;
235
+ return typeof id === "string" ? id.trim() : "";
236
+ }
237
+ return "";
238
+ })
239
+ .filter((id) => id.length > 0);
240
+ }
241
+ return [];
242
+ }
243
+
244
+ function extractModelIdsFromPayload(
245
+ payload: unknown,
246
+ providerId: string,
247
+ ): string[] {
248
+ const rootArray = parseModelIdList(payload);
249
+ if (rootArray.length > 0) return rootArray;
250
+ if (!payload || typeof payload !== "object") return [];
251
+ const data = payload as {
252
+ data?: unknown;
253
+ models?: unknown;
254
+ providers?: Record<string, unknown>;
255
+ };
256
+ const direct = parseModelIdList(data.data ?? data.models);
257
+ if (direct.length > 0) return direct;
258
+ if (
259
+ data.models &&
260
+ typeof data.models === "object" &&
261
+ !Array.isArray(data.models)
262
+ ) {
263
+ const modelKeys = Object.keys(data.models).filter(
264
+ (key) => key.trim().length > 0,
265
+ );
266
+ if (modelKeys.length > 0) return modelKeys;
267
+ }
268
+ const providerScoped = data.providers?.[providerId];
269
+ if (providerScoped && typeof providerScoped === "object") {
270
+ const nested = providerScoped as { models?: unknown };
271
+ const nestedList = parseModelIdList(nested.models ?? providerScoped);
272
+ if (nestedList.length > 0) return nestedList;
273
+ }
274
+ return [];
275
+ }
276
+
277
+ async function fetchModelIdsFromSource(
278
+ url: string,
279
+ providerId: string,
280
+ ): Promise<string[]> {
281
+ const response = await fetch(url, { method: "GET" });
282
+ if (!response.ok) {
283
+ throw new Error(
284
+ `failed to fetch models from ${url}: HTTP ${response.status}`,
285
+ );
286
+ }
287
+ const payload = (await response.json()) as unknown;
288
+ return extractModelIdsFromPayload(payload, providerId);
289
+ }
290
+
291
+ export async function addLocalProvider(
292
+ manager: ProviderSettingsManager,
293
+ request: RpcAddProviderActionRequest,
294
+ ): Promise<{
295
+ providerId: string;
296
+ settingsPath: string;
297
+ modelsPath: string;
298
+ modelsCount: number;
299
+ }> {
300
+ const providerId = request.providerId.trim().toLowerCase();
301
+ if (!providerId) throw new Error("providerId is required");
302
+ if (models.hasProvider(providerId)) {
303
+ throw new Error(`provider "${providerId}" already exists`);
304
+ }
305
+ const providerName = request.name.trim();
306
+ if (!providerName) throw new Error("name is required");
307
+ const baseUrl = request.baseUrl.trim();
308
+ if (!baseUrl) throw new Error("baseUrl is required");
309
+
310
+ const typedModels = (request.models ?? [])
311
+ .map((model) => model.trim())
312
+ .filter((model) => model.length > 0);
313
+ const sourceUrl = request.modelsSourceUrl?.trim();
314
+ const fetchedModels = sourceUrl
315
+ ? await fetchModelIdsFromSource(sourceUrl, providerId)
316
+ : [];
317
+ const modelIds = [...new Set([...typedModels, ...fetchedModels])];
318
+ if (modelIds.length === 0) {
319
+ throw new Error(
320
+ "at least one model is required (manual or via modelsSourceUrl)",
321
+ );
322
+ }
323
+
324
+ const defaultModelId =
325
+ request.defaultModelId?.trim() &&
326
+ modelIds.includes(request.defaultModelId.trim())
327
+ ? request.defaultModelId.trim()
328
+ : modelIds[0];
329
+ const capabilities = request.capabilities?.length
330
+ ? [...new Set(request.capabilities)]
331
+ : undefined;
332
+ const headerEntries = Object.entries(request.headers ?? {}).filter(
333
+ ([key]) => key.trim().length > 0,
334
+ );
335
+
336
+ manager.saveProviderSettings(
337
+ {
338
+ provider: providerId,
339
+ apiKey: request.apiKey?.trim() ? request.apiKey : undefined,
340
+ baseUrl,
341
+ headers:
342
+ headerEntries.length > 0
343
+ ? Object.fromEntries(headerEntries)
344
+ : undefined,
345
+ timeout: request.timeoutMs,
346
+ model: defaultModelId,
347
+ },
348
+ { setLastUsed: false },
349
+ );
350
+
351
+ const modelsPath = resolveModelsRegistryPath(manager);
352
+ const modelsState = await readModelsFile(modelsPath);
353
+ const supportsVision = capabilities?.includes("vision") ?? false;
354
+ const supportsAttachments = supportsVision;
355
+ modelsState.providers[providerId] = {
356
+ provider: {
357
+ name: providerName,
358
+ baseUrl,
359
+ defaultModelId,
360
+ capabilities,
361
+ modelsSourceUrl: sourceUrl,
362
+ },
363
+ models: Object.fromEntries(
364
+ modelIds.map((modelId) => [
365
+ modelId,
366
+ {
367
+ id: modelId,
368
+ name: modelId,
369
+ supportsVision,
370
+ supportsAttachments,
371
+ },
372
+ ]),
373
+ ),
374
+ };
375
+ await writeModelsFile(modelsPath, modelsState);
376
+ registerCustomProvider(providerId, modelsState.providers[providerId]);
377
+
378
+ return {
379
+ providerId,
380
+ settingsPath: manager.getFilePath(),
381
+ modelsPath,
382
+ modelsCount: modelIds.length,
383
+ };
384
+ }
385
+
386
+ export async function listLocalProviders(
387
+ manager: ProviderSettingsManager,
388
+ ): Promise<{
389
+ providers: RpcProviderListItem[];
390
+ settingsPath: string;
391
+ }> {
392
+ const state = manager.read();
393
+ const ids = models.getProviderIds().sort((a, b) => a.localeCompare(b));
394
+ const providerItems = await Promise.all(
395
+ ids.map(async (id): Promise<RpcProviderListItem> => {
396
+ const info = await models.getProvider(id);
397
+ const persistedSettings = state.providers[id]?.settings;
398
+ const providerName = info?.name ?? titleCaseFromId(id);
399
+ return {
400
+ id,
401
+ name: providerName,
402
+ models: null,
403
+ color: stableColor(id),
404
+ letter: createLetter(providerName),
405
+ enabled: Boolean(persistedSettings),
406
+ apiKey: persistedSettings
407
+ ? resolveVisibleApiKey(persistedSettings)
408
+ : undefined,
409
+ oauthAccessTokenPresent: persistedSettings
410
+ ? hasOAuthAccessToken(persistedSettings)
411
+ : undefined,
412
+ baseUrl: persistedSettings?.baseUrl ?? info?.baseUrl,
413
+ defaultModelId: info?.defaultModelId,
414
+ authDescription: "This provider uses API keys for authentication.",
415
+ baseUrlDescription: "The base endpoint to use for provider requests.",
416
+ };
417
+ }),
418
+ );
419
+
420
+ return {
421
+ providers: providerItems,
422
+ settingsPath: manager.getFilePath(),
423
+ };
424
+ }
425
+
426
+ export async function getLocalProviderModels(
427
+ providerId: string,
428
+ ): Promise<{ providerId: string; models: RpcProviderModel[] }> {
429
+ const id = providerId.trim();
430
+ const modelMap = await models.getModelsForProvider(id);
431
+ const items = Object.entries(modelMap)
432
+ .sort(([a], [b]) => a.localeCompare(b))
433
+ .map(([modelId, info]) => ({
434
+ id: modelId,
435
+ name: info.name ?? modelId,
436
+ supportsAttachments: info.capabilities?.includes("files"),
437
+ supportsVision: info.capabilities?.includes("images"),
438
+ }));
439
+ return {
440
+ providerId: id,
441
+ models: items,
442
+ };
443
+ }
444
+
445
+ export function saveLocalProviderSettings(
446
+ manager: ProviderSettingsManager,
447
+ request: RpcSaveProviderSettingsActionRequest,
448
+ ): { providerId: string; enabled: boolean; settingsPath: string } {
449
+ const providerId = request.providerId.trim();
450
+ const state = manager.read();
451
+
452
+ if (request.enabled === false) {
453
+ delete state.providers[providerId];
454
+ if (state.lastUsedProvider === providerId) {
455
+ delete state.lastUsedProvider;
456
+ }
457
+ manager.write(state);
458
+ return {
459
+ providerId,
460
+ enabled: false,
461
+ settingsPath: manager.getFilePath(),
462
+ };
463
+ }
464
+
465
+ const existing = manager.getProviderSettings(providerId);
466
+ const nextSettings: Record<string, unknown> = {
467
+ ...(existing ?? {}),
468
+ provider: providerId,
469
+ };
470
+
471
+ const hasApiKeyUpdate =
472
+ Object.hasOwn(request, "apiKey") && typeof request.apiKey === "string";
473
+ if (hasApiKeyUpdate) {
474
+ const apiKey = request.apiKey?.trim() ?? "";
475
+ if (apiKey.length === 0) {
476
+ delete nextSettings.apiKey;
477
+ } else {
478
+ nextSettings.apiKey = request.apiKey;
479
+ }
480
+ }
481
+
482
+ const hasBaseUrlUpdate =
483
+ Object.hasOwn(request, "baseUrl") && typeof request.baseUrl === "string";
484
+ if (hasBaseUrlUpdate) {
485
+ const baseUrl = request.baseUrl?.trim() ?? "";
486
+ if (baseUrl.length === 0) {
487
+ delete nextSettings.baseUrl;
488
+ } else {
489
+ nextSettings.baseUrl = request.baseUrl;
490
+ }
491
+ }
492
+
493
+ manager.saveProviderSettings(nextSettings, { setLastUsed: false });
494
+ return {
495
+ providerId,
496
+ enabled: true,
497
+ settingsPath: manager.getFilePath(),
498
+ };
499
+ }
500
+
501
+ export function normalizeOAuthProvider(provider: string): RpcOAuthProviderId {
502
+ const normalized = provider.trim().toLowerCase();
503
+ if (normalized === "codex" || normalized === "openai-codex") {
504
+ return "openai-codex";
505
+ }
506
+ if (normalized === "cline" || normalized === "oca") {
507
+ return normalized;
508
+ }
509
+ throw new Error(
510
+ `provider "${provider}" does not support OAuth login (supported: cline, oca, openai-codex)`,
511
+ );
512
+ }
513
+
514
+ function toProviderApiKey(
515
+ providerId: RpcOAuthProviderId,
516
+ credentials: { access: string },
517
+ ): string {
518
+ if (providerId === "cline") {
519
+ return `workos:${credentials.access}`;
520
+ }
521
+ return credentials.access;
522
+ }
523
+
524
+ export async function loginLocalProvider(
525
+ providerId: RpcOAuthProviderId,
526
+ existing: LlmsProviders.ProviderSettings | undefined,
527
+ openUrl: (url: string) => void,
528
+ ): Promise<{
529
+ access: string;
530
+ refresh: string;
531
+ expires: number;
532
+ accountId?: string;
533
+ }> {
534
+ const callbacks = createOAuthClientCallbacks({
535
+ onPrompt: async (prompt) => prompt.defaultValue ?? "",
536
+ openUrl,
537
+ onOpenUrlError: ({ error }) => {
538
+ throw error instanceof Error ? error : new Error(String(error));
539
+ },
540
+ });
541
+
542
+ if (providerId === "cline") {
543
+ return loginClineOAuth({
544
+ apiBaseUrl: existing?.baseUrl?.trim() || "https://api.cline.bot",
545
+ callbacks,
546
+ });
547
+ }
548
+ if (providerId === "oca") {
549
+ return loginOcaOAuth({
550
+ mode: existing?.oca?.mode,
551
+ callbacks,
552
+ });
553
+ }
554
+ return loginOpenAICodex(callbacks);
555
+ }
556
+
557
+ export function saveLocalProviderOAuthCredentials(
558
+ manager: ProviderSettingsManager,
559
+ providerId: RpcOAuthProviderId,
560
+ existing: LlmsProviders.ProviderSettings | undefined,
561
+ credentials: {
562
+ access: string;
563
+ refresh: string;
564
+ expires: number;
565
+ accountId?: string;
566
+ },
567
+ ): LlmsProviders.ProviderSettings {
568
+ const auth = {
569
+ ...(existing?.auth ?? {}),
570
+ accessToken: toProviderApiKey(providerId, credentials),
571
+ refreshToken: credentials.refresh,
572
+ accountId: credentials.accountId,
573
+ } as LlmsProviders.ProviderSettings["auth"] & { expiresAt?: number };
574
+ auth.expiresAt = credentials.expires;
575
+ const merged: LlmsProviders.ProviderSettings = {
576
+ ...(existing ?? {
577
+ provider: providerId as LlmsProviders.ProviderSettings["provider"],
578
+ }),
579
+ provider: providerId as LlmsProviders.ProviderSettings["provider"],
580
+ auth,
581
+ };
582
+ manager.saveProviderSettings(merged, { tokenSource: "oauth" });
583
+ return merged;
584
+ }
585
+
586
+ export function resolveLocalClineAuthToken(
587
+ settings: LlmsProviders.ProviderSettings | undefined,
588
+ ): string | undefined {
589
+ const token = settings?.auth?.accessToken?.trim() || settings?.apiKey?.trim();
590
+ return token && token.length > 0 ? token : undefined;
591
+ }