kanna-code 0.32.0 → 0.32.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.
@@ -30,8 +30,8 @@
30
30
  })()
31
31
  </script>
32
32
  <title>Kanna</title>
33
- <script type="module" crossorigin src="/assets/index-CoeDwJZ0.js"></script>
34
- <link rel="stylesheet" crossorigin href="/assets/index-BxEBJJ_f.css">
33
+ <script type="module" crossorigin src="/assets/index-CdBLcsbf.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.32.0",
4
+ "version": "0.32.2",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -672,6 +672,161 @@ describe("CodexAppServerManager", () => {
672
672
  })
673
673
  })
674
674
 
675
+ test("maps plain-text fileChange adds into write_file tool calls", async () => {
676
+ const process = new FakeCodexProcess((message, child) => {
677
+ if (message.method === "initialize") {
678
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
679
+ } else if (message.method === "thread/start") {
680
+ child.writeServerMessage({
681
+ id: message.id,
682
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
683
+ })
684
+ } else if (message.method === "turn/start") {
685
+ child.writeServerMessage({
686
+ id: message.id,
687
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
688
+ })
689
+ child.writeServerMessage({
690
+ method: "item/completed",
691
+ params: {
692
+ threadId: "thread-1",
693
+ turnId: "turn-1",
694
+ item: {
695
+ type: "fileChange",
696
+ id: "call-1",
697
+ changes: [
698
+ {
699
+ path: "/tmp/project/test.md",
700
+ kind: {
701
+ type: "add",
702
+ move_path: null,
703
+ },
704
+ diff: "hello\nworld\n",
705
+ },
706
+ ],
707
+ status: "completed",
708
+ },
709
+ },
710
+ })
711
+ child.writeServerMessage({
712
+ method: "turn/completed",
713
+ params: {
714
+ threadId: "thread-1",
715
+ turn: { id: "turn-1", status: "completed", error: null },
716
+ },
717
+ })
718
+ }
719
+ })
720
+
721
+ const manager = new CodexAppServerManager({
722
+ spawnProcess: () => process as never,
723
+ })
724
+
725
+ await manager.startSession({
726
+ chatId: "chat-1",
727
+ cwd: "/tmp/project",
728
+ model: "gpt-5.4",
729
+ sessionToken: null,
730
+ })
731
+
732
+ const turn = await manager.startTurn({
733
+ chatId: "chat-1",
734
+ model: "gpt-5.4",
735
+ content: "write a file",
736
+ planMode: false,
737
+ onToolRequest: async () => ({}),
738
+ })
739
+
740
+ const events = await collectStream(turn.stream)
741
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
742
+
743
+ expect(toolCall?.entry.kind).toBe("tool_call")
744
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
745
+ expect(toolCall.entry.tool.toolKind).toBe("write_file")
746
+ expect(toolCall.entry.tool.input).toEqual({
747
+ filePath: "/tmp/project/test.md",
748
+ content: "hello\nworld\n",
749
+ })
750
+ })
751
+
752
+ test("maps plain-text fileChange deletes into delete_file tool calls", async () => {
753
+ const process = new FakeCodexProcess((message, child) => {
754
+ if (message.method === "initialize") {
755
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
756
+ } else if (message.method === "thread/start") {
757
+ child.writeServerMessage({
758
+ id: message.id,
759
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
760
+ })
761
+ } else if (message.method === "turn/start") {
762
+ child.writeServerMessage({
763
+ id: message.id,
764
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
765
+ })
766
+ child.writeServerMessage({
767
+ method: "item/completed",
768
+ params: {
769
+ threadId: "thread-1",
770
+ turnId: "turn-1",
771
+ item: {
772
+ type: "fileChange",
773
+ id: "call-1",
774
+ changes: [
775
+ {
776
+ path: "/tmp/project/test.md",
777
+ kind: {
778
+ type: "delete",
779
+ move_path: null,
780
+ },
781
+ diff: "hello\nworld\n",
782
+ },
783
+ ],
784
+ status: "completed",
785
+ },
786
+ },
787
+ })
788
+ child.writeServerMessage({
789
+ method: "turn/completed",
790
+ params: {
791
+ threadId: "thread-1",
792
+ turn: { id: "turn-1", status: "completed", error: null },
793
+ },
794
+ })
795
+ }
796
+ })
797
+
798
+ const manager = new CodexAppServerManager({
799
+ spawnProcess: () => process as never,
800
+ })
801
+
802
+ await manager.startSession({
803
+ chatId: "chat-1",
804
+ cwd: "/tmp/project",
805
+ model: "gpt-5.4",
806
+ sessionToken: null,
807
+ })
808
+
809
+ const turn = await manager.startTurn({
810
+ chatId: "chat-1",
811
+ model: "gpt-5.4",
812
+ content: "delete a file",
813
+ planMode: false,
814
+ onToolRequest: async () => ({}),
815
+ })
816
+
817
+ const events = await collectStream(turn.stream)
818
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
819
+
820
+ expect(toolCall?.entry.kind).toBe("tool_call")
821
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
822
+ expect(toolCall.entry.tool.toolKind).toBe("delete_file")
823
+ expect(toolCall.entry.tool.toolName).toBe("Delete")
824
+ expect(toolCall.entry.tool.input).toEqual({
825
+ filePath: "/tmp/project/test.md",
826
+ content: "hello\nworld\n",
827
+ })
828
+ })
829
+
675
830
  test("splits multi-change fileChange items into multiple tool calls and results", async () => {
676
831
  const process = new FakeCodexProcess((message, child) => {
677
832
  if (message.method === "initialize") {
@@ -430,6 +430,18 @@ function parseUnifiedDiff(diff: string): { oldString: string; newString: string
430
430
  }
431
431
  }
432
432
 
433
+ function isUnifiedDiff(diff: string) {
434
+ return diff.includes("@@")
435
+ || diff.startsWith("---")
436
+ || diff.startsWith("+++")
437
+ || diff.split(/\r?\n/).some((line) => (
438
+ line.startsWith("+")
439
+ || line.startsWith("-")
440
+ || line.startsWith(" ")
441
+ || line === "\"
442
+ ))
443
+ }
444
+
433
445
  function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>): TranscriptEntry[] {
434
446
  return item.changes.map((change, index) => {
435
447
  const payload = fileChangePayload(item, change)
@@ -453,7 +465,10 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
453
465
  }
454
466
 
455
467
  if (typeof change.diff === "string") {
456
- const { oldString, newString } = parseUnifiedDiff(change.diff)
468
+ const diffIsUnified = isUnifiedDiff(change.diff)
469
+ const { oldString, newString } = diffIsUnified
470
+ ? parseUnifiedDiff(change.diff)
471
+ : { oldString: change.diff, newString: change.diff }
457
472
 
458
473
  if (normalizedKind.type === "add") {
459
474
  return timestamped({
@@ -473,6 +488,22 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
473
488
  }
474
489
 
475
490
  if (normalizedKind.type === "update") {
491
+ if (!diffIsUnified) {
492
+ return timestamped({
493
+ kind: "tool_call",
494
+ tool: {
495
+ kind: "tool",
496
+ toolKind: "unknown_tool",
497
+ toolName: "FileChange",
498
+ toolId,
499
+ input: {
500
+ payload,
501
+ },
502
+ rawInput: payload,
503
+ },
504
+ })
505
+ }
506
+
476
507
  return timestamped({
477
508
  kind: "tool_call",
478
509
  tool: {
@@ -489,6 +520,23 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
489
520
  },
490
521
  })
491
522
  }
523
+
524
+ if (normalizedKind.type === "delete") {
525
+ return timestamped({
526
+ kind: "tool_call",
527
+ tool: {
528
+ kind: "tool",
529
+ toolKind: "delete_file",
530
+ toolName: "Delete",
531
+ toolId,
532
+ input: {
533
+ filePath: change.path,
534
+ content: oldString,
535
+ },
536
+ rawInput: payload,
537
+ },
538
+ })
539
+ }
492
540
  }
493
541
 
494
542
  return timestamped({
@@ -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)
@@ -235,6 +270,28 @@ describe("DiffStore", () => {
235
270
  })
236
271
  })
237
272
 
273
+ test("refreshSnapshot tolerates tracked files replaced by directories", async () => {
274
+ const repoRoot = await createRepo()
275
+ tempDirs.push(repoRoot)
276
+ await writeFile(path.join(repoRoot, "thing"), "base\n", "utf8")
277
+ await run(["git", "add", "."], repoRoot)
278
+ await run(["git", "commit", "-m", "init"], repoRoot)
279
+
280
+ await rm(path.join(repoRoot, "thing"), { force: true })
281
+ await mkdir(path.join(repoRoot, "thing"), { recursive: true })
282
+ await writeFile(path.join(repoRoot, "thing", "file.txt"), "nested\n", "utf8")
283
+
284
+ const store = new DiffStore(repoRoot)
285
+ await store.initialize()
286
+ await expect(store.refreshSnapshot("project-1", repoRoot)).resolves.toBe(true)
287
+
288
+ const snapshot = store.getProjectSnapshot("project-1")
289
+ expect(snapshot.status).toBe("ready")
290
+ expect(snapshot.files).toHaveLength(2)
291
+ expect(snapshot.files.map((file) => file.path)).toEqual(["thing", "thing/file.txt"])
292
+ expect(snapshot.files.map((file) => file.changeType)).toEqual(["deleted", "added"])
293
+ })
294
+
238
295
  test("discardFile reverts a tracked modified file", async () => {
239
296
  const repoRoot = await createRepo()
240
297
  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
  }
@@ -848,7 +856,8 @@ async function listDirtyPaths(repoRoot: string) {
848
856
 
849
857
  async function readWorktreeFile(repoRoot: string, relativePath: string): Promise<string | null> {
850
858
  const absolutePath = path.join(repoRoot, relativePath)
851
- if (!(await fileExists(absolutePath))) {
859
+ const fileInfo = await stat(absolutePath).catch(() => null)
860
+ if (!fileInfo?.isFile()) {
852
861
  return null
853
862
  }
854
863
 
@@ -2017,15 +2026,27 @@ export class DiffStore {
2017
2026
  ])
2018
2027
  const hasOriginRemote = originRemoteUrl !== null
2019
2028
 
2020
- const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
2021
- const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
2029
+ const currentDirtyEntries = await listDirtyPaths(repo.repoRoot)
2030
+ const currentDirtyPathsByPath = new Map(currentDirtyEntries.map((entry) => [entry.path, entry]))
2031
+ const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.has(relativePath))
2022
2032
  if (missingPaths.length > 0) {
2023
2033
  throw new Error(`File is no longer changed: ${missingPaths[0]}`)
2024
2034
  }
2025
2035
 
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")
2036
+ const trackedPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2037
+ if (trackedPaths.length > 0) {
2038
+ const addTrackedResult = await runGit(["add", "-u", "--", ...trackedPaths], repo.repoRoot)
2039
+ if (addTrackedResult.exitCode !== 0) {
2040
+ return createCommitFailure(args.mode, formatGitFailure(addTrackedResult))
2041
+ }
2042
+ }
2043
+
2044
+ const untrackedPaths = normalizedPaths.filter((relativePath) => currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2045
+ if (untrackedPaths.length > 0) {
2046
+ const addUntrackedResult = await runGit(["add", "--", ...untrackedPaths], repo.repoRoot)
2047
+ if (addUntrackedResult.exitCode !== 0) {
2048
+ return createCommitFailure(args.mode, formatGitFailure(addUntrackedResult))
2049
+ }
2029
2050
  }
2030
2051
 
2031
2052
  const commitArgs = ["commit", "--only", "-m", summary]
@@ -181,7 +181,7 @@ describe("EventStore", () => {
181
181
  content: "second queued",
182
182
  attachments: [],
183
183
  provider: "claude",
184
- model: "sonnet",
184
+ model: "claude-sonnet-4-6",
185
185
  planMode: true,
186
186
  })
187
187
 
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
+ import OpenAI from "openai"
4
5
  import { getLlmProviderFilePath } from "../shared/branding"
5
6
  import {
6
7
  DEFAULT_OPENAI_SDK_MODEL,
@@ -8,6 +9,7 @@ import {
8
9
  type LlmProviderFile,
9
10
  type LlmProviderKind,
10
11
  type LlmProviderSnapshot,
12
+ type LlmProviderValidationResult,
11
13
  } from "../shared/types"
12
14
 
13
15
  export const OPENAI_BASE_URL = "https://api.openai.com/v1"
@@ -147,3 +149,59 @@ export async function writeLlmProviderSnapshot(
147
149
  await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
148
150
  return snapshot
149
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
+ }
@@ -8,14 +8,14 @@ import { resolveClaudeApiModelId } from "../shared/types"
8
8
 
9
9
  describe("provider catalog normalization", () => {
10
10
  test("maps legacy Claude effort into shared model options", () => {
11
- expect(normalizeClaudeModelOptions("opus", undefined, "max")).toEqual({
11
+ expect(normalizeClaudeModelOptions("claude-opus-4-7", undefined, "max")).toEqual({
12
12
  reasoningEffort: "max",
13
13
  contextWindow: "200k",
14
14
  })
15
15
  })
16
16
 
17
17
  test("normalizes Claude context window only for supported models", () => {
18
- expect(normalizeClaudeModelOptions("sonnet", {
18
+ expect(normalizeClaudeModelOptions("claude-sonnet-4-6", {
19
19
  claude: {
20
20
  reasoningEffort: "medium",
21
21
  contextWindow: "1m",
@@ -25,7 +25,7 @@ describe("provider catalog normalization", () => {
25
25
  contextWindow: "1m",
26
26
  })
27
27
 
28
- expect(normalizeClaudeModelOptions("haiku", {
28
+ expect(normalizeClaudeModelOptions("claude-haiku-4-5-20251001", {
29
29
  claude: {
30
30
  reasoningEffort: "medium",
31
31
  contextWindow: "1m",
@@ -56,7 +56,7 @@ describe("provider catalog normalization", () => {
56
56
  })
57
57
 
58
58
  test("resolves Claude API model ids for 1m context window", () => {
59
- expect(resolveClaudeApiModelId("opus", "1m")).toBe("opus[1m]")
60
- expect(resolveClaudeApiModelId("sonnet", "200k")).toBe("sonnet")
59
+ expect(resolveClaudeApiModelId("claude-opus-4-7", "1m")).toBe("claude-opus-4-7[1m]")
60
+ expect(resolveClaudeApiModelId("claude-sonnet-4-6", "200k")).toBe("claude-sonnet-4-6")
61
61
  })
62
62
  })
@@ -98,7 +98,7 @@ export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs
98
98
  prompt: args.prompt,
99
99
  options: {
100
100
  cwd: args.cwd,
101
- model: "haiku",
101
+ model: "claude-haiku-4-5-20251001",
102
102
  tools: [],
103
103
  systemPrompt: "",
104
104
  effort: "low",
@@ -62,7 +62,7 @@ describe("read models", () => {
62
62
  attachments: [],
63
63
  createdAt: 2,
64
64
  provider: "claude",
65
- model: "sonnet",
65
+ model: "claude-sonnet-4-6",
66
66
  planMode: true,
67
67
  }])
68
68
 
@@ -8,7 +8,7 @@ import { AgentCoordinator } from "./agent"
8
8
  import { DiffStore } from "./diff-store"
9
9
  import { discoverProjects, type DiscoveredProject } from "./discovery"
10
10
  import { KeybindingsManager } from "./keybindings"
11
- import { readLlmProviderSnapshot, writeLlmProviderSnapshot } from "./llm-provider"
11
+ import { readLlmProviderSnapshot, validateLlmProviderCredentials, writeLlmProviderSnapshot } from "./llm-provider"
12
12
  import { getMachineDisplayName } from "./machine-name"
13
13
  import { TerminalManager } from "./terminal-manager"
14
14
  import { UpdateManager } from "./update-manager"
@@ -124,6 +124,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
124
124
  llmProvider: {
125
125
  read: readLlmProviderSnapshot,
126
126
  write: writeLlmProviderSnapshot,
127
+ validate: validateLlmProviderCredentials,
127
128
  },
128
129
  refreshDiscovery,
129
130
  getDiscoveredProjects: () => discoveredProjects,
@@ -142,6 +142,10 @@ describe("ws-router", () => {
142
142
  enabled: Boolean(value.apiKey && value.model),
143
143
  }
144
144
  },
145
+ validate: async () => ({
146
+ ok: true,
147
+ error: null,
148
+ }),
145
149
  },
146
150
  refreshDiscovery: async () => [],
147
151
  getDiscoveredProjects: () => [],
@@ -13,6 +13,7 @@ import { TerminalManager } from "./terminal-manager"
13
13
  import type { UpdateManager } from "./update-manager"
14
14
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
15
15
  import type { LlmProviderSnapshot } from "../shared/types"
16
+ import type { LlmProviderValidationResult } from "../shared/types"
16
17
 
17
18
  const DEFAULT_CHAT_RECENT_LIMIT = 200
18
19
 
@@ -100,6 +101,7 @@ interface CreateWsRouterArgs {
100
101
  llmProvider?: {
101
102
  read: () => Promise<LlmProviderSnapshot>
102
103
  write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
104
+ validate: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderValidationResult>
103
105
  }
104
106
  refreshDiscovery: () => Promise<DiscoveredProject[]>
105
107
  getDiscoveredProjects: () => DiscoveredProject[]
@@ -203,6 +205,13 @@ export function createWsRouter({
203
205
  warning: null,
204
206
  filePathDisplay: "~/.kanna/llm-provider.json",
205
207
  }),
208
+ validate: async () => ({
209
+ ok: false,
210
+ error: {
211
+ type: "config_error",
212
+ message: "LLM provider validation unavailable.",
213
+ },
214
+ }),
206
215
  }
207
216
 
208
217
  function getProtectedChatIds() {
@@ -730,6 +739,16 @@ export function createWsRouter({
730
739
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
731
740
  return
732
741
  }
742
+ case "settings.validateLlmProvider": {
743
+ const result = await resolvedLlmProvider.validate({
744
+ provider: command.provider,
745
+ apiKey: command.apiKey,
746
+ model: command.model,
747
+ baseUrl: command.baseUrl,
748
+ })
749
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
750
+ return
751
+ }
733
752
  case "project.open": {
734
753
  await ensureProjectDirectory(command.localPath)
735
754
  const project = await store.openProject(command.localPath)
@@ -66,6 +66,13 @@ export type ClientCommand =
66
66
  model: string
67
67
  baseUrl: string
68
68
  }
69
+ | {
70
+ type: "settings.validateLlmProvider"
71
+ provider: LlmProviderSnapshot["provider"]
72
+ apiKey: string
73
+ model: string
74
+ baseUrl: string
75
+ }
69
76
  | {
70
77
  type: "system.openExternal"
71
78
  localPath: string