kanna-code 0.30.0 → 0.32.0
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/README.md +13 -0
- package/dist/client/assets/index-BxEBJJ_f.css +32 -0
- package/dist/client/assets/index-CoeDwJZ0.js +2593 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/auth.test.ts +207 -0
- package/src/server/auth.ts +304 -0
- package/src/server/cli-runtime.test.ts +28 -0
- package/src/server/cli-runtime.ts +11 -0
- package/src/server/event-store.test.ts +18 -0
- package/src/server/event-store.ts +32 -0
- package/src/server/events.ts +8 -0
- package/src/server/generate-commit-message.test.ts +20 -0
- package/src/server/generate-commit-message.ts +1 -1
- package/src/server/generate-title.ts +1 -1
- package/src/server/llm-provider.test.ts +134 -0
- package/src/server/llm-provider.ts +149 -0
- package/src/server/quick-response.test.ts +202 -0
- package/src/server/quick-response.ts +56 -2
- package/src/server/read-models.test.ts +78 -1
- package/src/server/read-models.ts +54 -4
- package/src/server/server.ts +47 -0
- package/src/server/ws-router.test.ts +274 -10
- package/src/server/ws-router.ts +111 -21
- package/src/shared/branding.ts +8 -0
- package/src/shared/protocol.ts +11 -0
- package/src/shared/types.ts +24 -0
- package/dist/client/assets/index-Bnur1EEv.css +0 -32
- package/dist/client/assets/index-DNENqajG.js +0 -2593
|
@@ -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 })),
|
package/src/server/events.ts
CHANGED
|
@@ -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,149 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { getLlmProviderFilePath } from "../shared/branding"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_OPENAI_SDK_MODEL,
|
|
7
|
+
DEFAULT_OPENROUTER_SDK_MODEL,
|
|
8
|
+
type LlmProviderFile,
|
|
9
|
+
type LlmProviderKind,
|
|
10
|
+
type LlmProviderSnapshot,
|
|
11
|
+
} from "../shared/types"
|
|
12
|
+
|
|
13
|
+
export const OPENAI_BASE_URL = "https://api.openai.com/v1"
|
|
14
|
+
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PROVIDER: LlmProviderKind = "openai"
|
|
17
|
+
|
|
18
|
+
function formatDisplayPath(filePath: string) {
|
|
19
|
+
const homePath = homedir()
|
|
20
|
+
if (filePath === homePath) return "~"
|
|
21
|
+
if (filePath.startsWith(`${homePath}${path.sep}`)) {
|
|
22
|
+
return `~${filePath.slice(homePath.length)}`
|
|
23
|
+
}
|
|
24
|
+
return filePath
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveProvider(value: unknown) {
|
|
28
|
+
if (value === "openai" || value === "openrouter" || value === "custom") {
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeString(value: unknown) {
|
|
35
|
+
return typeof value === "string" ? value.trim() : ""
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveLlmProviderBaseUrl(provider: LlmProviderKind, baseUrl: string) {
|
|
39
|
+
if (provider === "openai") return OPENAI_BASE_URL
|
|
40
|
+
if (provider === "openrouter") return OPENROUTER_BASE_URL
|
|
41
|
+
return baseUrl.trim()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveLlmProviderDefaultModel(provider: LlmProviderKind) {
|
|
45
|
+
if (provider === "openai") return DEFAULT_OPENAI_SDK_MODEL
|
|
46
|
+
if (provider === "openrouter") return DEFAULT_OPENROUTER_SDK_MODEL
|
|
47
|
+
return ""
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeLlmProviderSnapshot(
|
|
51
|
+
value: unknown,
|
|
52
|
+
filePath = getLlmProviderFilePath(homedir())
|
|
53
|
+
): LlmProviderSnapshot {
|
|
54
|
+
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
55
|
+
? value as Record<string, unknown>
|
|
56
|
+
: null
|
|
57
|
+
const warnings: string[] = []
|
|
58
|
+
|
|
59
|
+
if (!source) {
|
|
60
|
+
return createDefaultSnapshot(
|
|
61
|
+
filePath,
|
|
62
|
+
value === undefined || value === null ? null : "LLM provider file must contain a JSON object. Using defaults."
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const provider = resolveProvider(source.provider)
|
|
67
|
+
const apiKey = normalizeString(source.apiKey)
|
|
68
|
+
const model = normalizeString(source.model)
|
|
69
|
+
const baseUrl = normalizeString(source.baseUrl)
|
|
70
|
+
|
|
71
|
+
if (!provider) {
|
|
72
|
+
warnings.push("provider must be one of openai, openrouter, or custom")
|
|
73
|
+
}
|
|
74
|
+
if (source.apiKey !== undefined && typeof source.apiKey !== "string") {
|
|
75
|
+
warnings.push("apiKey must be a string")
|
|
76
|
+
}
|
|
77
|
+
if (source.model !== undefined && typeof source.model !== "string") {
|
|
78
|
+
warnings.push("model must be a string")
|
|
79
|
+
}
|
|
80
|
+
if (source.baseUrl !== undefined && source.baseUrl !== null && typeof source.baseUrl !== "string") {
|
|
81
|
+
warnings.push("baseUrl must be a string or null")
|
|
82
|
+
}
|
|
83
|
+
if ((provider ?? DEFAULT_PROVIDER) === "custom" && !baseUrl) {
|
|
84
|
+
warnings.push("custom provider requires a baseUrl")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const normalizedProvider = provider ?? DEFAULT_PROVIDER
|
|
88
|
+
const resolvedModel = model || resolveLlmProviderDefaultModel(normalizedProvider)
|
|
89
|
+
const resolvedBaseUrl = resolveLlmProviderBaseUrl(normalizedProvider, baseUrl)
|
|
90
|
+
const enabled = warnings.length === 0 && apiKey.length > 0 && resolvedModel.length > 0 && resolvedBaseUrl.length > 0
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
provider: normalizedProvider,
|
|
94
|
+
apiKey,
|
|
95
|
+
model: resolvedModel,
|
|
96
|
+
baseUrl,
|
|
97
|
+
resolvedBaseUrl,
|
|
98
|
+
enabled,
|
|
99
|
+
warning: warnings.length > 0 ? `Some LLM provider settings are invalid: ${warnings.join("; ")}` : null,
|
|
100
|
+
filePathDisplay: formatDisplayPath(filePath),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createDefaultSnapshot(filePath: string, warning: string | null = null): LlmProviderSnapshot {
|
|
105
|
+
return {
|
|
106
|
+
provider: DEFAULT_PROVIDER,
|
|
107
|
+
apiKey: "",
|
|
108
|
+
model: DEFAULT_OPENAI_SDK_MODEL,
|
|
109
|
+
baseUrl: "",
|
|
110
|
+
resolvedBaseUrl: OPENAI_BASE_URL,
|
|
111
|
+
enabled: false,
|
|
112
|
+
warning,
|
|
113
|
+
filePathDisplay: formatDisplayPath(filePath),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function readLlmProviderSnapshot(filePath = getLlmProviderFilePath(homedir())) {
|
|
118
|
+
try {
|
|
119
|
+
const text = await readFile(filePath, "utf8")
|
|
120
|
+
if (!text.trim()) {
|
|
121
|
+
return createDefaultSnapshot(filePath, "LLM provider file was empty. Using defaults.")
|
|
122
|
+
}
|
|
123
|
+
return normalizeLlmProviderSnapshot(JSON.parse(text), filePath)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
126
|
+
return createDefaultSnapshot(filePath)
|
|
127
|
+
}
|
|
128
|
+
if (error instanceof SyntaxError) {
|
|
129
|
+
return createDefaultSnapshot(filePath, "LLM provider file is invalid JSON. Using defaults.")
|
|
130
|
+
}
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function writeLlmProviderSnapshot(
|
|
136
|
+
value: Pick<LlmProviderFile, "provider" | "apiKey" | "model"> & { baseUrl: string },
|
|
137
|
+
filePath = getLlmProviderFilePath(homedir())
|
|
138
|
+
) {
|
|
139
|
+
const snapshot = normalizeLlmProviderSnapshot(value, filePath)
|
|
140
|
+
const payload: LlmProviderFile = {
|
|
141
|
+
provider: snapshot.provider,
|
|
142
|
+
apiKey: snapshot.apiKey,
|
|
143
|
+
model: snapshot.model,
|
|
144
|
+
baseUrl: snapshot.provider === "custom" ? snapshot.baseUrl : null,
|
|
145
|
+
}
|
|
146
|
+
await mkdir(path.dirname(filePath), { recursive: true })
|
|
147
|
+
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
|
|
148
|
+
return snapshot
|
|
149
|
+
}
|