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.
Files changed (63) hide show
  1. package/npm/bin/saeeol +42 -0
  2. package/npm/package.json +39 -0
  3. package/npm/postinstall.js +162 -0
  4. package/package.json +2 -2
  5. package/src/cli/cmd/mcp-refresh.ts +47 -0
  6. package/src/cli/cmd/mcp.ts +3 -1
  7. package/src/cli/cmd/tui/app-commands-core.tsx +11 -0
  8. package/src/cli/cmd/tui/app-commands-system.tsx +20 -0
  9. package/src/cli/cmd/tui/app-events.ts +43 -0
  10. package/src/cli/cmd/tui/app.tsx +4 -0
  11. package/src/cli/cmd/tui/component/dialog-model.tsx +2 -2
  12. package/src/cli/cmd/tui/component/prompt/use-prompt-memos.ts +1 -1
  13. package/src/cli/cmd/tui/component/use-connected.tsx +1 -1
  14. package/src/cli/cmd/tui/context/local.tsx +10 -3
  15. package/src/cli/cmd/tui/context/route.tsx +5 -1
  16. package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +1 -1
  17. package/src/cli/cmd/tui/plugin/api.tsx +7 -3
  18. package/src/cli/cmd/tui/routes/local-models.tsx +151 -0
  19. package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +1 -1
  20. package/src/cli/cmd/tui/util/model.ts +1 -1
  21. package/src/config/config-schema.ts +44 -0
  22. package/src/ltm/config.ts +124 -0
  23. package/src/ltm/events.ts +50 -0
  24. package/src/ltm/index.ts +12 -0
  25. package/src/ltm/memory/episodic.ts +83 -0
  26. package/src/ltm/memory/procedural.ts +102 -0
  27. package/src/ltm/memory/semantic.ts +80 -0
  28. package/src/ltm/pipeline.ts +155 -0
  29. package/src/ltm/retrieval.ts +62 -0
  30. package/src/ltm/scheduler.ts +55 -0
  31. package/src/ltm/store.ts +150 -0
  32. package/src/ltm/types.ts +108 -0
  33. package/src/mcp/index.ts +32 -1
  34. package/src/provider/custom-loaders.ts +12 -0
  35. package/src/provider/loader-local.ts +185 -0
  36. package/src/provider/local/embedder.ts +220 -0
  37. package/src/provider/local/events.ts +74 -0
  38. package/src/provider/local/gpu.ts +93 -0
  39. package/src/provider/local/hub.ts +174 -0
  40. package/src/provider/local/index.ts +10 -0
  41. package/src/provider/local/model-manager.ts +113 -0
  42. package/src/provider/local/orchestrator.ts +301 -0
  43. package/src/provider/local/rag.ts +112 -0
  44. package/src/provider/local/types.ts +142 -0
  45. package/src/provider/provider-conversion.ts +2 -0
  46. package/src/provider/provider-schema.ts +17 -2
  47. package/src/provider/provider-schemas.ts +10 -3
  48. package/src/provider/provider-state.ts +10 -2
  49. package/src/provider/provider.ts +2 -1
  50. package/src/saeeol/plugins/sidebar-usage.tsx +1 -1
  51. package/src/server/routes/instance/config.ts +1 -1
  52. package/src/server/routes/instance/httpapi/api.ts +2 -0
  53. package/src/server/routes/instance/httpapi/groups/local.ts +87 -0
  54. package/src/server/routes/instance/httpapi/groups/mcp.ts +10 -0
  55. package/src/server/routes/instance/httpapi/handlers/local.ts +95 -0
  56. package/src/server/routes/instance/httpapi/handlers/mcp.ts +5 -0
  57. package/src/server/routes/instance/httpapi/handlers/provider.ts +1 -1
  58. package/src/server/routes/instance/httpapi/server.ts +2 -0
  59. package/src/server/routes/instance/provider.ts +2 -2
  60. package/src/session/prompt-reminders.ts +29 -0
  61. package/test/fake/provider.ts +1 -0
  62. package/test/provider/local.test.ts +208 -0
  63. 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
 
@@ -35,6 +35,7 @@ export namespace ProviderTest {
35
35
  return {
36
36
  id,
37
37
  name: "Test Provider",
38
+ category: "cloud" as const,
38
39
  source: "config",
39
40
  env: [],
40
41
  options: {},
@@ -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
+ })