create-oke 0.6.1 → 0.8.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.
Files changed (74) hide show
  1. package/README.md +23 -8
  2. package/package.json +3 -3
  3. package/src/ai-setup/apply.ts +214 -0
  4. package/src/ai-setup/catalog.ts +263 -0
  5. package/src/ai-setup/detect-ollama.ts +180 -0
  6. package/src/ai-setup/from-pref.ts +99 -0
  7. package/src/ai-setup/prompts.ts +649 -0
  8. package/src/ai-setup/recommend.ts +203 -0
  9. package/src/cli.test.ts +385 -28
  10. package/src/cli.ts +332 -29
  11. package/src/create-defaults.test.ts +92 -0
  12. package/src/create-defaults.ts +246 -0
  13. package/src/customize-flow.test.ts +82 -0
  14. package/src/customize-flow.ts +432 -0
  15. package/src/drivers-catalog.ts +226 -0
  16. package/src/scaffold.ts +97 -10
  17. package/src/templates.ts +14 -9
  18. package/src/transform.test.ts +105 -0
  19. package/src/transform.ts +227 -0
  20. package/src/wizard-select.ts +50 -0
  21. package/{template → templates/advanced}/.env.example +1 -0
  22. package/templates/advanced/README.md +39 -0
  23. package/templates/advanced/oke.config.ts +81 -0
  24. package/templates/advanced/package.json +18 -0
  25. package/templates/advanced/src/app.ts +30 -0
  26. package/templates/advanced/src/channels.ts +13 -0
  27. package/templates/advanced/src/core.ts +8 -0
  28. package/{template → templates/advanced}/src/flows/main/index.ts +9 -6
  29. package/templates/advanced/src/flows/main/shapes.ts +1 -0
  30. package/templates/advanced/src/flows/main/signals.ts +1 -0
  31. package/templates/advanced/src/flows/notes/index.ts +224 -0
  32. package/templates/advanced/src/flows/notes/shapes.ts +52 -0
  33. package/templates/advanced/src/flows/notes/signals.ts +13 -0
  34. package/templates/advanced/src/gates.ts +12 -0
  35. package/templates/advanced/src/locales/ar.ts +18 -0
  36. package/templates/advanced/src/locales/en.ts +22 -0
  37. package/templates/advanced/src/schema.decl.ts +13 -0
  38. package/templates/advanced/src/seed/index.ts +67 -0
  39. package/templates/advanced/src/vault.ts +7 -0
  40. package/templates/advanced/tests/advanced.test.ts +42 -0
  41. package/templates/standard/.env.example +94 -0
  42. package/templates/standard/README.md +48 -0
  43. package/templates/standard/drizzle.config.ts +25 -0
  44. package/{template → templates/standard}/oke.config.ts +14 -7
  45. package/{template → templates/standard}/package.json +1 -1
  46. package/templates/standard/src/app.ts +30 -0
  47. package/templates/standard/src/channels.ts +13 -0
  48. package/{template → templates/standard}/src/core.ts +1 -0
  49. package/templates/standard/src/flows/main/index.ts +34 -0
  50. package/templates/standard/src/flows/main/shapes.ts +1 -0
  51. package/templates/standard/src/flows/main/signals.ts +1 -0
  52. package/templates/standard/src/flows/notes/index.ts +143 -0
  53. package/templates/standard/src/flows/notes/shapes.ts +26 -0
  54. package/templates/standard/src/flows/notes/signals.ts +13 -0
  55. package/templates/standard/src/gates.ts +12 -0
  56. package/templates/standard/src/locales/ar.ts +18 -0
  57. package/templates/standard/src/locales/en.ts +22 -0
  58. package/templates/standard/src/schema.decl.ts +13 -0
  59. package/templates/standard/src/seed/index.ts +56 -0
  60. package/templates/standard/src/vault.ts +7 -0
  61. package/templates/standard/tests/standard.test.ts +51 -0
  62. package/template/README.md +0 -77
  63. package/template/bun.lock +0 -845
  64. package/template/src/app.ts +0 -18
  65. package/template/src/channels.ts +0 -4
  66. package/template/src/flows/main/shapes.ts +0 -2
  67. package/template/src/flows/main/signals.ts +0 -4
  68. package/template/src/gates.ts +0 -4
  69. package/template/src/locales/ar.ts +0 -20
  70. package/template/src/locales/en.ts +0 -26
  71. package/template/src/schema.decl.ts +0 -12
  72. package/template/src/vault.ts +0 -4
  73. package/template/tests/standard.test.ts +0 -18
  74. /package/{template → templates/advanced}/drizzle.config.ts +0 -0
package/README.md CHANGED
@@ -8,8 +8,21 @@ cd my-app
8
8
  oke dev
9
9
  ```
10
10
 
11
- On a TTY, create-oke opens a Clack wizard to confirm the name and optionally
12
- install dependencies and start `oke dev`. Pass `--yes` for non-interactive use.
11
+ On a TTY, create-oke opens a Clack wizard:
12
+
13
+ 1. Project name
14
+ 2. **Starter** — `standard` (local-first Notes) or `advanced` (same domain,
15
+ docker-ready + files/digest/AI hooks)
16
+ 3. **Recommended defaults** · **Customize** (pick **local** or **docker** first,
17
+ walk facets for that side, then optionally customize the other; saved to
18
+ user-global `~/.oke/create-defaults.json`) · **Reuse previous settings**
19
+ (only when saved settings exist for the selected template).
20
+ The project-name step rejects a path that already exists and is not empty.
21
+ Customize steps include **← Back**. Scaffold writes `.oke/mode` from the
22
+ primary side (recommended: standard → `local`, advanced → `docker`).
23
+ 4. Install dependencies and start `oke dev`?
24
+
25
+ Pass `--yes` for non-interactive recommended defaults (zero prompts).
13
26
 
14
27
  | Port | What |
15
28
  | ------- | ------------------- |
@@ -17,21 +30,23 @@ install dependencies and start `oke dev`. Pass `--yes` for non-interactive use.
17
30
  | `:6533` | Console |
18
31
  | `:6535` | MCP (live Manifest) |
19
32
 
20
- The single bundled template is **standard**: the full recommended file layout
21
- with empty scaffolding.
22
-
23
33
  ```bash
24
34
  bunx create-oke@latest my-app --template standard
35
+ bunx create-oke@latest my-app --template advanced
25
36
  bunx create-oke@latest my-app --sql postgres
26
37
  bunx create-oke@latest my-app --yes
38
+ bunx create-oke@latest my-app --ai # force AI model wizard before install
39
+ bunx create-oke@latest my-app --no-ai
27
40
  ```
28
41
 
29
42
  `--sql sqlite|postgres` is opt-in. The default keeps the dual-mode Store
30
43
  config (`local: sqlite` · `docker`/`prod: postgres`) and abstract
31
44
  `src/schema.decl.ts` (dialect emitted from the active driver). Pass
32
45
  `--sql postgres` to pin `oke.config.ts` `store.sql` local/docker/prod to
33
- `postgres`. The interactive wizard does not ask and keeps the same dual-mode
34
- default.
46
+ `postgres`. Customize can set drivers the template exposes; when AI is enabled,
47
+ model selection runs in the wizard **before** `bun install` (writes `.env.local`
48
+
49
+ - `src/ai.ts`).
35
50
 
36
51
  Store-bearing scaffolds ship `drizzle.config.ts` and `.env.example`. Local
37
52
  `oke dev` auto-runs `oke db push` on schema change; use `oke mode docker` /
@@ -47,4 +62,4 @@ Every scaffold writes an `AGENTS.md` so coding agents know the OKE contract
47
62
  | npm (CLI) | [`create-oke`](https://www.npmjs.com/package/create-oke) — use this with `bunx` |
48
63
  | JSR | [`@omqkhafi/create-oke`](https://jsr.io/@omqkhafi/create-oke) — same sources; prefer npm for the binary |
49
64
 
50
- Requires **Bun ≥ 1.3**. Docs: [oke.omqkhafi.dev](https://oke.omqkhafi.dev) · [handbook](https://oke.omqkhafi.dev/docs).
65
+ Requires **Bun ≥ 1.3.14**. Docs: [oke.omqkhafi.dev](https://oke.omqkhafi.dev) · [handbook](https://oke.omqkhafi.dev/docs).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-oke",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Scaffold an okengine app — bunx create-oke@latest <name>",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "src",
16
- "template"
16
+ "templates"
17
17
  ],
18
18
  "type": "module",
19
19
  "sideEffects": false,
@@ -33,6 +33,6 @@
33
33
  "typescript": "^7.0.2"
34
34
  },
35
35
  "engines": {
36
- "bun": ">=1.3"
36
+ "bun": ">=1.3.14"
37
37
  }
38
38
  }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Write AI driver config, env, and `src/ai.ts` for `oke ai setup`.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ /** Choices applied to the project. */
9
+ export type AiSetupApplyInput = {
10
+ readonly driver: "ollama" | "anthropic" | "openai-compatible" | "mock";
11
+ readonly baseUrl?: string;
12
+ readonly chatModel?: string;
13
+ readonly visionModel?: string | null;
14
+ readonly embedModel?: string | null;
15
+ readonly apiKeyEnv?: string;
16
+ };
17
+
18
+ /** Options for {@link applyAiSetup}. */
19
+ export type AiSetupApplyOptions = {
20
+ /**
21
+ * When false, leave `drivers.ai` / `images.ai` alone (create-oke already
22
+ * wrote per-env pins). Default true for standalone `oke ai setup`.
23
+ */
24
+ readonly updateDrivers?: boolean;
25
+ };
26
+
27
+ /**
28
+ * Apply AI setup to a project root.
29
+ *
30
+ * @param cwd - Project root
31
+ * @param input - Selected provider / models
32
+ * @param options - Pin-preserving knobs for create-oke
33
+ */
34
+ export function applyAiSetup(
35
+ cwd: string,
36
+ input: AiSetupApplyInput,
37
+ options: AiSetupApplyOptions = {},
38
+ ): {
39
+ readonly configPath: string;
40
+ readonly envPath: string;
41
+ readonly aiTsPath: string;
42
+ } {
43
+ const updateDrivers = options.updateDrivers !== false;
44
+ const configPath = join(cwd, "oke.config.ts");
45
+ if (!existsSync(configPath)) {
46
+ throw new Error("oke ai setup: no oke.config.ts in the current directory");
47
+ }
48
+
49
+ if (updateDrivers) {
50
+ let config = readFileSync(configPath, "utf8");
51
+ config = upsertAiDrivers(config, input.driver);
52
+ if (input.driver === "ollama") {
53
+ config = upsertImage(config, "ai", "ollama/ollama:latest");
54
+ }
55
+ writeFileSync(configPath, config, "utf8");
56
+ }
57
+
58
+ const envPath = join(cwd, ".env.local");
59
+ const envExample = join(cwd, ".env.example");
60
+ let env = existsSync(envPath)
61
+ ? readFileSync(envPath, "utf8")
62
+ : existsSync(envExample)
63
+ ? readFileSync(envExample, "utf8")
64
+ : "";
65
+ env = upsertEnv(env, "OKE_AI_DRIVER", input.driver);
66
+ if (input.baseUrl) env = upsertEnv(env, "OKE_AI_URL", input.baseUrl);
67
+ if (input.chatModel) env = upsertEnv(env, "OKE_AI_MODEL", input.chatModel);
68
+ if (input.visionModel) env = upsertEnv(env, "OKE_AI_VISION_MODEL", input.visionModel);
69
+ if (input.embedModel) env = upsertEnv(env, "OKE_AI_EMBED_MODEL", input.embedModel);
70
+ if (input.apiKeyEnv) {
71
+ // Ensure the key name is present (value left blank for the user)
72
+ if (!new RegExp(`^${input.apiKeyEnv}=`, "m").test(env)) {
73
+ env = `${env.trimEnd()}\n${input.apiKeyEnv}=\n`;
74
+ }
75
+ }
76
+ writeFileSync(envPath, env.endsWith("\n") ? env : `${env}\n`, "utf8");
77
+
78
+ const aiTsPath = join(cwd, "src", "ai.ts");
79
+ mkdirSync(dirname(aiTsPath), { recursive: true });
80
+ writeFileSync(aiTsPath, renderAiTs(input), "utf8");
81
+ ensureAiImported(cwd);
82
+
83
+ return { configPath, envPath, aiTsPath };
84
+ }
85
+
86
+ /**
87
+ * @param source - oke.config.ts
88
+ * @param driver - AI driver id
89
+ */
90
+ export function upsertAiDrivers(source: string, driver: string): string {
91
+ const block = `{
92
+ local: "${driver}",
93
+ docker: "${driver === "mock" ? "mock" : driver}",
94
+ test: "mock",
95
+ prod: "${driver === "mock" ? "mock" : driver}",
96
+ }`;
97
+ // Top-level drivers.ai only (4-space indent) — never nest under channel.email.
98
+ const aiRe = /^( {4})ai:\s*\{[\s\S]*?\n\1\}/m;
99
+ if (aiRe.test(source)) {
100
+ return source.replace(aiRe, `$1ai: ${block}`);
101
+ }
102
+ const channelRe = /^( {4})channel:\s*\{[\s\S]*?\n\1\},?\n/m;
103
+ if (channelRe.test(source)) {
104
+ return source.replace(channelRe, (match) => `${match} ai: ${block},\n`);
105
+ }
106
+ const driversClose = /(drivers:\s*\{[\s\S]*?)(\n\s*\},)/;
107
+ if (!driversClose.test(source)) {
108
+ throw new Error("oke ai setup: could not find drivers block in oke.config.ts");
109
+ }
110
+ return source.replace(driversClose, `$1\n ai: ${block},$2`);
111
+ }
112
+
113
+ /**
114
+ * @param source - Config source
115
+ * @param key - Image role
116
+ * @param image - Image ref
117
+ */
118
+ export function upsertImage(source: string, key: string, image: string): string {
119
+ const keyLit = key.includes(".") ? `"${key}"` : key;
120
+ const line = ` ${keyLit}: "${image}",`;
121
+ const imagesRe = /images:\s*\{([\s\S]*?)\n\s*\}/;
122
+ const m = imagesRe.exec(source);
123
+ if (!m) return source;
124
+ const body = m[1]!;
125
+ if (new RegExp(`${keyLit}\\s*:`).test(body) || new RegExp(`"${key}"\\s*:`).test(body)) {
126
+ return source.replace(
127
+ new RegExp(`(["']?${key.replace(".", "\\.")}["']?\\s*:\\s*)"[^"]*"`),
128
+ `$1"${image}"`,
129
+ );
130
+ }
131
+ return source.replace(imagesRe, `images: {${body}\n${line}\n }`);
132
+ }
133
+
134
+ /**
135
+ * @param env - dotenv text
136
+ * @param key - Variable name
137
+ * @param value - Value
138
+ */
139
+ export function upsertEnv(env: string, key: string, value: string): string {
140
+ const line = `${key}=${value}`;
141
+ const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
142
+ if (re.test(env)) return env.replace(re, line);
143
+ const aiSection = /(# ── AI[^\n]*\n)/;
144
+ if (aiSection.test(env)) {
145
+ return env.replace(aiSection, `$1${line}\n`);
146
+ }
147
+ return `${env.trimEnd()}\n\n# ── AI ──────────────────────────────────────────────────────\n${line}\n`;
148
+ }
149
+
150
+ /**
151
+ * @param input - Setup choices
152
+ */
153
+ export function renderAiTs(input: AiSetupApplyInput): string {
154
+ const chat = input.chatModel ?? "default";
155
+ const provider =
156
+ input.driver === "ollama"
157
+ ? "ollama"
158
+ : input.driver === "anthropic"
159
+ ? "anthropic"
160
+ : input.driver === "mock"
161
+ ? "mock"
162
+ : "openai-compatible";
163
+
164
+ const lines = [
165
+ `import { ai } from "okengine";`,
166
+ ``,
167
+ `export const smart = ai.model("smart", {`,
168
+ ` provider: "${provider}",`,
169
+ ` model: process.env.OKE_AI_MODEL ?? "${chat}",`,
170
+ `});`,
171
+ ];
172
+
173
+ if (input.visionModel) {
174
+ lines.push(
175
+ ``,
176
+ `export const vision = ai.model("vision", {`,
177
+ ` provider: "${provider}",`,
178
+ ` model: process.env.OKE_AI_VISION_MODEL ?? "${input.visionModel}",`,
179
+ `});`,
180
+ );
181
+ }
182
+
183
+ if (input.embedModel) {
184
+ lines.push(
185
+ ``,
186
+ `export const embedModel = ai.model("embed", {`,
187
+ ` provider: "${provider}",`,
188
+ ` model: process.env.OKE_AI_EMBED_MODEL ?? "${input.embedModel}",`,
189
+ `});`,
190
+ ``,
191
+ `export const docsEmbed = ai.embed("docs", { model: embedModel });`,
192
+ );
193
+ }
194
+
195
+ lines.push(``);
196
+ return `${lines.join("\n")}\n`;
197
+ }
198
+
199
+ /**
200
+ * Ensure `src/app.ts` (or `src/core.ts`) imports `./ai`.
201
+ *
202
+ * @param cwd - Project root
203
+ */
204
+ function ensureAiImported(cwd: string): void {
205
+ for (const rel of ["src/app.ts", "src/core.ts"]) {
206
+ const path = join(cwd, rel);
207
+ if (!existsSync(path)) continue;
208
+ const src = readFileSync(path, "utf8");
209
+ if (/from\s+["']\.\/ai["']/.test(src) || /import\s+["']\.\/ai["']/.test(src)) return;
210
+ const next = `import "./ai";\n${src}`;
211
+ writeFileSync(path, next, "utf8");
212
+ return;
213
+ }
214
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Curated Ollama model catalog for `oke ai setup`.
3
+ *
4
+ * Short lists with Recommended — not the full Ollama library.
5
+ * Tags verified against common 2026 library names; override freely.
6
+ */
7
+
8
+ /** Role a model can fill in the wizard. */
9
+ export type CatalogRole = "chat" | "vision" | "embed";
10
+
11
+ /** One curated model entry. */
12
+ export type CatalogModel = {
13
+ readonly id: string;
14
+ readonly label: string;
15
+ readonly hint: string;
16
+ readonly role: CatalogRole;
17
+ readonly recommended?: boolean;
18
+ /** Approximate RAM (GB) needed to run comfortably. */
19
+ readonly ramGb: number;
20
+ };
21
+
22
+ /** Chat models (short list). */
23
+ export const CHAT_MODELS: readonly CatalogModel[] = [
24
+ {
25
+ id: "gemma4:e4b",
26
+ label: "Gemma 4 4B",
27
+ hint: "Fast · ≈8GB-class machine",
28
+ role: "chat",
29
+ recommended: true,
30
+ ramGb: 8,
31
+ },
32
+ {
33
+ id: "qwen3.5:9b",
34
+ label: "Qwen3.5 9B",
35
+ hint: "Better coding · ≈16GB-class",
36
+ role: "chat",
37
+ ramGb: 16,
38
+ },
39
+ {
40
+ id: "qwen3.5:27b",
41
+ label: "Qwen3.5 27B",
42
+ hint: "Best local quality · ≈32GB-class",
43
+ role: "chat",
44
+ ramGb: 32,
45
+ },
46
+ {
47
+ id: "llama4:scout",
48
+ label: "Llama 4 Scout",
49
+ hint: "General purpose · ≈16GB-class",
50
+ role: "chat",
51
+ ramGb: 16,
52
+ },
53
+ {
54
+ id: "deepseek-r1:8b",
55
+ label: "DeepSeek R1 8B",
56
+ hint: "Strong reasoning · ≈10GB-class",
57
+ role: "chat",
58
+ ramGb: 10,
59
+ },
60
+ ];
61
+
62
+ /** Vision models (short list + skip handled in prompts). */
63
+ export const VISION_MODELS: readonly CatalogModel[] = [
64
+ {
65
+ id: "qwen3-vl:4b",
66
+ label: "Qwen3-VL 4B",
67
+ hint: "Vision-language",
68
+ role: "vision",
69
+ recommended: true,
70
+ ramGb: 8,
71
+ },
72
+ {
73
+ id: "gemma4:e4b",
74
+ label: "Gemma Vision",
75
+ hint: "Multimodal Gemma 4",
76
+ role: "vision",
77
+ ramGb: 8,
78
+ },
79
+ ];
80
+
81
+ /** Embedding models. */
82
+ export const EMBED_MODELS: readonly CatalogModel[] = [
83
+ {
84
+ id: "nomic-embed-text",
85
+ label: "nomic-embed-text",
86
+ hint: "Default local RAG embedder",
87
+ role: "embed",
88
+ recommended: true,
89
+ ramGb: 1,
90
+ },
91
+ {
92
+ id: "bge-m3",
93
+ label: "bge-m3",
94
+ hint: "Multilingual + long docs",
95
+ role: "embed",
96
+ ramGb: 2,
97
+ },
98
+ {
99
+ id: "jina-embeddings-v4",
100
+ label: "jina-embeddings-v4",
101
+ hint: "High-quality embedder",
102
+ role: "embed",
103
+ ramGb: 2,
104
+ },
105
+ ];
106
+
107
+ /** All curated ids (for detect / not-installed). */
108
+ export const ALL_CURATED: readonly CatalogModel[] = [
109
+ ...CHAT_MODELS,
110
+ ...VISION_MODELS.filter((m) => !CHAT_MODELS.some((c) => c.id === m.id)),
111
+ ...EMBED_MODELS,
112
+ ];
113
+
114
+ /**
115
+ * Recommend a chat model for the host RAM (comfortable headroom).
116
+ *
117
+ * Prefer {@link recommendChatForNeeds} when use-case answers are available.
118
+ *
119
+ * @param totalRamGb - Detected total system RAM in GB (or null)
120
+ */
121
+ export function recommendChatModel(totalRamGb: number | null): CatalogModel {
122
+ // Keep catalog free of recommend.ts cycles — same rules as comfortableChatModels.
123
+ const HEADROOM = 4;
124
+ const sorted = [...CHAT_MODELS].sort((a, b) => a.ramGb - b.ramGb);
125
+ const smallest = sorted[0]!;
126
+ if (totalRamGb === null || !Number.isFinite(totalRamGb)) {
127
+ return CHAT_MODELS.find((m) => m.recommended) ?? smallest;
128
+ }
129
+ const comfortable = sorted.filter(
130
+ (m) => (m.id === smallest.id && m.ramGb <= totalRamGb) || m.ramGb + HEADROOM <= totalRamGb,
131
+ );
132
+ if (comfortable.length > 0) return comfortable[comfortable.length - 1]!;
133
+ // Fall back to largest that meets the machine tier (tight but runnable).
134
+ const tierOk = sorted.filter((m) => m.ramGb <= totalRamGb);
135
+ return tierOk.length > 0 ? tierOk[tierOk.length - 1]! : smallest;
136
+ }
137
+
138
+ /**
139
+ * Recommend vision / embed defaults.
140
+ *
141
+ * @param role - vision or embed
142
+ */
143
+ export function recommendForRole(role: "vision" | "embed"): CatalogModel {
144
+ const list = role === "vision" ? VISION_MODELS : EMBED_MODELS;
145
+ return list.find((m) => m.recommended) ?? list[0]!;
146
+ }
147
+
148
+ /** Thin cloud provider menu (non-Ollama). */
149
+ export const CLOUD_PROVIDERS = [
150
+ {
151
+ value: "openai",
152
+ label: "OpenAI",
153
+ driver: "openai-compatible" as const,
154
+ baseUrl: "https://api.openai.com/v1",
155
+ },
156
+ { value: "anthropic", label: "Anthropic", driver: "anthropic" as const, baseUrl: undefined },
157
+ {
158
+ value: "gemini",
159
+ label: "Gemini",
160
+ driver: "openai-compatible" as const,
161
+ baseUrl: undefined,
162
+ },
163
+ {
164
+ value: "lmstudio",
165
+ label: "LM Studio",
166
+ driver: "openai-compatible" as const,
167
+ baseUrl: "http://127.0.0.1:1234/v1",
168
+ },
169
+ {
170
+ value: "openrouter",
171
+ label: "OpenRouter",
172
+ driver: "openai-compatible" as const,
173
+ baseUrl: "https://openrouter.ai/api/v1",
174
+ },
175
+ {
176
+ value: "custom",
177
+ label: "Custom OpenAI Compatible",
178
+ driver: "openai-compatible" as const,
179
+ baseUrl: undefined,
180
+ },
181
+ ] as const;
182
+
183
+ /** Cloud chat model entry (no RAM tier). */
184
+ export type CloudModel = {
185
+ readonly id: string;
186
+ readonly label: string;
187
+ readonly hint: string;
188
+ readonly recommended?: boolean;
189
+ };
190
+
191
+ /** Short curated chat models per cloud / openai-compatible provider. */
192
+ export const CLOUD_CHAT_MODELS: Readonly<Record<string, readonly CloudModel[]>> = {
193
+ openai: [
194
+ { id: "gpt-4o-mini", label: "GPT-4o mini", hint: "Fast · cheap default", recommended: true },
195
+ { id: "gpt-4o", label: "GPT-4o", hint: "Strong general / vision" },
196
+ { id: "gpt-4.1", label: "GPT-4.1", hint: "Latest flagship" },
197
+ { id: "o4-mini", label: "o4-mini", hint: "Reasoning · lower cost" },
198
+ ],
199
+ anthropic: [
200
+ {
201
+ id: "claude-sonnet-4-20250514",
202
+ label: "Claude Sonnet 4",
203
+ hint: "Balanced default",
204
+ recommended: true,
205
+ },
206
+ { id: "claude-opus-4-20250514", label: "Claude Opus 4", hint: "Highest quality" },
207
+ { id: "claude-haiku-4-20250514", label: "Claude Haiku 4", hint: "Fast · cheap" },
208
+ ],
209
+ gemini: [
210
+ {
211
+ id: "gemini-2.5-flash",
212
+ label: "Gemini 2.5 Flash",
213
+ hint: "Fast default",
214
+ recommended: true,
215
+ },
216
+ { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", hint: "Higher quality" },
217
+ ],
218
+ openrouter: [
219
+ {
220
+ id: "openai/gpt-4o-mini",
221
+ label: "GPT-4o mini",
222
+ hint: "via OpenRouter",
223
+ recommended: true,
224
+ },
225
+ { id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4", hint: "via OpenRouter" },
226
+ { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash", hint: "via OpenRouter" },
227
+ ],
228
+ lmstudio: [
229
+ {
230
+ id: "local-model",
231
+ label: "local-model",
232
+ hint: "LM Studio loaded model id",
233
+ recommended: true,
234
+ },
235
+ ],
236
+ custom: [
237
+ {
238
+ id: "default",
239
+ label: "default",
240
+ hint: "Whatever your endpoint expects",
241
+ recommended: true,
242
+ },
243
+ ],
244
+ };
245
+
246
+ /**
247
+ * Curated chat models for a cloud provider (empty → Other-only).
248
+ *
249
+ * @param provider - Menu provider id
250
+ */
251
+ export function cloudChatModels(provider: string): readonly CloudModel[] {
252
+ return CLOUD_CHAT_MODELS[provider] ?? [];
253
+ }
254
+
255
+ /**
256
+ * Recommended cloud chat model id for a provider.
257
+ *
258
+ * @param provider - Menu provider id
259
+ */
260
+ export function recommendCloudChat(provider: string): string {
261
+ const list = cloudChatModels(provider);
262
+ return list.find((m) => m.recommended)?.id ?? list[0]?.id ?? "gpt-4o-mini";
263
+ }