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
@@ -0,0 +1,112 @@
1
+ /** RAG support — embedding models, rerankers, vector DB management */
2
+
3
+ import { Effect } from "effect"
4
+ import * as Log from "@saeeol/core/util/log"
5
+ import type { RAGAsset, RAGAssetType } from "./types"
6
+ import * as Hub from "./hub"
7
+ import * as Manager from "./model-manager"
8
+
9
+ const log = Log.create({ service: "local/rag" })
10
+
11
+ /** Well-known embedding models with typical dimensions */
12
+ export const EMBEDDING_MODELS: Array<{ id: string; repo: string; name: string; dimensions: number; sizeBytes: number }> = [
13
+ { id: "bge-small-en", repo: "BAAI/bge-small-en-v1.5", name: "BGE Small English", dimensions: 384, sizeBytes: 130_000_000 },
14
+ { id: "bge-base-en", repo: "BAAI/bge-base-en-v1.5", name: "BGE Base English", dimensions: 768, sizeBytes: 420_000_000 },
15
+ { id: "bge-large-en", repo: "BAAI/bge-large-en-v1.5", name: "BGE Large English", dimensions: 1024, sizeBytes: 1_300_000_000 },
16
+ { id: "bge-m3", repo: "BAAI/bge-m3", name: "BGE M3 Multilingual", dimensions: 1024, sizeBytes: 2_200_000_000 },
17
+ { id: "nomic-embed", repo: "nomic-ai/nomic-embed-text-v1.5", name: "Nomic Embed Text", dimensions: 768, sizeBytes: 550_000_000 },
18
+ { id: "all-minilm-l6", repo: "sentence-transformers/all-MiniLM-L6-v2", name: "MiniLM L6", dimensions: 384, sizeBytes: 80_000_000 },
19
+ { id: "gte-small", repo: "Alibaba-NLP/gte-base-en-v1.5", name: "GTE Base", dimensions: 768, sizeBytes: 430_000_000 },
20
+ ]
21
+
22
+ /** Well-known reranker models */
23
+ export const RERANKER_MODELS: Array<{ id: string; repo: string; name: string; sizeBytes: number }> = [
24
+ { id: "bge-reranker-base", repo: "BAAI/bge-reranker-base", name: "BGE Reranker Base", sizeBytes: 420_000_000 },
25
+ { id: "bge-reranker-large", repo: "BAAI/bge-reranker-large", name: "BGE Reranker Large", sizeBytes: 1_300_000_000 },
26
+ { id: "ms-marco-minilm", repo: "cross-encoder/ms-marco-MiniLM-L-6-v2", name: "MS MARCO MiniLM", sizeBytes: 80_000_000 },
27
+ ]
28
+
29
+ /** Build a RAGAsset from a known embedding model */
30
+ export function embeddingAsset(model: typeof EMBEDDING_MODELS[number]): RAGAsset {
31
+ return {
32
+ id: model.id,
33
+ name: model.name,
34
+ type: "embedding",
35
+ repo: model.repo,
36
+ format: "safetensors",
37
+ sizeBytes: model.sizeBytes,
38
+ dimensions: model.dimensions,
39
+ }
40
+ }
41
+
42
+ /** Build a RAGAsset from a known reranker model */
43
+ export function rerankerAsset(model: typeof RERANKER_MODELS[number]): RAGAsset {
44
+ return {
45
+ id: model.id,
46
+ name: model.name,
47
+ type: "reranker",
48
+ repo: model.repo,
49
+ format: "safetensors",
50
+ sizeBytes: model.sizeBytes,
51
+ }
52
+ }
53
+
54
+ /** Install an embedding model for RAG */
55
+ export async function installEmbedding(
56
+ model: typeof EMBEDDING_MODELS[number],
57
+ opts?: { onProgress?: (downloaded: number, total: number) => void; signal?: AbortSignal },
58
+ ): Promise<string> {
59
+ const asset = embeddingAsset(model)
60
+ log.info("installing embedding model", { repo: asset.repo })
61
+
62
+ // Download model config and weights
63
+ const files = ["config.json", "tokenizer.json", "tokenizer_config.json"]
64
+ let lastPath = ""
65
+ for (const file of files) {
66
+ try {
67
+ lastPath = await Manager.installRAG(
68
+ { repo: asset.repo, filename: file, sizeBytes: 0 },
69
+ opts,
70
+ )
71
+ } catch { /* non-critical */ }
72
+ }
73
+
74
+ // Download safetensors weights
75
+ const weightFile = "model.safetensors"
76
+ lastPath = await Manager.installRAG(
77
+ { repo: asset.repo, filename: weightFile, sizeBytes: asset.sizeBytes },
78
+ opts,
79
+ )
80
+
81
+ return lastPath
82
+ }
83
+
84
+ /** Install a reranker model */
85
+ export async function installReranker(
86
+ model: typeof RERANKER_MODELS[number],
87
+ opts?: { onProgress?: (downloaded: number, total: number) => void; signal?: AbortSignal },
88
+ ): Promise<string> {
89
+ const asset = rerankerAsset(model)
90
+ log.info("installing reranker model", { repo: asset.repo })
91
+
92
+ const weightFile = "model.safetensors"
93
+ return Manager.installRAG(
94
+ { repo: asset.repo, filename: weightFile, sizeBytes: asset.sizeBytes },
95
+ opts,
96
+ )
97
+ }
98
+
99
+ /** List recommended RAG assets that fit in available VRAM */
100
+ export async function recommendForVRAM(vramMB: number): Promise<{
101
+ embeddings: typeof EMBEDDING_MODELS
102
+ rerankers: typeof RERANKER_MODELS
103
+ }> {
104
+ const embeddings = EMBEDDING_MODELS.filter((m) => m.sizeBytes * 1.2 <= vramMB * 1024 * 1024)
105
+ const rerankers = RERANKER_MODELS.filter((m) => m.sizeBytes * 1.2 <= vramMB * 1024 * 1024)
106
+ return { embeddings, rerankers }
107
+ }
108
+
109
+ /** Search for RAG-compatible models on HuggingFace */
110
+ export async function search(query?: string, type?: RAGAssetType) {
111
+ return Hub.searchRAG(type ?? "embedding", query, 20)
112
+ }
@@ -0,0 +1,142 @@
1
+ /** Local model orchestration — shared types */
2
+
3
+ import { Schema } from "effect"
4
+ import { optionalOmitUndefined } from "@/util/schema"
5
+
6
+ // ── GPU ──
7
+
8
+ export const GPUInfo = Schema.Struct({
9
+ index: Schema.Number,
10
+ name: Schema.String,
11
+ vramTotalMB: Schema.Number,
12
+ vramUsedMB: Schema.Number,
13
+ vramFreeMB: Schema.Number,
14
+ computeCapability: optionalOmitUndefined(Schema.String),
15
+ driverVersion: optionalOmitUndefined(Schema.String),
16
+ cudaVersion: optionalOmitUndefined(Schema.String),
17
+ })
18
+ export type GPUInfo = Schema.Schema.Type<typeof GPUInfo>
19
+
20
+ export const GPUProfile = Schema.Struct({
21
+ gpus: Schema.Array(GPUInfo),
22
+ totalVRAMMB: Schema.Number,
23
+ availableVRAMMB: Schema.Number,
24
+ cudaAvailable: Schema.Boolean,
25
+ })
26
+ export type GPUProfile = Schema.Schema.Type<typeof GPUProfile>
27
+
28
+ // ── Model Artifact ──
29
+
30
+ export const Quantization = Schema.Literals([
31
+ "q2_k", "q3_k_s", "q3_k_m", "q3_k_l",
32
+ "q4_0", "q4_1", "q4_k_s", "q4_k_m",
33
+ "q5_0", "q5_1", "q5_k_s", "q5_k_m",
34
+ "q6_k", "q8_0", "fp16", "bf16", "fp32",
35
+ ])
36
+ export type Quantization = Schema.Schema.Type<typeof Quantization>
37
+
38
+ export const ModelFormat = Schema.Literals(["gguf", "safetensors", "pytorch", "onnx", "awq", "gptq"])
39
+ export type ModelFormat = Schema.Schema.Type<typeof ModelFormat>
40
+
41
+ export const ModelArtifact = Schema.Struct({
42
+ id: Schema.String,
43
+ repo: Schema.String, // e.g. "meta-llama/Llama-3.1-8B-Instruct"
44
+ filename: Schema.String, // e.g. "model-q4_k_m.gguf"
45
+ format: ModelFormat,
46
+ quantization: Quantization,
47
+ sizeBytes: Schema.Number,
48
+ sha256: optionalOmitUndefined(Schema.String),
49
+ })
50
+ export type ModelArtifact = Schema.Schema.Type<typeof ModelArtifact>
51
+
52
+ // ── Model Instance (running process) ──
53
+
54
+ export const ModelStatus = Schema.Literals(["stopped", "starting", "running", "error", "downloading"])
55
+ export type ModelStatus = Schema.Schema.Type<typeof ModelStatus>
56
+
57
+ export const ModelInstance = Schema.Struct({
58
+ id: Schema.String,
59
+ artifact: ModelArtifact,
60
+ status: ModelStatus,
61
+ pid: optionalOmitUndefined(Schema.Number),
62
+ port: optionalOmitUndefined(Schema.Number),
63
+ gpuIndex: optionalOmitUndefined(Schema.Number),
64
+ vramUsageMB: optionalOmitUndefined(Schema.Number),
65
+ endpoint: optionalOmitUndefined(Schema.String),
66
+ error: optionalOmitUndefined(Schema.String),
67
+ })
68
+ export type ModelInstance = Schema.Schema.Type<typeof ModelInstance>
69
+
70
+ // ── Backend (Ollama, LM Studio, vLLM, etc.) ──
71
+
72
+ export const BackendType = Schema.Literals(["ollama", "lmstudio", "vllm", "llama.cpp", "text-generation-webui"])
73
+ export type BackendType = Schema.Schema.Type<typeof BackendType>
74
+
75
+ export const BackendStatus = Schema.Struct({
76
+ type: BackendType,
77
+ available: Schema.Boolean,
78
+ endpoint: Schema.String,
79
+ version: optionalOmitUndefined(Schema.String),
80
+ loadedModels: Schema.Array(Schema.String),
81
+ })
82
+ export type BackendStatus = Schema.Schema.Type<typeof BackendStatus>
83
+
84
+ // ── RAG / Vector DB ──
85
+
86
+ export const RAGAssetType = Schema.Literals(["embedding", "reranker", "vectordb"])
87
+ export type RAGAssetType = Schema.Schema.Type<typeof RAGAssetType>
88
+
89
+ export const RAGAsset = Schema.Struct({
90
+ id: Schema.String,
91
+ name: Schema.String,
92
+ type: RAGAssetType,
93
+ repo: Schema.String,
94
+ format: ModelFormat,
95
+ sizeBytes: Schema.Number,
96
+ dimensions: optionalOmitUndefined(Schema.Number),
97
+ })
98
+ export type RAGAsset = Schema.Schema.Type<typeof RAGAsset>
99
+
100
+ // ── Download Progress ──
101
+
102
+ export const DownloadState = Schema.Struct({
103
+ id: Schema.String,
104
+ bytesDownloaded: Schema.Number,
105
+ bytesTotal: Schema.Number,
106
+ speedMBps: Schema.Finite,
107
+ eta: optionalOmitUndefined(Schema.Number),
108
+ done: Schema.Boolean,
109
+ error: optionalOmitUndefined(Schema.String),
110
+ })
111
+ export type DownloadState = Schema.Schema.Type<typeof DownloadState>
112
+
113
+ // ── HuggingFace Hub ──
114
+
115
+ export const HFModelSearch = Schema.Struct({
116
+ id: Schema.String,
117
+ name: Schema.String,
118
+ author: Schema.String,
119
+ downloads: Schema.Number,
120
+ likes: Schema.Number,
121
+ tags: Schema.Array(Schema.String),
122
+ pipelineTag: optionalOmitUndefined(Schema.String),
123
+ libraryName: optionalOmitUndefined(Schema.String),
124
+ })
125
+ export type HFModelSearch = Schema.Schema.Type<typeof HFModelSearch>
126
+
127
+ export const HFSibling = Schema.Struct({
128
+ rfilename: Schema.String,
129
+ })
130
+ export type HFSibling = Schema.Schema.Type<typeof HFSibling>
131
+
132
+ export const HFModelInfo = Schema.Struct({
133
+ id: Schema.String,
134
+ modelId: Schema.String,
135
+ sha: Schema.String,
136
+ siblings: Schema.Array(HFSibling),
137
+ tags: Schema.Array(Schema.String),
138
+ downloads: Schema.Number,
139
+ likes: Schema.Number,
140
+ private: Schema.Boolean,
141
+ })
142
+ export type HFModelInfo = Schema.Schema.Type<typeof HFModelInfo>
@@ -115,6 +115,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
115
115
  id: ProviderID.make(provider.id),
116
116
  source: "custom",
117
117
  name: provider.name,
118
+ category: "custom" as const,
118
119
  env: [...(provider.env ?? [])],
119
120
  options: {},
120
121
  models,
@@ -135,6 +136,7 @@ export function applyConfigModels(
135
136
  env: provider.env ?? existing?.env ?? [],
136
137
  options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
137
138
  source: "config",
139
+ category: existing?.category ?? "cloud",
138
140
  models: existing?.models ?? {},
139
141
  }
140
142
 
@@ -79,17 +79,22 @@ export const Model = Schema.Struct({
79
79
  .pipe(withStatics((s) => ({ zod: zod(s) })))
80
80
  export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
81
81
 
82
+ // Provider categories
83
+ export const ProviderCategory = Schema.Literals(["local", "custom", "cloud"])
84
+ export type ProviderCategory = typeof ProviderCategory.Type
85
+
82
86
  export const Info = Schema.Struct({
83
87
  id: ProviderID,
84
88
  name: Schema.String,
89
+ category: ProviderCategory,
85
90
  source: Schema.Literals(["env", "config", "custom", "api"]),
86
91
  env: Schema.Array(Schema.String),
87
92
  key: optionalOmitUndefined(Schema.String),
88
93
  options: Schema.Record(Schema.String, Schema.Any),
89
94
  models: Schema.Record(Schema.String, Model),
90
95
  })
91
- .annotate({ identifier: "Provider" })
92
- .pipe(withStatics((s) => ({ zod: zod(s) })))
96
+ .annotate({ identifier: "Provider" })
97
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
93
98
  export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
94
99
 
95
100
  const DefaultModelIDs = Schema.Record(Schema.String, Schema.String)
@@ -99,11 +104,21 @@ export const ListResult = Schema.Struct({
99
104
  default: DefaultModelIDs,
100
105
  connected: Schema.Array(Schema.String),
101
106
  failed: Schema.Array(Schema.String),
107
+ categories: Schema.Struct({
108
+ local: Schema.Array(Schema.String),
109
+ custom: Schema.Array(Schema.String),
110
+ cloud: Schema.Array(Schema.String),
111
+ }),
102
112
  }).pipe(withStatics((s) => ({ zod: zod(s) })))
103
113
  export type ListResult = Types.DeepMutable<Schema.Schema.Type<typeof ListResult>>
104
114
 
105
115
  export const ConfigProvidersResult = Schema.Struct({
106
116
  providers: Schema.Array(Info),
107
117
  default: DefaultModelIDs,
118
+ categories: Schema.Struct({
119
+ local: Schema.Array(Schema.String),
120
+ custom: Schema.Array(Schema.String),
121
+ cloud: Schema.Array(Schema.String),
122
+ }),
108
123
  }).pipe(withStatics((s) => ({ zod: zod(s) })))
109
124
  export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
@@ -25,8 +25,11 @@ export const Model = Schema.Struct({
25
25
  }).annotate({ identifier: "Model" }).pipe(withStatics((s) => ({ zod: zod(s) })))
26
26
  export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
27
27
 
28
+ export const ProviderCategory = Schema.Literals(["local", "custom", "cloud"])
29
+ export type ProviderCategory = typeof ProviderCategory.Type
30
+
28
31
  export const Info = Schema.Struct({
29
- id: ProviderID, name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String),
32
+ id: ProviderID, name: Schema.String, category: ProviderCategory, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String),
30
33
  key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model),
31
34
  }).annotate({ identifier: "Provider" }).pipe(withStatics((s) => ({ zod: zod(s) })))
32
35
  export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -38,7 +41,11 @@ export const ConfigProvidersResult = Schema.Struct({ providers: Schema.Array(Inf
38
41
  export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
39
42
 
40
43
  export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
41
- return mapValues(providers, (item) => sortModels(Object.values(item.models))[0].id)
44
+ return mapValues(providers, (item) => {
45
+ const models = Object.values(item.models ?? {})
46
+ if (models.length === 0) return undefined
47
+ return sortModels(models)[0].id
48
+ })
42
49
  }
43
50
 
44
51
  function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
@@ -79,7 +86,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
79
86
  }
80
87
  }
81
88
  }
82
- return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, env: [...(provider.env ?? [])], options: {}, models }
89
+ return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, category: "custom" as const, env: [...(provider.env ?? [])], options: {}, models }
83
90
  }
84
91
 
85
92
  const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
@@ -4,10 +4,11 @@ import * as Log from "@saeeol/core/util/log"
4
4
  import { EffectBridge } from "@/effect/bridge"
5
5
  import { InstanceState } from "@/effect/instance-state"
6
6
  import { applyConfigModels, cleanupProviders, fromModelsDevProvider } from "./provider-conversion"
7
- import { custom } from "./custom-loaders"
7
+ import { custom, LOCAL_PROVIDERS, CUSTOM_PROVIDERS, CLOUD_PROVIDERS } from "./custom-loaders"
8
8
  import { ModelID, ProviderID } from "./schema"
9
9
  import type { State, CustomModelLoader, CustomVarsLoader, CustomDiscoverModels } from "./provider-types"
10
- import type { Info } from "./provider-schema"
10
+ import type { Info } from "./provider-schemas"
11
+ import type { ProviderCategory } from "./provider-schemas"
11
12
  import {
12
13
  saeeolCustomLoaders,
13
14
  patchCustomLoaderResult,
@@ -53,8 +54,15 @@ export function initState(deps: {
53
54
 
54
55
  log.info("init")
55
56
 
57
+ function getCategory(id: string): ProviderCategory {
58
+ if (LOCAL_PROVIDERS.includes(id as any)) return "local"
59
+ if (CUSTOM_PROVIDERS.includes(id as any)) return "custom"
60
+ return "cloud"
61
+ }
62
+
56
63
  function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
57
64
  const existing = providers[providerID]
65
+ if (!provider.category) provider.category = getCategory(providerID)
58
66
  if (existing) {
59
67
  // @ts-expect-error
60
68
  providers[providerID] = mergeDeep(existing, provider)
@@ -31,6 +31,7 @@ export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError",
31
31
  export const InitError = namedSchemaError("ProviderInitError", { providerID: ProviderID })
32
32
 
33
33
  export { defaultLayer, list, getModelExport as getModel, getLanguage, getSmallModel, defaultModel } from "./provider-layer"
34
- export { fromModelsDevProvider } from "./provider-schemas"
34
+ export { fromModelsDevProvider, ProviderCategory } from "./provider-schemas"
35
+ export { LOCAL_PROVIDERS, CUSTOM_PROVIDERS, CLOUD_PROVIDERS } from "./custom-loaders"
35
36
 
36
37
  export * as Provider from "./provider"
@@ -22,7 +22,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
22
22
  const last = messages.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
23
23
  if (!last) return null
24
24
  const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
25
- const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
25
+ const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models?.[last.modelID]
26
26
  const limit = model?.limit.context ?? 0
27
27
  if (!limit) return { pct: 0, label: "", color: theme().textMuted }
28
28
  const pct = Math.round((tokens / limit) * 100)
@@ -115,7 +115,7 @@ export const ConfigRoutes = lazy(() =>
115
115
  const token = saeeolAuth?.type === "oauth" ? saeeolAuth.access : saeeolAuth?.key
116
116
  const organizationId = saeeolAuth?.type === "oauth" ? saeeolAuth.accountId : undefined
117
117
  const saeeolApiDefault = yield* Effect.promise(() => fetchDefaultModel(token, organizationId))
118
- if (saeeolApiDefault && providers[ProviderID.saeeol]?.models[saeeolApiDefault]) {
118
+ if (saeeolApiDefault && providers[ProviderID.saeeol]?.models?.[saeeolApiDefault]) {
119
119
  defaults[ProviderID.saeeol] = ModelID.make(saeeolApiDefault)
120
120
  }
121
121
  }
@@ -2,6 +2,7 @@ import { Schema } from "effect"
2
2
  import { HttpApi } from "effect/unstable/httpapi"
3
3
  import { BusEvent } from "@/bus/bus-event"
4
4
  import { SyncEvent } from "@/sync"
5
+ import { LocalApi } from "./groups/local"
5
6
  import { ConfigApi } from "./groups/config"
6
7
  import { ControlApi } from "./groups/control"
7
8
  import { EventApi } from "./event"
@@ -34,6 +35,7 @@ export const InstanceHttpApi = HttpApi.make("saeeol-instance")
34
35
  .addHttpApi(ExperimentalApi)
35
36
  .addHttpApi(FileApi)
36
37
  .addHttpApi(InstanceApi)
38
+ .addHttpApi(LocalApi)
37
39
  .addHttpApi(McpApi)
38
40
  .addHttpApi(ProjectApi)
39
41
  .addHttpApi(PtyApi)
@@ -0,0 +1,87 @@
1
+ import { Schema } from "effect"
2
+ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
3
+ import { Authorization } from "../middleware/authorization"
4
+ import { InstanceContextMiddleware } from "../middleware/instance-context"
5
+ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
6
+ import { described } from "./metadata"
7
+ import {
8
+ GPUProfile, BackendStatus, ModelInstance,
9
+ RAGAsset, HFModelSearch,
10
+ } from "@/provider/local/types"
11
+
12
+ const root = "/local"
13
+
14
+ // Request schemas
15
+ const InstallPayload = Schema.Struct({
16
+ repo: Schema.String,
17
+ filename: Schema.String,
18
+ format: Schema.String,
19
+ quantization: Schema.String,
20
+ sizeBytes: Schema.Number,
21
+ sha256: Schema.optional(Schema.String),
22
+ })
23
+ const LoadPayload = Schema.Struct({
24
+ repo: Schema.String,
25
+ filename: Schema.String,
26
+ backend: Schema.optional(Schema.String),
27
+ gpuIndex: Schema.optional(Schema.Number),
28
+ })
29
+ const UnloadPayload = Schema.Struct({ instanceId: Schema.String })
30
+ const InstallRAGPayload = Schema.Struct({ id: Schema.String, repo: Schema.String, type: Schema.String })
31
+ const InstalledModel = Schema.Struct({ repo: Schema.String, filename: Schema.String, path: Schema.String, sizeBytes: Schema.Number })
32
+ const RAGRecommendResult = Schema.Struct({ embeddings: Schema.Array(RAGAsset), rerankers: Schema.Array(RAGAsset) })
33
+
34
+ export const LocalApi = HttpApi.make("local")
35
+ .add(
36
+ HttpApiGroup.make("local")
37
+ .add(
38
+ HttpApiEndpoint.get("gpu", `${root}/gpu`, {
39
+ success: described(GPUProfile, "GPU profile with VRAM info"),
40
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.gpu", summary: "Get GPU profile" })),
41
+ HttpApiEndpoint.get("backends", `${root}/backends`, {
42
+ success: described(Schema.Array(BackendStatus), "Detected local backends"),
43
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.backends", summary: "List detected backends" })),
44
+ HttpApiEndpoint.get("models", `${root}/models`, {
45
+ success: described(Schema.Array(InstalledModel), "Installed local models"),
46
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.models", summary: "List installed models" })),
47
+ HttpApiEndpoint.get("instances", `${root}/instances`, {
48
+ success: described(Schema.Array(ModelInstance), "Running model instances"),
49
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.instances", summary: "List running instances" })),
50
+ HttpApiEndpoint.get("hub_search", `${root}/hub/search`, {
51
+ requestSearchParams: Schema.Struct({ q: Schema.String, limit: Schema.String }),
52
+ success: described(Schema.Array(HFModelSearch), "Search results from HuggingFace Hub"),
53
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.hub.search", summary: "Search HuggingFace Hub" })),
54
+ HttpApiEndpoint.get("rag_recommend", `${root}/rag/recommend`, {
55
+ requestSearchParams: Schema.Struct({ vramMB: Schema.String }),
56
+ success: described(RAGRecommendResult, "RAG recommendations"),
57
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.rag.recommend", summary: "Get RAG model recommendations" })),
58
+ HttpApiEndpoint.post("install", `${root}/models/install`, {
59
+ payload: InstallPayload,
60
+ success: described(Schema.String, "Installed file path"),
61
+ error: HttpApiError.BadRequest,
62
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.install", summary: "Install model from HuggingFace" })),
63
+ HttpApiEndpoint.post("uninstall", `${root}/models/uninstall`, {
64
+ payload: InstallPayload,
65
+ success: described(Schema.Boolean, "Success"),
66
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.uninstall", summary: "Uninstall a local model" })),
67
+ HttpApiEndpoint.post("load", `${root}/instances/load`, {
68
+ payload: LoadPayload,
69
+ success: described(ModelInstance, "Started model instance"),
70
+ error: HttpApiError.BadRequest,
71
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.load", summary: "Load and start a model" })),
72
+ HttpApiEndpoint.post("unload", `${root}/instances/unload`, {
73
+ payload: UnloadPayload,
74
+ success: described(Schema.Boolean, "Success"),
75
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.unload", summary: "Stop a running model" })),
76
+ HttpApiEndpoint.post("rag_install", `${root}/rag/install`, {
77
+ payload: InstallRAGPayload,
78
+ success: described(Schema.String, "Installed path"),
79
+ error: HttpApiError.BadRequest,
80
+ }).annotateMerge(OpenApi.annotations({ identifier: "local.rag.install", summary: "Install RAG asset" })),
81
+ )
82
+ .annotateMerge(OpenApi.annotations({ title: "local", description: "Local model management API" }))
83
+ .middleware(InstanceContextMiddleware)
84
+ .middleware(WorkspaceRoutingMiddleware)
85
+ .middleware(Authorization),
86
+ )
87
+ .annotateMerge(OpenApi.annotations({ title: "saeeol local models", version: "0.0.1" }))
@@ -30,6 +30,7 @@ export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthErr
30
30
 
31
31
  export const McpPaths = {
32
32
  status: "/mcp",
33
+ refresh: "/mcp/refresh",
33
34
  auth: "/mcp/:name/auth",
34
35
  authCallback: "/mcp/:name/auth/callback",
35
36
  authAuthenticate: "/mcp/:name/auth/authenticate",
@@ -125,6 +126,15 @@ export const McpApi = HttpApi.make("mcp")
125
126
  description: "Disconnect an MCP server.",
126
127
  }),
127
128
  ),
129
+ HttpApiEndpoint.post("refresh", McpPaths.refresh, {
130
+ success: described(StatusMap, "MCP servers refreshed"),
131
+ }).annotateMerge(
132
+ OpenApi.annotations({
133
+ identifier: "mcp.refresh",
134
+ summary: "Refresh MCP servers",
135
+ description: "Re-read config and reconnect all MCP servers.",
136
+ }),
137
+ ),
128
138
  )
129
139
  .annotateMerge(
130
140
  OpenApi.annotations({
@@ -0,0 +1,95 @@
1
+ import { Effect } from "effect"
2
+ import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
3
+ import { InstanceHttpApi } from "../api"
4
+ import * as GPU from "@/provider/local/gpu"
5
+ import * as Hub from "@/provider/local/hub"
6
+ import * as Manager from "@/provider/local/model-manager"
7
+ import * as Orchestrator from "@/provider/local/orchestrator"
8
+ import * as RAG from "@/provider/local/rag"
9
+ import type { ModelArtifact } from "@/provider/local/types"
10
+
11
+ function toArtifact(payload: { repo: string; filename: string; format: string; quantization: string; sizeBytes: number; sha256?: string }): ModelArtifact {
12
+ return Hub.buildArtifact(payload.repo, payload.filename, payload.sizeBytes, payload.sha256)
13
+ }
14
+
15
+ export const localHandlers = HttpApiBuilder.group(InstanceHttpApi, "local", (handlers) =>
16
+ Effect.gen(function* () {
17
+ const gpu = Effect.fn("LocalHttpApi.gpu")(function* () {
18
+ return yield* GPU.profile
19
+ })
20
+
21
+ const backends = Effect.fn("LocalHttpApi.backends")(function* () {
22
+ return yield* Effect.promise(() => Orchestrator.detectBackends())
23
+ })
24
+
25
+ const models = Effect.fn("LocalHttpApi.models")(function* () {
26
+ return yield* Effect.promise(() => Manager.list())
27
+ })
28
+
29
+ const instances = Effect.fn("LocalHttpApi.instances")(function* () {
30
+ return Orchestrator.runningInstances()
31
+ })
32
+
33
+ const hubSearch = Effect.fn("LocalHttpApi.hubSearch")(function* (ctx: { requestSearchParams: { q: string; limit?: string } }) {
34
+ return yield* Effect.promise(() => Hub.search(ctx.requestSearchParams.q, { limit: Number(ctx.requestSearchParams.limit ?? "20") }))
35
+ })
36
+
37
+ const ragRecommend = Effect.fn("LocalHttpApi.ragRecommend")(function* (ctx: { requestSearchParams: { vramMB: string } }) {
38
+ return yield* Effect.promise(() => RAG.recommendForVRAM(Number(ctx.requestSearchParams.vramMB)))
39
+ })
40
+
41
+ const install = Effect.fn("LocalHttpApi.install")(function* (ctx: { payload: { repo: string; filename: string; format: string; quantization: string; sizeBytes: number; sha256?: string } }) {
42
+ const artifact = toArtifact(ctx.payload)
43
+ return yield* Effect.promise(() => Manager.install(artifact))
44
+ })
45
+
46
+ const uninstall = Effect.fn("LocalHttpApi.uninstall")(function* (ctx: { payload: { repo: string; filename: string; format: string; quantization: string; sizeBytes: number; sha256?: string } }) {
47
+ const artifact = toArtifact(ctx.payload)
48
+ yield* Effect.promise(() => Manager.uninstall(artifact))
49
+ return true
50
+ })
51
+
52
+ const load = Effect.fn("LocalHttpApi.load")(function* (ctx: { payload: { repo: string; filename: string; backend?: string; gpuIndex?: number } }) {
53
+ // Find or create artifact from installed models
54
+ const installed = yield* Effect.promise(() => Manager.list())
55
+ const match = installed.find((m) => m.repo === ctx.payload.repo && m.filename === ctx.payload.filename)
56
+ if (!match) {
57
+ return yield* Effect.fail(new HttpApiError.BadRequest({}))
58
+ }
59
+ const artifact = Hub.buildArtifact(ctx.payload.repo, ctx.payload.filename, match.sizeBytes)
60
+ return yield* Effect.promise(() => Orchestrator.load(artifact, { backend: ctx.payload.backend as any, gpuIndex: ctx.payload.gpuIndex }))
61
+ })
62
+
63
+ const unload = Effect.fn("LocalHttpApi.unload")(function* (ctx: { payload: { instanceId: string } }) {
64
+ yield* Effect.promise(() => Orchestrator.unload(ctx.payload.instanceId))
65
+ return true
66
+ })
67
+
68
+ const ragInstall = Effect.fn("LocalHttpApi.ragInstall")(function* (ctx: { payload: { id: string; repo: string; type: string } }) {
69
+ const allEmbeddings = RAG.EMBEDDING_MODELS
70
+ const allRerankers = RAG.RERANKER_MODELS
71
+ const emb = allEmbeddings.find((m) => m.id === ctx.payload.id)
72
+ if (emb) {
73
+ return yield* Effect.promise(() => RAG.installEmbedding(emb))
74
+ }
75
+ const reranker = allRerankers.find((m) => m.id === ctx.payload.id)
76
+ if (reranker) {
77
+ return yield* Effect.promise(() => RAG.installReranker(reranker))
78
+ }
79
+ return yield* Effect.fail(new HttpApiError.BadRequest({}))
80
+ })
81
+
82
+ return handlers
83
+ .handle("gpu", gpu)
84
+ .handle("backends", backends)
85
+ .handle("models", models)
86
+ .handle("instances", instances)
87
+ .handle("hub_search", hubSearch)
88
+ .handle("rag_recommend", ragRecommend)
89
+ .handle("install", install)
90
+ .handle("uninstall", uninstall)
91
+ .handle("load", load)
92
+ .handle("unload", unload)
93
+ .handle("rag_install", ragInstall)
94
+ }),
95
+ )
@@ -55,6 +55,10 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
55
55
  return true
56
56
  })
57
57
 
58
+ const refresh = Effect.fn("McpHttpApi.refresh")(function* () {
59
+ return yield* mcp.refresh()
60
+ })
61
+
58
62
  return handlers
59
63
  .handle("status", status)
60
64
  .handle("add", add)
@@ -64,5 +68,6 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
64
68
  .handle("authRemove", authRemove)
65
69
  .handle("connect", connect)
66
70
  .handle("disconnect", disconnect)
71
+ .handle("refresh", refresh)
67
72
  }),
68
73
  )
@@ -40,7 +40,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
40
40
  )
41
41
  return {
42
42
  all: Object.values(validProviders),
43
- default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)),
43
+ default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models ?? {}).length > 0)),
44
44
  connected: Object.keys(connected),
45
45
  failed,
46
46
  }