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.
- package/dist/client/assets/index-B3EUEMfY.css +32 -0
- package/dist/client/assets/index-CdBLcsbf.js +2598 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/codex-app-server.test.ts +155 -0
- package/src/server/codex-app-server.ts +49 -1
- package/src/server/diff-store.test.ts +57 -0
- package/src/server/diff-store.ts +29 -8
- package/src/server/event-store.test.ts +1 -1
- package/src/server/llm-provider.ts +58 -0
- package/src/server/provider-catalog.test.ts +5 -5
- package/src/server/quick-response.ts +1 -1
- package/src/server/read-models.test.ts +1 -1
- package/src/server/server.ts +2 -1
- package/src/server/ws-router.test.ts +4 -0
- package/src/server/ws-router.ts +19 -0
- package/src/shared/protocol.ts +7 -0
- package/src/shared/types.ts +37 -4
- package/dist/client/assets/index-BxEBJJ_f.css +0 -32
- package/dist/client/assets/index-CoeDwJZ0.js +0 -2593
package/dist/client/index.html
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
})()
|
|
31
31
|
</script>
|
|
32
32
|
<title>Kanna</title>
|
|
33
|
-
<script type="module" crossorigin src="/assets/index-
|
|
34
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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
|
|
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)
|
package/src/server/diff-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
2021
|
-
const
|
|
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
|
|
2027
|
-
if (
|
|
2028
|
-
|
|
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]
|
|
@@ -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
|
})
|
package/src/server/server.ts
CHANGED
|
@@ -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,
|
package/src/server/ws-router.ts
CHANGED
|
@@ -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)
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|