@chat-js/cli 0.2.0 → 0.3.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.
@@ -1,4 +1,4 @@
1
- import type { ConfigInput } from "@/lib/config-schema";
1
+ import { defineConfig } from "@/lib/config-schema";
2
2
 
3
3
  const isProd = process.env.NODE_ENV === "production";
4
4
 
@@ -8,7 +8,7 @@ const isProd = process.env.NODE_ENV === "production";
8
8
  * Edit this file to customize your app.
9
9
  * @see https://chatjs.dev/docs/reference/config
10
10
  */
11
- const config = {
11
+ const config = defineConfig({
12
12
  appPrefix: "chatjs",
13
13
  appName: "ChatJS",
14
14
  appTitle: "ChatJS - The prod ready AI chat app",
@@ -68,36 +68,12 @@ const config = {
68
68
  vercel: true, // Requires VERCEL_APP_CLIENT_ID + VERCEL_APP_CLIENT_SECRET
69
69
  },
70
70
  ai: {
71
- gateway: "vercel",
72
- providerOrder: ["openai", "google", "anthropic", "xai"],
73
- disabledModels: ["morph/morph-v3-large", "morph/morph-v3-fast"],
74
- curatedDefaults: [
75
- // OpenAI
76
- "openai/gpt-5-nano",
77
- "openai/gpt-5-mini",
78
- "openai/gpt-5.2",
79
- "openai/gpt-5.2-chat",
80
- // Google
81
- "google/gemini-2.5-flash-lite",
82
- "google/gemini-3-flash",
83
- "google/gemini-3-pro-preview",
84
- // Anthropic
85
- "anthropic/claude-sonnet-4.5",
86
- "anthropic/claude-opus-4.5",
87
- // xAI
88
- "xai/grok-4",
89
- ],
90
- anonymousModels: [
91
- "google/gemini-2.5-flash-lite",
92
- "openai/gpt-5-mini",
93
- "openai/gpt-5-nano",
94
- "anthropic/claude-haiku-4.5",
95
- ],
71
+ gateway: "openai",
72
+ providerOrder: ["openai"],
73
+ disabledModels: [],
74
+ anonymousModels: ["gpt-5-nano"],
96
75
  workflows: {
97
- chat: "openai/gpt-5-mini",
98
- title: "openai/gpt-5-nano",
99
- pdf: "openai/gpt-5-mini",
100
- chatImageCompatible: "openai/gpt-4o-mini",
76
+ chatImageCompatible: "gpt-4o-mini",
101
77
  },
102
78
  tools: {
103
79
  webSearch: {
@@ -114,30 +90,25 @@ const config = {
114
90
  },
115
91
  followupSuggestions: {
116
92
  enabled: true,
117
- default: "openai/gpt-5-nano",
118
93
  },
119
94
  text: {
120
- polish: "openai/gpt-5-mini",
95
+ polish: "gpt-5-mini",
121
96
  },
122
97
  sheet: {
123
- format: "openai/gpt-5-mini",
124
- analyze: "openai/gpt-5-mini",
98
+ format: "gpt-5-mini",
99
+ analyze: "gpt-5-mini",
125
100
  },
126
101
  code: {
127
- edits: "openai/gpt-5-mini",
102
+ edits: "gpt-5-mini",
128
103
  },
129
104
  image: {
130
105
  enabled: true, // Requires BLOB_READ_WRITE_TOKEN
131
- default: "google/gemini-3-pro-image",
132
- },
133
- video: {
134
- enabled: true, // Requires BLOB_READ_WRITE_TOKEN
135
- default: "xai/grok-imagine-video",
106
+ default: "gpt-image-1",
136
107
  },
137
108
  deepResearch: {
138
109
  enabled: true, // Requires webSearch
139
- defaultModel: "google/gemini-2.5-flash-lite",
140
- finalReportModel: "google/gemini-3-flash",
110
+ defaultModel: "gpt-5-nano",
111
+ finalReportModel: "gpt-5-mini",
141
112
  allowClarification: true,
142
113
  maxResearcherIterations: 1,
143
114
  maxConcurrentResearchUnits: 2,
@@ -162,6 +133,6 @@ const config = {
162
133
  "application/pdf": [".pdf"],
163
134
  },
164
135
  },
165
- } satisfies ConfigInput;
136
+ });
166
137
 
167
138
  export default config;
@@ -5,16 +5,19 @@ import type { StreamWriter } from "@/lib/ai/types";
5
5
  import { config } from "@/lib/config";
6
6
  import { generateUUID } from "@/lib/utils";
7
7
 
8
+ const FOLLOWUP_CONTEXT_MESSAGES = 2;
9
+
8
10
  export async function generateFollowupSuggestions(
9
11
  modelMessages: ModelMessage[]
10
12
  ) {
11
13
  const maxQuestionCount = 5;
12
14
  const minQuestionCount = 3;
13
15
  const maxCharactersPerQuestion = 80;
16
+ const recentMessages = modelMessages.slice(-FOLLOWUP_CONTEXT_MESSAGES);
14
17
  return streamText({
15
18
  model: await getLanguageModel(config.ai.tools.followupSuggestions.default),
16
19
  messages: [
17
- ...modelMessages,
20
+ ...recentMessages,
18
21
  {
19
22
  role: "user",
20
23
  content: `What question should I ask next? Return an array of suggested questions (minimum ${minQuestionCount}, maximum ${maxQuestionCount}). Each question should be no more than ${maxCharactersPerQuestion} characters.`,
@@ -1,45 +1,46 @@
1
- import type { GatewayType } from "./gateways/registry";
1
+ import type {
2
+ GatewayImageModelIdMap,
3
+ GatewayModelIdMap,
4
+ GatewayType,
5
+ GatewayVideoModelIdMap,
6
+ } from "./gateways/registry";
2
7
 
3
- interface ModelDefaults {
4
- anonymousModels: string[];
5
- curatedDefaults: string[];
6
- disabledModels: string[];
8
+ type VideoDefault<G extends GatewayType> = [GatewayVideoModelIdMap[G]] extends [
9
+ never,
10
+ ]
11
+ ? { enabled: false }
12
+ :
13
+ | { enabled: true; default: GatewayVideoModelIdMap[G] }
14
+ | { enabled: false; default?: GatewayVideoModelIdMap[G] };
15
+
16
+ type ImageDefault<G extends GatewayType> = [GatewayImageModelIdMap[G]] extends [
17
+ never,
18
+ ]
19
+ ? { enabled: false }
20
+ :
21
+ | { enabled: true; default: GatewayImageModelIdMap[G] }
22
+ | { enabled: false; default?: GatewayImageModelIdMap[G] };
23
+
24
+ export interface ModelDefaultsFor<G extends GatewayType> {
25
+ anonymousModels: GatewayModelIdMap[G][];
26
+ curatedDefaults: GatewayModelIdMap[G][];
27
+ disabledModels: GatewayModelIdMap[G][];
7
28
  providerOrder: string[];
8
29
  tools: {
9
- webSearch: {
10
- enabled: boolean;
11
- };
12
- urlRetrieval: {
13
- enabled: boolean;
14
- };
15
- codeExecution: {
16
- enabled: boolean;
17
- };
18
- mcp: {
19
- enabled: boolean;
20
- };
21
- followupSuggestions: {
22
- enabled: boolean;
23
- default: string;
24
- };
25
- text: {
26
- polish: string;
27
- };
28
- sheet: {
29
- format: string;
30
- analyze: string;
31
- };
32
- code: {
33
- edits: string;
34
- };
35
- image: {
36
- enabled: boolean;
37
- default: string;
38
- };
30
+ webSearch: { enabled: boolean };
31
+ urlRetrieval: { enabled: boolean };
32
+ codeExecution: { enabled: boolean };
33
+ mcp: { enabled: boolean };
34
+ followupSuggestions: { enabled: boolean; default: GatewayModelIdMap[G] };
35
+ text: { polish: GatewayModelIdMap[G] };
36
+ sheet: { format: GatewayModelIdMap[G]; analyze: GatewayModelIdMap[G] };
37
+ code: { edits: GatewayModelIdMap[G] };
38
+ image: ImageDefault<G>;
39
+ video: VideoDefault<G>;
39
40
  deepResearch: {
40
41
  enabled: boolean;
41
- defaultModel: string;
42
- finalReportModel: string;
42
+ defaultModel: GatewayModelIdMap[G];
43
+ finalReportModel: GatewayModelIdMap[G];
43
44
  allowClarification: boolean;
44
45
  maxResearcherIterations: number;
45
46
  maxConcurrentResearchUnits: number;
@@ -47,14 +48,14 @@ interface ModelDefaults {
47
48
  };
48
49
  };
49
50
  workflows: {
50
- chat: string;
51
- title: string;
52
- pdf: string;
53
- chatImageCompatible: string;
51
+ chat: GatewayModelIdMap[G];
52
+ title: GatewayModelIdMap[G];
53
+ pdf: GatewayModelIdMap[G];
54
+ chatImageCompatible: GatewayModelIdMap[G];
54
55
  };
55
56
  }
56
57
 
57
- const multiProviderDefaults = {
58
+ const vercelDefaults = {
58
59
  providerOrder: ["openai", "google", "anthropic"],
59
60
  disabledModels: [],
60
61
  curatedDefaults: [
@@ -77,36 +78,67 @@ const multiProviderDefaults = {
77
78
  chatImageCompatible: "openai/gpt-4o-mini",
78
79
  },
79
80
  tools: {
80
- webSearch: {
81
- enabled: false,
82
- },
83
- urlRetrieval: {
84
- enabled: false,
85
- },
86
- codeExecution: {
81
+ webSearch: { enabled: false },
82
+ urlRetrieval: { enabled: false },
83
+ codeExecution: { enabled: false },
84
+ mcp: { enabled: false },
85
+ followupSuggestions: {
87
86
  enabled: false,
87
+ default: "google/gemini-2.5-flash-lite",
88
88
  },
89
- mcp: {
89
+ text: { polish: "openai/gpt-5-mini" },
90
+ sheet: { format: "openai/gpt-5-mini", analyze: "openai/gpt-5-mini" },
91
+ code: { edits: "openai/gpt-5-mini" },
92
+ image: { enabled: false, default: "google/gemini-3-pro-image" },
93
+ video: { enabled: false, default: "xai/grok-imagine-video" },
94
+ deepResearch: {
90
95
  enabled: false,
96
+ defaultModel: "google/gemini-2.5-flash-lite",
97
+ finalReportModel: "google/gemini-3-flash",
98
+ allowClarification: true,
99
+ maxResearcherIterations: 1,
100
+ maxConcurrentResearchUnits: 2,
101
+ maxSearchQueries: 2,
91
102
  },
103
+ },
104
+ } satisfies ModelDefaultsFor<"vercel">;
105
+
106
+ const openrouterDefaults = {
107
+ providerOrder: ["openai", "google", "anthropic"],
108
+ disabledModels: [],
109
+ curatedDefaults: [
110
+ "openai/gpt-5-nano",
111
+ "openai/gpt-5-mini",
112
+ "openai/gpt-5.2",
113
+ "openai/gpt-5.2-chat",
114
+ "google/gemini-2.5-flash-lite",
115
+ "google/gemini-3-flash",
116
+ "google/gemini-3-pro-preview",
117
+ "anthropic/claude-sonnet-4.5",
118
+ "anthropic/claude-opus-4.5",
119
+ "xai/grok-4",
120
+ ],
121
+ anonymousModels: ["google/gemini-2.5-flash-lite", "openai/gpt-5-nano"],
122
+ workflows: {
123
+ chat: "openai/gpt-5-mini",
124
+ title: "openai/gpt-5-nano",
125
+ pdf: "openai/gpt-5-mini",
126
+ chatImageCompatible: "openai/gpt-4o-mini",
127
+ },
128
+ tools: {
129
+ webSearch: { enabled: false },
130
+ urlRetrieval: { enabled: false },
131
+ codeExecution: { enabled: false },
132
+ mcp: { enabled: false },
92
133
  followupSuggestions: {
93
134
  enabled: false,
94
135
  default: "google/gemini-2.5-flash-lite",
95
136
  },
96
- text: {
97
- polish: "openai/gpt-5-mini",
98
- },
99
- sheet: {
100
- format: "openai/gpt-5-mini",
101
- analyze: "openai/gpt-5-mini",
102
- },
103
- code: {
104
- edits: "openai/gpt-5-mini",
105
- },
106
- image: {
107
- enabled: false,
108
- default: "google/gemini-3-pro-image",
109
- },
137
+ text: { polish: "openai/gpt-5-mini" },
138
+ sheet: { format: "openai/gpt-5-mini", analyze: "openai/gpt-5-mini" },
139
+ code: { edits: "openai/gpt-5-mini" },
140
+ image: { enabled: false },
141
+ video: { enabled: false },
110
142
  deepResearch: {
111
143
  enabled: false,
112
144
  defaultModel: "google/gemini-2.5-flash-lite",
@@ -117,9 +149,9 @@ const multiProviderDefaults = {
117
149
  maxSearchQueries: 2,
118
150
  },
119
151
  },
120
- } satisfies ModelDefaults;
152
+ } satisfies ModelDefaultsFor<"openrouter">;
121
153
 
122
- const openaiOnlyDefaults = {
154
+ const openaiDefaults = {
123
155
  providerOrder: ["openai"],
124
156
  disabledModels: [],
125
157
  curatedDefaults: [
@@ -136,36 +168,55 @@ const openaiOnlyDefaults = {
136
168
  chatImageCompatible: "gpt-4o-mini",
137
169
  },
138
170
  tools: {
139
- webSearch: {
140
- enabled: false,
141
- },
142
- urlRetrieval: {
143
- enabled: false,
144
- },
145
- codeExecution: {
146
- enabled: false,
147
- },
148
- mcp: {
149
- enabled: false,
150
- },
151
- followupSuggestions: {
152
- enabled: false,
153
- default: "gpt-5-nano",
154
- },
155
- text: {
156
- polish: "gpt-5-mini",
157
- },
158
- sheet: {
159
- format: "gpt-5-mini",
160
- analyze: "gpt-5-mini",
161
- },
162
- code: {
163
- edits: "gpt-5-mini",
164
- },
165
- image: {
171
+ webSearch: { enabled: false },
172
+ urlRetrieval: { enabled: false },
173
+ codeExecution: { enabled: false },
174
+ mcp: { enabled: false },
175
+ followupSuggestions: { enabled: false, default: "gpt-5-nano" },
176
+ text: { polish: "gpt-5-mini" },
177
+ sheet: { format: "gpt-5-mini", analyze: "gpt-5-mini" },
178
+ code: { edits: "gpt-5-mini" },
179
+ image: { enabled: false },
180
+ video: { enabled: false },
181
+ deepResearch: {
166
182
  enabled: false,
167
- default: "gpt-image-1",
183
+ defaultModel: "gpt-5-nano",
184
+ finalReportModel: "gpt-5-mini",
185
+ allowClarification: true,
186
+ maxResearcherIterations: 1,
187
+ maxConcurrentResearchUnits: 2,
188
+ maxSearchQueries: 2,
168
189
  },
190
+ },
191
+ } satisfies ModelDefaultsFor<"openai">;
192
+
193
+ const openaiCompatibleDefaults = {
194
+ providerOrder: ["openai"],
195
+ disabledModels: [],
196
+ curatedDefaults: [
197
+ "gpt-5-nano",
198
+ "gpt-5-mini",
199
+ "gpt-5.2",
200
+ "gpt-5.2-chat-latest",
201
+ ],
202
+ anonymousModels: ["gpt-5-nano"],
203
+ workflows: {
204
+ chat: "gpt-5-mini",
205
+ title: "gpt-5-nano",
206
+ pdf: "gpt-5-mini",
207
+ chatImageCompatible: "gpt-4o-mini",
208
+ },
209
+ tools: {
210
+ webSearch: { enabled: false },
211
+ urlRetrieval: { enabled: false },
212
+ codeExecution: { enabled: false },
213
+ mcp: { enabled: false },
214
+ followupSuggestions: { enabled: false, default: "gpt-5-nano" },
215
+ text: { polish: "gpt-5-mini" },
216
+ sheet: { format: "gpt-5-mini", analyze: "gpt-5-mini" },
217
+ code: { edits: "gpt-5-mini" },
218
+ image: { enabled: false },
219
+ video: { enabled: false },
169
220
  deepResearch: {
170
221
  enabled: false,
171
222
  defaultModel: "gpt-5-nano",
@@ -176,11 +227,14 @@ const openaiOnlyDefaults = {
176
227
  maxSearchQueries: 2,
177
228
  },
178
229
  },
179
- } satisfies ModelDefaults;
230
+ } satisfies ModelDefaultsFor<"openai-compatible">;
180
231
 
181
- export const GATEWAY_MODEL_DEFAULTS: Record<GatewayType, ModelDefaults> = {
182
- vercel: multiProviderDefaults,
183
- openrouter: multiProviderDefaults,
184
- openai: openaiOnlyDefaults,
185
- "openai-compatible": openaiOnlyDefaults,
232
+ // Record ensures a compile error if a new gateway is added but not here.
233
+ export const GATEWAY_MODEL_DEFAULTS: {
234
+ [G in GatewayType]: ModelDefaultsFor<G>;
235
+ } = {
236
+ vercel: vercelDefaults,
237
+ openrouter: openrouterDefaults,
238
+ openai: openaiDefaults,
239
+ "openai-compatible": openaiCompatibleDefaults,
186
240
  };
@@ -96,7 +96,7 @@ function toAiGatewayModel(model: OpenRouterModelResponse): AiGatewayModel {
96
96
  }
97
97
 
98
98
  export class OpenRouterGateway
99
- implements GatewayProvider<"openrouter", string, string, never>
99
+ implements GatewayProvider<"openrouter", string, never, never>
100
100
  {
101
101
  readonly type = "openrouter" as const;
102
102
 
@@ -113,7 +113,7 @@ export class OpenRouterGateway
113
113
  return provider.chat(modelId);
114
114
  }
115
115
 
116
- createImageModel(_modelId: string): ImageModel | null {
116
+ createImageModel(_modelId: never): ImageModel | null {
117
117
  // OpenRouter routes image generation through multimodal language models.
118
118
  // Return null to signal callers should use createLanguageModel instead.
119
119
  return null;
@@ -41,6 +41,9 @@ async function resolveImageModel(selectedModel?: string): Promise<{
41
41
  }
42
42
 
43
43
  // Fall back to the configured default image model
44
+ if (!config.ai.tools.image.enabled) {
45
+ throw new Error("Image generation is not enabled");
46
+ }
44
47
  const defaultId = config.ai.tools.image.default;
45
48
  try {
46
49
  const model = await getAppModelDefinition(defaultId as AppModelId);
@@ -134,6 +137,10 @@ async function runGenerateImageTraditional({
134
137
  startMs: number;
135
138
  costAccumulator?: CostAccumulator;
136
139
  }): Promise<{ imageUrl: string; prompt: string }> {
140
+ if (!config.ai.tools.image.enabled) {
141
+ throw new Error("Image generation is not enabled");
142
+ }
143
+ const imageDefault = config.ai.tools.image.default;
137
144
  let promptInput:
138
145
  | string
139
146
  | {
@@ -161,7 +168,7 @@ async function runGenerateImageTraditional({
161
168
  }
162
169
 
163
170
  const res = await generateImage({
164
- model: getImageModel(config.ai.tools.image.default),
171
+ model: getImageModel(imageDefault),
165
172
  prompt: promptInput,
166
173
  n: 1,
167
174
  providerOptions: {
@@ -184,7 +191,7 @@ async function runGenerateImageTraditional({
184
191
 
185
192
  if (res.usage) {
186
193
  costAccumulator?.addLLMCost(
187
- config.ai.tools.image.default as AppModelId,
194
+ imageDefault as AppModelId,
188
195
  {
189
196
  inputTokens: res.usage.inputTokens,
190
197
  outputTokens: res.usage.outputTokens,
@@ -48,6 +48,9 @@ async function resolveVideoModel(selectedModel?: string): Promise<string> {
48
48
  // Not in app models registry, fall through
49
49
  }
50
50
  }
51
+ if (!config.ai.tools.video.enabled) {
52
+ throw new Error("Video generation is not enabled");
53
+ }
51
54
  return config.ai.tools.video.default;
52
55
  }
53
56
 
@@ -101,7 +101,7 @@ type webSearchTool = InferUITool<ReturnType<typeof tavilyWebSearch>>;
101
101
  type codeExecutionTool = InferUITool<ReturnType<typeof codeExecution>>;
102
102
  type retrieveUrlTool = InferUITool<typeof retrieveUrl>;
103
103
 
104
- // biome-ignore lint/style/useConsistentTypeDefinitions: <explanation>
104
+ // biome-ignore lint/style/useConsistentTypeDefinitions: using type for mapped type compatibility
105
105
  export type ChatTools = {
106
106
  codeExecution: codeExecutionTool;
107
107
  createCodeDocument: createCodeDocumentToolType;
@@ -123,7 +123,7 @@ interface FollowupSuggestions {
123
123
  suggestions: string[];
124
124
  }
125
125
 
126
- // biome-ignore lint/style/useConsistentTypeDefinitions: <explanation>
126
+ // biome-ignore lint/style/useConsistentTypeDefinitions: using type for mapped type compatibility
127
127
  export type CustomUIDataTypes = {
128
128
  appendMessage: string;
129
129
  chatConfirmed: {