saeeol 1.0.9 → 1.1.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.
- package/npm/bin/saeeol +42 -0
- package/npm/package.json +39 -0
- package/npm/postinstall.js +162 -0
- package/package.json +2 -2
- package/src/cli/cmd/mcp-refresh.ts +47 -0
- package/src/cli/cmd/mcp.ts +3 -1
- package/src/cli/cmd/tui/app-commands-core.tsx +11 -0
- package/src/cli/cmd/tui/app-commands-system.tsx +20 -0
- package/src/cli/cmd/tui/app-events.ts +43 -0
- package/src/cli/cmd/tui/app.tsx +4 -0
- package/src/cli/cmd/tui/component/dialog-model.tsx +2 -2
- package/src/cli/cmd/tui/component/prompt/use-prompt-memos.ts +1 -1
- package/src/cli/cmd/tui/component/use-connected.tsx +1 -1
- package/src/cli/cmd/tui/context/local.tsx +10 -3
- package/src/cli/cmd/tui/context/route.tsx +5 -1
- package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +1 -1
- package/src/cli/cmd/tui/plugin/api.tsx +7 -3
- package/src/cli/cmd/tui/routes/local-models.tsx +151 -0
- package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +1 -1
- package/src/cli/cmd/tui/util/model.ts +1 -1
- package/src/config/config-schema.ts +44 -0
- package/src/ltm/config.ts +124 -0
- package/src/ltm/events.ts +50 -0
- package/src/ltm/index.ts +12 -0
- package/src/ltm/memory/episodic.ts +83 -0
- package/src/ltm/memory/procedural.ts +102 -0
- package/src/ltm/memory/semantic.ts +80 -0
- package/src/ltm/pipeline.ts +155 -0
- package/src/ltm/retrieval.ts +62 -0
- package/src/ltm/scheduler.ts +55 -0
- package/src/ltm/store.ts +150 -0
- package/src/ltm/types.ts +108 -0
- package/src/mcp/index.ts +32 -1
- package/src/provider/custom-loaders.ts +12 -0
- package/src/provider/loader-local.ts +185 -0
- package/src/provider/local/embedder.ts +220 -0
- package/src/provider/local/events.ts +74 -0
- package/src/provider/local/gpu.ts +93 -0
- package/src/provider/local/hub.ts +174 -0
- package/src/provider/local/index.ts +10 -0
- package/src/provider/local/model-manager.ts +113 -0
- package/src/provider/local/orchestrator.ts +301 -0
- package/src/provider/local/rag.ts +112 -0
- package/src/provider/local/types.ts +142 -0
- package/src/provider/provider-conversion.ts +2 -0
- package/src/provider/provider-schema.ts +17 -2
- package/src/provider/provider-schemas.ts +10 -3
- package/src/provider/provider-state.ts +10 -2
- package/src/provider/provider.ts +2 -1
- package/src/saeeol/plugins/sidebar-usage.tsx +1 -1
- package/src/server/routes/instance/config.ts +1 -1
- package/src/server/routes/instance/httpapi/api.ts +2 -0
- package/src/server/routes/instance/httpapi/groups/local.ts +87 -0
- package/src/server/routes/instance/httpapi/groups/mcp.ts +10 -0
- package/src/server/routes/instance/httpapi/handlers/local.ts +95 -0
- package/src/server/routes/instance/httpapi/handlers/mcp.ts +5 -0
- package/src/server/routes/instance/httpapi/handlers/provider.ts +1 -1
- package/src/server/routes/instance/httpapi/server.ts +2 -0
- package/src/server/routes/instance/provider.ts +2 -2
- package/src/session/prompt-reminders.ts +29 -0
- package/test/fake/provider.ts +1 -0
- package/test/provider/local.test.ts +208 -0
- package/test/provider/provider-category.test.ts +190 -0
|
@@ -58,6 +58,7 @@ import { experimentalHandlers } from "./handlers/experimental"
|
|
|
58
58
|
import { fileHandlers } from "./handlers/file"
|
|
59
59
|
import { globalHandlers } from "./handlers/global"
|
|
60
60
|
import { instanceHandlers } from "./handlers/instance"
|
|
61
|
+
import { localHandlers } from "./handlers/local"
|
|
61
62
|
import { mcpHandlers } from "./handlers/mcp"
|
|
62
63
|
import { permissionHandlers } from "./handlers/permission"
|
|
63
64
|
import { projectHandlers } from "./handlers/project"
|
|
@@ -110,6 +111,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
|
|
110
111
|
experimentalHandlers,
|
|
111
112
|
fileHandlers,
|
|
112
113
|
instanceHandlers,
|
|
114
|
+
localHandlers,
|
|
113
115
|
mcpHandlers,
|
|
114
116
|
projectHandlers,
|
|
115
117
|
ptyHandlers,
|
|
@@ -58,11 +58,11 @@ export const ProviderRoutes = lazy(() =>
|
|
|
58
58
|
const failedSet = new Set(failed)
|
|
59
59
|
const validProviders = pickBy(
|
|
60
60
|
providers,
|
|
61
|
-
(item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id),
|
|
61
|
+
(item, id) => Object.keys(item.models ?? {}).length > 0 || id in connected || failedSet.has(id),
|
|
62
62
|
)
|
|
63
63
|
return {
|
|
64
64
|
all: Object.values(validProviders),
|
|
65
|
-
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)),
|
|
65
|
+
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models ?? {}).length > 0)),
|
|
66
66
|
connected: Object.keys(connected),
|
|
67
67
|
failed,
|
|
68
68
|
}
|
|
@@ -7,6 +7,7 @@ import * as Session from "./session"
|
|
|
7
7
|
import { InstanceState } from "@/effect/instance-state"
|
|
8
8
|
import CODE_SWITCH from "./prompt/code-switch.txt"
|
|
9
9
|
import type { PromptDeps } from "./prompt-types"
|
|
10
|
+
import * as LTM from "@/ltm"
|
|
10
11
|
|
|
11
12
|
const PLAN_REMINDER = `<system-reminder>
|
|
12
13
|
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
|
@@ -57,6 +58,34 @@ export const insertReminders = (deps: PromptDeps, SaeeolSessionPrompt: any) =>
|
|
|
57
58
|
sessionID: userMessage.info.sessionID, type: "text", text: CODE_SWITCH, synthetic: true,
|
|
58
59
|
})
|
|
59
60
|
}
|
|
61
|
+
|
|
62
|
+
// LTM retrieval — inject relevant long-term memories into the prompt
|
|
63
|
+
const cfg = yield* deps.config.get()
|
|
64
|
+
if (cfg?.ltm?.enabled) {
|
|
65
|
+
const userText = userMessage.parts
|
|
66
|
+
.filter((p) => p.type === "text")
|
|
67
|
+
.map((p) => (p as any).text ?? "")
|
|
68
|
+
.join(" ")
|
|
69
|
+
.slice(0, 500)
|
|
70
|
+
if (userText.length > 10) {
|
|
71
|
+
const ltmCfg = {
|
|
72
|
+
...LTM.Config.DEFAULT_LTM_CONFIG,
|
|
73
|
+
...cfg.ltm,
|
|
74
|
+
episodic: { ...LTM.Config.DEFAULT_LTM_CONFIG.episodic, ...cfg.ltm.episodic },
|
|
75
|
+
semantic: { ...LTM.Config.DEFAULT_LTM_CONFIG.semantic, ...cfg.ltm.semantic },
|
|
76
|
+
procedural: { ...LTM.Config.DEFAULT_LTM_CONFIG.procedural, ...cfg.ltm.procedural },
|
|
77
|
+
retrieval: { ...LTM.Config.DEFAULT_LTM_CONFIG.retrieval, ...cfg.ltm.retrieval },
|
|
78
|
+
}
|
|
79
|
+
const context = yield* Effect.promise(() => LTM.Retrieval.inject(userText, ltmCfg))
|
|
80
|
+
if (context) {
|
|
81
|
+
userMessage.parts.push({
|
|
82
|
+
id: PartID.ascending(), messageID: userMessage.info.id,
|
|
83
|
+
sessionID: userMessage.info.sessionID, type: "text", text: `\n${context}`, synthetic: true,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
return input.messages
|
|
61
90
|
}
|
|
62
91
|
|
package/test/fake/provider.ts
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import { Schema } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
GPUInfo, GPUProfile, Quantization, ModelFormat,
|
|
5
|
+
ModelArtifact, ModelStatus, ModelInstance,
|
|
6
|
+
BackendType, BackendStatus,
|
|
7
|
+
RAGAssetType, RAGAsset,
|
|
8
|
+
DownloadState, HFModelSearch,
|
|
9
|
+
} from "@/provider/local/types"
|
|
10
|
+
import * as GPU from "@/provider/local/gpu"
|
|
11
|
+
import * as Hub from "@/provider/local/hub"
|
|
12
|
+
import * as RAG from "@/provider/local/rag"
|
|
13
|
+
|
|
14
|
+
// ── Types ──
|
|
15
|
+
|
|
16
|
+
describe("local types", () => {
|
|
17
|
+
test("Quantization accepts known values", () => {
|
|
18
|
+
const vals = ["q4_k_m", "q5_k_m", "q8_0", "fp16", "bf16", "fp32"] as const
|
|
19
|
+
for (const v of vals) {
|
|
20
|
+
expect(Schema.decodeSync(Quantization)(v)).toBe(v)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("Quantization rejects invalid", () => {
|
|
25
|
+
expect(() => Schema.decodeSync(Quantization)("invalid" as any)).toThrow()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("ModelFormat accepts known values", () => {
|
|
29
|
+
for (const v of ["gguf", "safetensors", "pytorch", "onnx", "awq", "gptq"] as const) {
|
|
30
|
+
expect(Schema.decodeSync(ModelFormat)(v)).toBe(v)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("ModelStatus accepts all states", () => {
|
|
35
|
+
for (const v of ["stopped", "starting", "running", "error", "downloading"] as const) {
|
|
36
|
+
expect(Schema.decodeSync(ModelStatus)(v)).toBe(v)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("BackendType accepts known backends", () => {
|
|
41
|
+
for (const v of ["ollama", "lmstudio", "vllm", "llama.cpp", "text-generation-webui"] as const) {
|
|
42
|
+
expect(Schema.decodeSync(BackendType)(v)).toBe(v)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("RAGAssetType accepts known types", () => {
|
|
47
|
+
for (const v of ["embedding", "reranker", "vectordb"] as const) {
|
|
48
|
+
expect(Schema.decodeSync(RAGAssetType)(v)).toBe(v)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ── GPU ──
|
|
54
|
+
|
|
55
|
+
describe("GPU utilities", () => {
|
|
56
|
+
const mockProfile: GPUProfile = {
|
|
57
|
+
gpus: [
|
|
58
|
+
{ index: 0, name: "RTX 4090", vramTotalMB: 24564, vramUsedMB: 2000, vramFreeMB: 22564, computeCapability: "8.9" },
|
|
59
|
+
{ index: 1, name: "RTX 3090", vramTotalMB: 24564, vramUsedMB: 8000, vramFreeMB: 16564, computeCapability: "8.6" },
|
|
60
|
+
],
|
|
61
|
+
totalVRAMMB: 49128,
|
|
62
|
+
availableVRAMMB: 39128,
|
|
63
|
+
cudaAvailable: true,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("estimateVRAM returns MB with 20% overhead", () => {
|
|
67
|
+
// 1GB model = ~1220MB VRAM needed
|
|
68
|
+
const vram = GPU.estimateVRAM(1024 * 1024 * 1024, "q4_k_m")
|
|
69
|
+
expect(vram).toBeGreaterThan(1000)
|
|
70
|
+
expect(vram).toBeLessThan(1500)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("findBestGPU picks GPU with most free VRAM", () => {
|
|
74
|
+
const gpu = GPU.findBestGPU(mockProfile, 10000)
|
|
75
|
+
expect(gpu).toBeDefined()
|
|
76
|
+
expect(gpu!.index).toBe(0) // 22564MB free > 16564MB free
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("findBestGPU returns undefined if nothing fits", () => {
|
|
80
|
+
const gpu = GPU.findBestGPU(mockProfile, 50000)
|
|
81
|
+
expect(gpu).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("canFit returns true when enough VRAM", () => {
|
|
85
|
+
expect(GPU.canFit(mockProfile, 30000)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("canFit returns false when not enough VRAM", () => {
|
|
89
|
+
expect(GPU.canFit(mockProfile, 50000)).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("suggestQuantization picks highest quality that fits", () => {
|
|
93
|
+
const sizes: Record<string, number> = {
|
|
94
|
+
fp16: 14_000_000_000,
|
|
95
|
+
q8_0: 8_000_000_000,
|
|
96
|
+
q5_k_m: 5_000_000_000,
|
|
97
|
+
q4_k_m: 4_000_000_000,
|
|
98
|
+
q2_k: 2_500_000_000,
|
|
99
|
+
}
|
|
100
|
+
const result = GPU.suggestQuantization(10000, sizes)
|
|
101
|
+
expect(result).toBeDefined()
|
|
102
|
+
if (result) expect(["q4_k_m", "q5_k_m", "q8_0", "fp16"]).toContain(result)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("suggestQuantization returns undefined if nothing fits", () => {
|
|
106
|
+
const sizes: Record<string, number> = {
|
|
107
|
+
fp16: 100_000_000_000,
|
|
108
|
+
q2_k: 50_000_000_000,
|
|
109
|
+
}
|
|
110
|
+
const result = GPU.suggestQuantization(100, sizes)
|
|
111
|
+
expect(result).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ── Hub ──
|
|
116
|
+
|
|
117
|
+
describe("HuggingFace Hub utilities", () => {
|
|
118
|
+
test("parseQuant extracts quantization from GGUF filename", () => {
|
|
119
|
+
expect(Hub.parseQuant("model-q4_k_m.gguf")).toBe("q4_k_m")
|
|
120
|
+
expect(Hub.parseQuant("Llama-3.1-8B-Q5_K_M.gguf")).toBe("q5_k_m")
|
|
121
|
+
expect(Hub.parseQuant("model-q8_0.gguf")).toBe("q8_0")
|
|
122
|
+
expect(Hub.parseQuant("model-fp16.gguf")).toBe("fp16")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("parseQuant returns undefined for unknown", () => {
|
|
126
|
+
expect(Hub.parseQuant("model.bin")).toBeUndefined()
|
|
127
|
+
expect(Hub.parseQuant("config.json")).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("listGGUF filters GGUF files from siblings", () => {
|
|
131
|
+
const siblings = [
|
|
132
|
+
{ rfilename: "model-q4_k_m.gguf" },
|
|
133
|
+
{ rfilename: "config.json" },
|
|
134
|
+
{ rfilename: "tokenizer.json" },
|
|
135
|
+
{ rfilename: "model-q8_0.gguf" },
|
|
136
|
+
]
|
|
137
|
+
const ggufs = Hub.listGGUF(siblings)
|
|
138
|
+
expect(ggufs).toHaveLength(2)
|
|
139
|
+
expect(ggufs).toContain("model-q4_k_m.gguf")
|
|
140
|
+
expect(ggufs).toContain("model-q8_0.gguf")
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("downloadURL builds correct URL", () => {
|
|
144
|
+
const url = Hub.downloadURL("meta-llama/Llama-3.1-8B", "model.gguf")
|
|
145
|
+
expect(url).toBe("https://huggingface.co/meta-llama/Llama-3.1-8B/resolve/main/model.gguf")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("buildArtifact creates valid artifact", () => {
|
|
149
|
+
const artifact = Hub.buildArtifact("meta-llama/Llama-3.1-8B", "model-q4_k_m.gguf", 5_000_000_000, "abc123")
|
|
150
|
+
expect(artifact.repo).toBe("meta-llama/Llama-3.1-8B")
|
|
151
|
+
expect(artifact.filename).toBe("model-q4_k_m.gguf")
|
|
152
|
+
expect(artifact.format).toBe("gguf")
|
|
153
|
+
expect(artifact.quantization).toBe("q4_k_m")
|
|
154
|
+
expect(artifact.sizeBytes).toBe(5_000_000_000)
|
|
155
|
+
expect(artifact.sha256).toBe("abc123")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("buildArtifact detects safetensors format", () => {
|
|
159
|
+
const artifact = Hub.buildArtifact("BAAI/bge-small", "model.safetensors", 130_000_000)
|
|
160
|
+
expect(artifact.format).toBe("safetensors")
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ── RAG ──
|
|
165
|
+
|
|
166
|
+
describe("RAG assets", () => {
|
|
167
|
+
test("EMBEDDING_MODELS has well-known entries", () => {
|
|
168
|
+
expect(RAG.EMBEDDING_MODELS.length).toBeGreaterThan(0)
|
|
169
|
+
const ids = RAG.EMBEDDING_MODELS.map(m => m.id)
|
|
170
|
+
expect(ids).toContain("bge-small-en")
|
|
171
|
+
expect(ids).toContain("nomic-embed")
|
|
172
|
+
expect(ids).toContain("all-minilm-l6")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("RERANKER_MODELS has well-known entries", () => {
|
|
176
|
+
expect(RAG.RERANKER_MODELS.length).toBeGreaterThan(0)
|
|
177
|
+
const ids = RAG.RERANKER_MODELS.map(m => m.id)
|
|
178
|
+
expect(ids).toContain("bge-reranker-base")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("embeddingAsset builds correct RAGAsset", () => {
|
|
182
|
+
const model = RAG.EMBEDDING_MODELS[0]
|
|
183
|
+
const asset = RAG.embeddingAsset(model)
|
|
184
|
+
expect(asset.type).toBe("embedding")
|
|
185
|
+
expect(asset.repo).toBe(model.repo)
|
|
186
|
+
expect(asset.dimensions).toBe(model.dimensions)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("rerankerAsset builds correct RAGAsset", () => {
|
|
190
|
+
const model = RAG.RERANKER_MODELS[0]
|
|
191
|
+
const asset = RAG.rerankerAsset(model)
|
|
192
|
+
expect(asset.type).toBe("reranker")
|
|
193
|
+
expect(asset.repo).toBe(model.repo)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("recommendForVRAM filters by available memory", async () => {
|
|
197
|
+
const { embeddings, rerankers } = await RAG.recommendForVRAM(500) // 500MB
|
|
198
|
+
// Should only include small models
|
|
199
|
+
for (const e of embeddings) {
|
|
200
|
+
expect(e.sizeBytes).toBeLessThan(500 * 1024 * 1024 * 1.2)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("recommendForVRAM returns all when plenty of VRAM", async () => {
|
|
205
|
+
const { embeddings } = await RAG.recommendForVRAM(100000) // 100GB
|
|
206
|
+
expect(embeddings.length).toBe(RAG.EMBEDDING_MODELS.length)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import { Schema } from "effect"
|
|
3
|
+
import { Provider } from "@/provider/provider"
|
|
4
|
+
import { ProviderID, ModelID } from "../../src/provider/schema"
|
|
5
|
+
import { LOCAL_PROVIDERS, CUSTOM_PROVIDERS, CLOUD_PROVIDERS } from "@/provider/custom-loaders"
|
|
6
|
+
import { ProviderCategory, Info } from "@/provider/provider-schemas"
|
|
7
|
+
|
|
8
|
+
const local = LOCAL_PROVIDERS as readonly string[]
|
|
9
|
+
const custom = CUSTOM_PROVIDERS as readonly string[]
|
|
10
|
+
const cloud = CLOUD_PROVIDERS as readonly string[]
|
|
11
|
+
|
|
12
|
+
describe("provider category constants", () => {
|
|
13
|
+
test("LOCAL_PROVIDERS contains expected local providers", () => {
|
|
14
|
+
expect(local).toContain("ollama")
|
|
15
|
+
expect(local).toContain("lmstudio")
|
|
16
|
+
expect(local).toContain("vllm")
|
|
17
|
+
expect(local).toContain("text-generation-webui")
|
|
18
|
+
expect(local).toContain("llama.cpp")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("CUSTOM_PROVIDERS contains expected custom providers", () => {
|
|
22
|
+
expect(custom).toContain("openrouter")
|
|
23
|
+
expect(custom).toContain("llmgateway")
|
|
24
|
+
expect(custom).toContain("nvidia")
|
|
25
|
+
expect(custom).toContain("vercel")
|
|
26
|
+
expect(custom).toContain("zenmux")
|
|
27
|
+
expect(custom).toContain("cerebras")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("CLOUD_PROVIDERS contains expected cloud providers", () => {
|
|
31
|
+
expect(cloud).toContain("anthropic")
|
|
32
|
+
expect(cloud).toContain("openai")
|
|
33
|
+
expect(cloud).toContain("xai")
|
|
34
|
+
expect(cloud).toContain("saeeol")
|
|
35
|
+
expect(cloud).toContain("azure")
|
|
36
|
+
expect(cloud).toContain("amazon-bedrock")
|
|
37
|
+
expect(cloud).toContain("google-vertex")
|
|
38
|
+
expect(cloud).toContain("google-vertex-anthropic")
|
|
39
|
+
expect(cloud).toContain("github-copilot")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("no overlap between categories", () => {
|
|
43
|
+
const localSet = new Set(local)
|
|
44
|
+
const customSet = new Set(custom)
|
|
45
|
+
const cloudSet = new Set(cloud)
|
|
46
|
+
|
|
47
|
+
for (const id of localSet) {
|
|
48
|
+
expect(customSet.has(id as string)).toBe(false)
|
|
49
|
+
expect(cloudSet.has(id as string)).toBe(false)
|
|
50
|
+
}
|
|
51
|
+
for (const id of customSet) {
|
|
52
|
+
expect(localSet.has(id as string)).toBe(false)
|
|
53
|
+
expect(cloudSet.has(id as string)).toBe(false)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("all arrays are non-empty", () => {
|
|
58
|
+
expect(local.length).toBeGreaterThan(0)
|
|
59
|
+
expect(custom.length).toBeGreaterThan(0)
|
|
60
|
+
expect(cloud.length).toBeGreaterThan(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("ProviderCategory schema", () => {
|
|
65
|
+
test("ProviderCategory accepts valid values", () => {
|
|
66
|
+
for (const val of ["local", "custom", "cloud"] as const) {
|
|
67
|
+
const result = Schema.decodeSync(ProviderCategory)(val)
|
|
68
|
+
expect(result).toBe(val)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("ProviderCategory rejects invalid values", () => {
|
|
73
|
+
expect(() => Schema.decodeSync(ProviderCategory)("invalid" as any)).toThrow()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("Info schema with category", () => {
|
|
78
|
+
function makeInfo(cat: "local" | "custom" | "cloud") {
|
|
79
|
+
return {
|
|
80
|
+
id: "test" as any,
|
|
81
|
+
name: "Test",
|
|
82
|
+
category: cat,
|
|
83
|
+
source: "config" as const,
|
|
84
|
+
env: [] as string[],
|
|
85
|
+
options: {} as Record<string, any>,
|
|
86
|
+
models: {} as Record<string, any>,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("Info decodes with category field", () => {
|
|
91
|
+
const parsed = Schema.decodeSync(Info)(makeInfo("cloud"))
|
|
92
|
+
expect(parsed.category).toBe("cloud")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("Info rejects missing category", () => {
|
|
96
|
+
const missing = {
|
|
97
|
+
id: "test" as any,
|
|
98
|
+
name: "Test",
|
|
99
|
+
source: "config" as const,
|
|
100
|
+
env: [] as string[],
|
|
101
|
+
options: {} as Record<string, any>,
|
|
102
|
+
models: {} as Record<string, any>,
|
|
103
|
+
}
|
|
104
|
+
expect(() => Schema.decodeSync(Info)(missing as any)).toThrow()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("Info accepts all valid categories", () => {
|
|
108
|
+
for (const cat of ["local", "custom", "cloud"] as const) {
|
|
109
|
+
expect(Schema.decodeSync(Info)(makeInfo(cat)).category).toBe(cat)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("getCategory classification", () => {
|
|
115
|
+
function getCategory(id: string): "local" | "custom" | "cloud" {
|
|
116
|
+
if (local.includes(id)) return "local"
|
|
117
|
+
if (custom.includes(id)) return "custom"
|
|
118
|
+
return "cloud"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("local providers classified correctly", () => {
|
|
122
|
+
expect(getCategory("ollama")).toBe("local")
|
|
123
|
+
expect(getCategory("lmstudio")).toBe("local")
|
|
124
|
+
expect(getCategory("vllm")).toBe("local")
|
|
125
|
+
expect(getCategory("text-generation-webui")).toBe("local")
|
|
126
|
+
expect(getCategory("llama.cpp")).toBe("local")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("custom providers classified correctly", () => {
|
|
130
|
+
expect(getCategory("openrouter")).toBe("custom")
|
|
131
|
+
expect(getCategory("llmgateway")).toBe("custom")
|
|
132
|
+
expect(getCategory("nvidia")).toBe("custom")
|
|
133
|
+
expect(getCategory("vercel")).toBe("custom")
|
|
134
|
+
expect(getCategory("cerebras")).toBe("custom")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("cloud providers classified correctly", () => {
|
|
138
|
+
expect(getCategory("anthropic")).toBe("cloud")
|
|
139
|
+
expect(getCategory("openai")).toBe("cloud")
|
|
140
|
+
expect(getCategory("saeeol")).toBe("cloud")
|
|
141
|
+
expect(getCategory("github-copilot")).toBe("cloud")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("unknown providers default to cloud", () => {
|
|
145
|
+
expect(getCategory("some-new-provider")).toBe("cloud")
|
|
146
|
+
expect(getCategory("future-ai")).toBe("cloud")
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("fromModelsDevProvider assigns category", () => {
|
|
151
|
+
test("fromModelsDevProvider returns custom category", () => {
|
|
152
|
+
const { fromModelsDevProvider } = require("@/provider/provider-schemas")
|
|
153
|
+
const mockProvider = {
|
|
154
|
+
id: "test-provider",
|
|
155
|
+
name: "Test Provider",
|
|
156
|
+
env: [],
|
|
157
|
+
models: {
|
|
158
|
+
"test-model": {
|
|
159
|
+
id: "test-model",
|
|
160
|
+
name: "Test Model",
|
|
161
|
+
limit: { context: 4096, output: 2048 },
|
|
162
|
+
cost: {},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
const result = fromModelsDevProvider(mockProvider)
|
|
167
|
+
expect(result.category).toBe("custom")
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("provider re-exports", () => {
|
|
172
|
+
test("Provider exports ProviderCategory", () => {
|
|
173
|
+
expect(Provider.ProviderCategory).toBeDefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("Provider exports LOCAL_PROVIDERS", () => {
|
|
177
|
+
expect(Provider.LOCAL_PROVIDERS).toBeDefined()
|
|
178
|
+
expect(Provider.LOCAL_PROVIDERS).toContain("ollama")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("Provider exports CUSTOM_PROVIDERS", () => {
|
|
182
|
+
expect(Provider.CUSTOM_PROVIDERS).toBeDefined()
|
|
183
|
+
expect(Provider.CUSTOM_PROVIDERS).toContain("openrouter")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("Provider exports CLOUD_PROVIDERS", () => {
|
|
187
|
+
expect(Provider.CLOUD_PROVIDERS).toBeDefined()
|
|
188
|
+
expect(Provider.CLOUD_PROVIDERS).toContain("anthropic")
|
|
189
|
+
})
|
|
190
|
+
})
|