kanna-code 0.31.0 → 0.32.1

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.
@@ -30,8 +30,8 @@
30
30
  })()
31
31
  </script>
32
32
  <title>Kanna</title>
33
- <script type="module" crossorigin src="/assets/index-D_R1gYah.js"></script>
34
- <link rel="stylesheet" crossorigin href="/assets/index-KvdfpGdt.css">
33
+ <script type="module" crossorigin src="/assets/index-EVYrrkP5.js"></script>
34
+ <link rel="stylesheet" crossorigin href="/assets/index-B3EUEMfY.css">
35
35
  </head>
36
36
  <body>
37
37
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.31.0",
4
+ "version": "0.32.1",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -57,6 +57,7 @@
57
57
  "cloudflared": "^0.7.1",
58
58
  "default-shell": "^2.2.0",
59
59
  "file-type": "^22.0.0",
60
+ "openai": "^6.34.0",
60
61
  "react-resizable-panels": "^4.7.3",
61
62
  "uqr": "^0.1.3"
62
63
  },
@@ -176,6 +176,41 @@ describe("DiffStore", () => {
176
176
  expect((await run(["git", "log", "-1", "--pretty=%s"], repoRoot)).trim()).toBe("Local only")
177
177
  })
178
178
 
179
+ test("commits tracked files inside newly ignored directories", async () => {
180
+ const repoRoot = await createRepo()
181
+ tempDirs.push(repoRoot)
182
+ await mkdir(path.join(repoRoot, "build", ".wrangler"), { recursive: true })
183
+ await writeFile(path.join(repoRoot, "build", ".wrangler", "state.sqlite"), "base\n", "utf8")
184
+ await run(["git", "add", "."], repoRoot)
185
+ await run(["git", "commit", "-m", "init"], repoRoot)
186
+
187
+ await writeFile(path.join(repoRoot, "build", ".gitignore"), ".wrangler/\n", "utf8")
188
+ await writeFile(path.join(repoRoot, "build", ".wrangler", "state.sqlite"), "changed\n", "utf8")
189
+
190
+ const store = new DiffStore(repoRoot)
191
+ await store.initialize()
192
+ await store.refreshSnapshot("project-1", repoRoot)
193
+
194
+ const result = await store.commitFiles({
195
+ projectId: "project-1",
196
+ projectPath: repoRoot,
197
+ paths: ["build/.wrangler/state.sqlite"],
198
+ summary: "Commit tracked ignored file",
199
+ mode: "commit_only",
200
+ })
201
+
202
+ expect(result).toMatchObject({
203
+ ok: true,
204
+ mode: "commit_only",
205
+ pushed: false,
206
+ })
207
+ expect((await run(["git", "log", "-1", "--pretty=%s"], repoRoot)).trim()).toBe("Commit tracked ignored file")
208
+
209
+ const snapshot = store.getProjectSnapshot("project-1")
210
+ expect(snapshot.files).toHaveLength(1)
211
+ expect(snapshot.files[0]?.path).toBe("build/.gitignore")
212
+ })
213
+
179
214
  test("refreshSnapshot reports origin presence before the first commit", async () => {
180
215
  const repoRoot = await createRepo()
181
216
  tempDirs.push(repoRoot)
@@ -179,12 +179,20 @@ function summarizeGitFailure(detail: string, fallback: string) {
179
179
  }
180
180
 
181
181
  function createCommitFailure(mode: DiffCommitMode, detail: string): DiffCommitResult {
182
- const message = summarizeGitFailure(detail, "Git could not create the commit.")
182
+ const normalized = detail.toLowerCase()
183
+ let title = "Commit failed"
184
+ let message = summarizeGitFailure(detail, "Git could not create the commit.")
185
+
186
+ if (normalized.includes("ignored by one of your .gitignore files")) {
187
+ title = "Ignored files cannot be staged"
188
+ message = "One or more selected paths are ignored by .gitignore. Unignore them or remove them from the commit selection."
189
+ }
190
+
183
191
  return {
184
192
  ok: false,
185
193
  mode,
186
194
  phase: "commit",
187
- title: "Commit failed",
195
+ title,
188
196
  message,
189
197
  detail,
190
198
  }
@@ -2017,15 +2025,27 @@ export class DiffStore {
2017
2025
  ])
2018
2026
  const hasOriginRemote = originRemoteUrl !== null
2019
2027
 
2020
- const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
2021
- const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
2028
+ const currentDirtyEntries = await listDirtyPaths(repo.repoRoot)
2029
+ const currentDirtyPathsByPath = new Map(currentDirtyEntries.map((entry) => [entry.path, entry]))
2030
+ const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.has(relativePath))
2022
2031
  if (missingPaths.length > 0) {
2023
2032
  throw new Error(`File is no longer changed: ${missingPaths[0]}`)
2024
2033
  }
2025
2034
 
2026
- const addResult = await runGit(["add", "--", ...normalizedPaths], repo.repoRoot)
2027
- if (addResult.exitCode !== 0) {
2028
- throw new Error(addResult.stderr.trim() || "Failed to stage selected files")
2035
+ const trackedPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2036
+ if (trackedPaths.length > 0) {
2037
+ const addTrackedResult = await runGit(["add", "-u", "--", ...trackedPaths], repo.repoRoot)
2038
+ if (addTrackedResult.exitCode !== 0) {
2039
+ return createCommitFailure(args.mode, formatGitFailure(addTrackedResult))
2040
+ }
2041
+ }
2042
+
2043
+ const untrackedPaths = normalizedPaths.filter((relativePath) => currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2044
+ if (untrackedPaths.length > 0) {
2045
+ const addUntrackedResult = await runGit(["add", "--", ...untrackedPaths], repo.repoRoot)
2046
+ if (addUntrackedResult.exitCode !== 0) {
2047
+ return createCommitFailure(args.mode, formatGitFailure(addUntrackedResult))
2048
+ }
2029
2049
  }
2030
2050
 
2031
2051
  const commitArgs = ["commit", "--only", "-m", summary]
@@ -345,6 +345,24 @@ describe("EventStore", () => {
345
345
  expect(store.getChat("chat-1")?.unread).toBe(false)
346
346
  })
347
347
 
348
+ test("persists sidebar project order across restart and compaction", async () => {
349
+ const dataDir = await createTempDataDir()
350
+ const store = new EventStore(dataDir)
351
+ await store.initialize()
352
+
353
+ const first = await store.openProject("/tmp/project-a")
354
+ const second = await store.openProject("/tmp/project-b")
355
+
356
+ await store.setSidebarProjectOrder([second.id, first.id])
357
+ expect(store.state.sidebarProjectOrder).toEqual([second.id, first.id])
358
+
359
+ await store.compact()
360
+
361
+ const reloaded = new EventStore(dataDir)
362
+ await reloaded.initialize()
363
+ expect(reloaded.state.sidebarProjectOrder).toEqual([second.id, first.id])
364
+ })
365
+
348
366
  test("prunes stale empty chats after thirty minutes", async () => {
349
367
  const dataDir = await createTempDataDir()
350
368
  const store = new EventStore(dataDir)
@@ -59,6 +59,7 @@ function getReplayEventPriority(event: StoreEvent) {
59
59
  switch (event.type) {
60
60
  case "project_opened":
61
61
  case "project_removed":
62
+ case "sidebar_project_order_set":
62
63
  return 0
63
64
  case "chat_created":
64
65
  return 1
@@ -198,6 +199,7 @@ export class EventStore {
198
199
  unread: chat.unread ?? false,
199
200
  })
200
201
  }
202
+ this.state.sidebarProjectOrder = [...(parsed.sidebarProjectOrder ?? [])]
201
203
  if (parsed.queuedMessages?.length) {
202
204
  for (const queuedSet of parsed.queuedMessages) {
203
205
  this.state.queuedMessagesByChatId.set(queuedSet.chatId, queuedSet.entries.map((entry) => ({
@@ -223,6 +225,7 @@ export class EventStore {
223
225
  this.state.projectIdsByPath.clear()
224
226
  this.state.chatsById.clear()
225
227
  this.state.queuedMessagesByChatId.clear()
228
+ this.state.sidebarProjectOrder = []
226
229
  this.cachedTranscript = null
227
230
  }
228
231
 
@@ -322,6 +325,10 @@ export class EventStore {
322
325
  this.state.projectIdsByPath.delete(project.localPath)
323
326
  break
324
327
  }
328
+ case "sidebar_project_order_set": {
329
+ this.state.sidebarProjectOrder = [...event.projectIds]
330
+ break
331
+ }
325
332
  case "chat_created": {
326
333
  const chat = {
327
334
  id: event.chatId,
@@ -527,6 +534,30 @@ export class EventStore {
527
534
  await this.append(this.projectsLogPath, event)
528
535
  }
529
536
 
537
+ async setSidebarProjectOrder(projectIds: string[]) {
538
+ const validProjectIds = projectIds.filter((projectId) => {
539
+ const project = this.state.projectsById.get(projectId)
540
+ return Boolean(project && !project.deletedAt)
541
+ })
542
+
543
+ const uniqueProjectIds = [...new Set(validProjectIds)]
544
+ const current = this.state.sidebarProjectOrder
545
+ if (
546
+ uniqueProjectIds.length === current.length
547
+ && uniqueProjectIds.every((projectId, index) => current[index] === projectId)
548
+ ) {
549
+ return
550
+ }
551
+
552
+ const event: ProjectEvent = {
553
+ v: STORE_VERSION,
554
+ type: "sidebar_project_order_set",
555
+ timestamp: Date.now(),
556
+ projectIds: uniqueProjectIds,
557
+ }
558
+ await this.append(this.projectsLogPath, event)
559
+ }
560
+
530
561
  async createChat(projectId: string) {
531
562
  const project = this.state.projectsById.get(projectId)
532
563
  if (!project || project.deletedAt) {
@@ -930,6 +961,7 @@ export class EventStore {
930
961
  v: STORE_VERSION,
931
962
  generatedAt: Date.now(),
932
963
  projects: this.listProjects().map((project) => ({ ...project })),
964
+ sidebarProjectOrder: [...this.state.sidebarProjectOrder],
933
965
  chats: [...this.state.chatsById.values()]
934
966
  .filter((chat) => !chat.deletedAt)
935
967
  .map((chat) => ({ ...chat })),
@@ -25,6 +25,7 @@ export interface StoreState {
25
25
  projectIdsByPath: Map<string, string>
26
26
  chatsById: Map<string, ChatRecord>
27
27
  queuedMessagesByChatId: Map<string, QueuedChatMessage[]>
28
+ sidebarProjectOrder: string[]
28
29
  }
29
30
 
30
31
  export interface SnapshotFile {
@@ -32,6 +33,7 @@ export interface SnapshotFile {
32
33
  generatedAt: number
33
34
  projects: ProjectRecord[]
34
35
  chats: ChatRecord[]
36
+ sidebarProjectOrder?: string[]
35
37
  queuedMessages?: Array<{ chatId: string; entries: QueuedChatMessage[] }>
36
38
  messages?: Array<{ chatId: string; entries: TranscriptEntry[] }>
37
39
  }
@@ -48,6 +50,11 @@ export type ProjectEvent = {
48
50
  type: "project_removed"
49
51
  timestamp: number
50
52
  projectId: string
53
+ } | {
54
+ v: 2
55
+ type: "sidebar_project_order_set"
56
+ timestamp: number
57
+ projectIds: string[]
51
58
  }
52
59
 
53
60
  export type ChatEvent =
@@ -160,6 +167,7 @@ export function createEmptyState(): StoreState {
160
167
  projectIdsByPath: new Map(),
161
168
  chatsById: new Map(),
162
169
  queuedMessagesByChatId: new Map(),
170
+ sidebarProjectOrder: [],
163
171
  }
164
172
  }
165
173
 
@@ -15,6 +15,16 @@ describe("generateCommitMessageDetailed", () => {
15
15
  }],
16
16
  },
17
17
  new QuickResponseAdapter({
18
+ readLlmProvider: async () => ({
19
+ provider: "openai",
20
+ apiKey: "",
21
+ model: "",
22
+ baseUrl: "",
23
+ resolvedBaseUrl: "https://api.openai.com/v1",
24
+ enabled: false,
25
+ warning: null,
26
+ filePathDisplay: "~/.kanna/llm-provider.json",
27
+ }),
18
28
  runClaudeStructured: async () => ({
19
29
  subject: " Add login UI.\nextra line",
20
30
  body: " - wire form\n- add validation ",
@@ -42,6 +52,16 @@ describe("generateCommitMessageDetailed", () => {
42
52
  }],
43
53
  },
44
54
  new QuickResponseAdapter({
55
+ readLlmProvider: async () => ({
56
+ provider: "openai",
57
+ apiKey: "",
58
+ model: "",
59
+ baseUrl: "",
60
+ resolvedBaseUrl: "https://api.openai.com/v1",
61
+ enabled: false,
62
+ warning: null,
63
+ filePathDisplay: "~/.kanna/llm-provider.json",
64
+ }),
45
65
  runClaudeStructured: async () => {
46
66
  throw new Error("Not authenticated")
47
67
  },
@@ -24,7 +24,7 @@ export interface GenerateCommitMessageResult {
24
24
  failureMessage: string | null
25
25
  }
26
26
 
27
- function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
27
+ function summarizeFailures(failures: Array<{ provider: "openai" | "claude" | "codex"; reason: string }>) {
28
28
  if (failures.length === 0) return null
29
29
  return failures.map((failure) => failure.reason).join("; ")
30
30
  }
@@ -29,7 +29,7 @@ export interface GenerateChatTitleResult {
29
29
  failureMessage: string | null
30
30
  }
31
31
 
32
- function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
32
+ function summarizeFailures(failures: Array<{ provider: "openai" | "claude" | "codex"; reason: string }>) {
33
33
  if (failures.length === 0) return null
34
34
  return failures.map((failure) => failure.reason).join("; ")
35
35
  }
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { DEFAULT_OPENAI_SDK_MODEL, DEFAULT_OPENROUTER_SDK_MODEL } from "../shared/types"
6
+ import {
7
+ normalizeLlmProviderSnapshot,
8
+ OPENAI_BASE_URL,
9
+ OPENROUTER_BASE_URL,
10
+ readLlmProviderSnapshot,
11
+ resolveLlmProviderBaseUrl,
12
+ writeLlmProviderSnapshot,
13
+ } from "./llm-provider"
14
+
15
+ let tempDirs: string[] = []
16
+ const TEST_FILE_PATH = "/tmp/kanna-test-llm-provider.json"
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
20
+ tempDirs = []
21
+ })
22
+
23
+ async function createTempFilePath() {
24
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-llm-provider-"))
25
+ tempDirs.push(dir)
26
+ return path.join(dir, "llm-provider.json")
27
+ }
28
+
29
+ describe("resolveLlmProviderBaseUrl", () => {
30
+ test("derives known provider URLs and preserves custom URLs", () => {
31
+ expect(resolveLlmProviderBaseUrl("openai", "")).toBe(OPENAI_BASE_URL)
32
+ expect(resolveLlmProviderBaseUrl("openrouter", "")).toBe(OPENROUTER_BASE_URL)
33
+ expect(resolveLlmProviderBaseUrl("custom", " https://example.com/v1 ")).toBe("https://example.com/v1")
34
+ })
35
+ })
36
+
37
+ describe("normalizeLlmProviderSnapshot", () => {
38
+ test("normalizes a valid custom provider config", () => {
39
+ expect(normalizeLlmProviderSnapshot({
40
+ provider: "custom",
41
+ apiKey: " test-key ",
42
+ model: " gpt-test ",
43
+ baseUrl: " https://example.com/v1 ",
44
+ }, TEST_FILE_PATH)).toEqual({
45
+ provider: "custom",
46
+ apiKey: "test-key",
47
+ model: "gpt-test",
48
+ baseUrl: "https://example.com/v1",
49
+ resolvedBaseUrl: "https://example.com/v1",
50
+ enabled: true,
51
+ warning: null,
52
+ filePathDisplay: TEST_FILE_PATH,
53
+ })
54
+ })
55
+
56
+ test("disables invalid configs with a warning", () => {
57
+ const snapshot = normalizeLlmProviderSnapshot({
58
+ provider: "custom",
59
+ apiKey: "test-key",
60
+ model: "gpt-test",
61
+ baseUrl: "",
62
+ }, TEST_FILE_PATH)
63
+
64
+ expect(snapshot.enabled).toBe(false)
65
+ expect(snapshot.warning).toContain("custom provider requires a baseUrl")
66
+ })
67
+ })
68
+
69
+ describe("readLlmProviderSnapshot", () => {
70
+ test("returns defaults when the file does not exist", async () => {
71
+ const filePath = await createTempFilePath()
72
+ expect(await readLlmProviderSnapshot(filePath)).toEqual({
73
+ provider: "openai",
74
+ apiKey: "",
75
+ model: DEFAULT_OPENAI_SDK_MODEL,
76
+ baseUrl: "",
77
+ resolvedBaseUrl: OPENAI_BASE_URL,
78
+ enabled: false,
79
+ warning: null,
80
+ filePathDisplay: filePath,
81
+ })
82
+ })
83
+
84
+ test("returns a warning when the file contains invalid json", async () => {
85
+ const filePath = await createTempFilePath()
86
+ await writeFile(filePath, "{not-json", "utf8")
87
+
88
+ const snapshot = await readLlmProviderSnapshot(filePath)
89
+ expect(snapshot.enabled).toBe(false)
90
+ expect(snapshot.warning).toContain("invalid JSON")
91
+ })
92
+
93
+ test("fills named-provider default models when the file omits them", async () => {
94
+ const filePath = await createTempFilePath()
95
+ await writeFile(filePath, JSON.stringify({
96
+ provider: "openrouter",
97
+ apiKey: "test-key",
98
+ baseUrl: null,
99
+ }), "utf8")
100
+
101
+ const snapshot = await readLlmProviderSnapshot(filePath)
102
+ expect(snapshot.model).toBe(DEFAULT_OPENROUTER_SDK_MODEL)
103
+ expect(snapshot.enabled).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe("writeLlmProviderSnapshot", () => {
108
+ test("writes normalized config to disk", async () => {
109
+ const filePath = await createTempFilePath()
110
+ const snapshot = await writeLlmProviderSnapshot({
111
+ provider: "openrouter",
112
+ apiKey: " test-key ",
113
+ model: " openrouter/model ",
114
+ baseUrl: "ignored",
115
+ }, filePath)
116
+
117
+ expect(snapshot).toEqual({
118
+ provider: "openrouter",
119
+ apiKey: "test-key",
120
+ model: "openrouter/model",
121
+ baseUrl: "ignored",
122
+ resolvedBaseUrl: OPENROUTER_BASE_URL,
123
+ enabled: true,
124
+ warning: null,
125
+ filePathDisplay: filePath,
126
+ })
127
+ expect(await Bun.file(filePath).json()).toEqual({
128
+ provider: "openrouter",
129
+ apiKey: "test-key",
130
+ model: "openrouter/model",
131
+ baseUrl: null,
132
+ })
133
+ })
134
+ })
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+ import OpenAI from "openai"
5
+ import { getLlmProviderFilePath } from "../shared/branding"
6
+ import {
7
+ DEFAULT_OPENAI_SDK_MODEL,
8
+ DEFAULT_OPENROUTER_SDK_MODEL,
9
+ type LlmProviderFile,
10
+ type LlmProviderKind,
11
+ type LlmProviderSnapshot,
12
+ type LlmProviderValidationResult,
13
+ } from "../shared/types"
14
+
15
+ export const OPENAI_BASE_URL = "https://api.openai.com/v1"
16
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
17
+
18
+ const DEFAULT_PROVIDER: LlmProviderKind = "openai"
19
+
20
+ function formatDisplayPath(filePath: string) {
21
+ const homePath = homedir()
22
+ if (filePath === homePath) return "~"
23
+ if (filePath.startsWith(`${homePath}${path.sep}`)) {
24
+ return `~${filePath.slice(homePath.length)}`
25
+ }
26
+ return filePath
27
+ }
28
+
29
+ function resolveProvider(value: unknown) {
30
+ if (value === "openai" || value === "openrouter" || value === "custom") {
31
+ return value
32
+ }
33
+ return null
34
+ }
35
+
36
+ function normalizeString(value: unknown) {
37
+ return typeof value === "string" ? value.trim() : ""
38
+ }
39
+
40
+ export function resolveLlmProviderBaseUrl(provider: LlmProviderKind, baseUrl: string) {
41
+ if (provider === "openai") return OPENAI_BASE_URL
42
+ if (provider === "openrouter") return OPENROUTER_BASE_URL
43
+ return baseUrl.trim()
44
+ }
45
+
46
+ export function resolveLlmProviderDefaultModel(provider: LlmProviderKind) {
47
+ if (provider === "openai") return DEFAULT_OPENAI_SDK_MODEL
48
+ if (provider === "openrouter") return DEFAULT_OPENROUTER_SDK_MODEL
49
+ return ""
50
+ }
51
+
52
+ export function normalizeLlmProviderSnapshot(
53
+ value: unknown,
54
+ filePath = getLlmProviderFilePath(homedir())
55
+ ): LlmProviderSnapshot {
56
+ const source = value && typeof value === "object" && !Array.isArray(value)
57
+ ? value as Record<string, unknown>
58
+ : null
59
+ const warnings: string[] = []
60
+
61
+ if (!source) {
62
+ return createDefaultSnapshot(
63
+ filePath,
64
+ value === undefined || value === null ? null : "LLM provider file must contain a JSON object. Using defaults."
65
+ )
66
+ }
67
+
68
+ const provider = resolveProvider(source.provider)
69
+ const apiKey = normalizeString(source.apiKey)
70
+ const model = normalizeString(source.model)
71
+ const baseUrl = normalizeString(source.baseUrl)
72
+
73
+ if (!provider) {
74
+ warnings.push("provider must be one of openai, openrouter, or custom")
75
+ }
76
+ if (source.apiKey !== undefined && typeof source.apiKey !== "string") {
77
+ warnings.push("apiKey must be a string")
78
+ }
79
+ if (source.model !== undefined && typeof source.model !== "string") {
80
+ warnings.push("model must be a string")
81
+ }
82
+ if (source.baseUrl !== undefined && source.baseUrl !== null && typeof source.baseUrl !== "string") {
83
+ warnings.push("baseUrl must be a string or null")
84
+ }
85
+ if ((provider ?? DEFAULT_PROVIDER) === "custom" && !baseUrl) {
86
+ warnings.push("custom provider requires a baseUrl")
87
+ }
88
+
89
+ const normalizedProvider = provider ?? DEFAULT_PROVIDER
90
+ const resolvedModel = model || resolveLlmProviderDefaultModel(normalizedProvider)
91
+ const resolvedBaseUrl = resolveLlmProviderBaseUrl(normalizedProvider, baseUrl)
92
+ const enabled = warnings.length === 0 && apiKey.length > 0 && resolvedModel.length > 0 && resolvedBaseUrl.length > 0
93
+
94
+ return {
95
+ provider: normalizedProvider,
96
+ apiKey,
97
+ model: resolvedModel,
98
+ baseUrl,
99
+ resolvedBaseUrl,
100
+ enabled,
101
+ warning: warnings.length > 0 ? `Some LLM provider settings are invalid: ${warnings.join("; ")}` : null,
102
+ filePathDisplay: formatDisplayPath(filePath),
103
+ }
104
+ }
105
+
106
+ function createDefaultSnapshot(filePath: string, warning: string | null = null): LlmProviderSnapshot {
107
+ return {
108
+ provider: DEFAULT_PROVIDER,
109
+ apiKey: "",
110
+ model: DEFAULT_OPENAI_SDK_MODEL,
111
+ baseUrl: "",
112
+ resolvedBaseUrl: OPENAI_BASE_URL,
113
+ enabled: false,
114
+ warning,
115
+ filePathDisplay: formatDisplayPath(filePath),
116
+ }
117
+ }
118
+
119
+ export async function readLlmProviderSnapshot(filePath = getLlmProviderFilePath(homedir())) {
120
+ try {
121
+ const text = await readFile(filePath, "utf8")
122
+ if (!text.trim()) {
123
+ return createDefaultSnapshot(filePath, "LLM provider file was empty. Using defaults.")
124
+ }
125
+ return normalizeLlmProviderSnapshot(JSON.parse(text), filePath)
126
+ } catch (error) {
127
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
128
+ return createDefaultSnapshot(filePath)
129
+ }
130
+ if (error instanceof SyntaxError) {
131
+ return createDefaultSnapshot(filePath, "LLM provider file is invalid JSON. Using defaults.")
132
+ }
133
+ throw error
134
+ }
135
+ }
136
+
137
+ export async function writeLlmProviderSnapshot(
138
+ value: Pick<LlmProviderFile, "provider" | "apiKey" | "model"> & { baseUrl: string },
139
+ filePath = getLlmProviderFilePath(homedir())
140
+ ) {
141
+ const snapshot = normalizeLlmProviderSnapshot(value, filePath)
142
+ const payload: LlmProviderFile = {
143
+ provider: snapshot.provider,
144
+ apiKey: snapshot.apiKey,
145
+ model: snapshot.model,
146
+ baseUrl: snapshot.provider === "custom" ? snapshot.baseUrl : null,
147
+ }
148
+ await mkdir(path.dirname(filePath), { recursive: true })
149
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
150
+ return snapshot
151
+ }
152
+
153
+ function toSerializableValue(value: unknown): unknown {
154
+ if (value === null || value === undefined) return value ?? null
155
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value
156
+ if (Array.isArray(value)) {
157
+ return value.map((entry) => toSerializableValue(entry))
158
+ }
159
+ if (value instanceof Error) {
160
+ return toSerializableValue(Object.fromEntries(
161
+ Object.getOwnPropertyNames(value).map((key) => [key, (value as unknown as Record<string, unknown>)[key]])
162
+ ))
163
+ }
164
+ if (typeof value === "object") {
165
+ const record = value as Record<string, unknown>
166
+ return Object.fromEntries(
167
+ Object.keys(record).map((key) => [key, toSerializableValue(record[key])])
168
+ )
169
+ }
170
+ return String(value)
171
+ }
172
+
173
+ export async function validateLlmProviderCredentials(
174
+ value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">
175
+ ): Promise<LlmProviderValidationResult> {
176
+ const snapshot = normalizeLlmProviderSnapshot(value)
177
+ if (!snapshot.enabled) {
178
+ return {
179
+ ok: false,
180
+ error: {
181
+ type: "config_error",
182
+ message: snapshot.warning ?? "LLM provider configuration is incomplete.",
183
+ },
184
+ }
185
+ }
186
+
187
+ try {
188
+ const client = new OpenAI({
189
+ apiKey: snapshot.apiKey,
190
+ baseURL: snapshot.resolvedBaseUrl,
191
+ })
192
+ await client.responses.create({
193
+ model: snapshot.model,
194
+ input: "Reply with ok.",
195
+ max_output_tokens: 5,
196
+ })
197
+ return {
198
+ ok: true,
199
+ error: null,
200
+ }
201
+ } catch (error) {
202
+ return {
203
+ ok: false,
204
+ error: toSerializableValue(error),
205
+ }
206
+ }
207
+ }