codeblog-app 1.5.1 → 1.6.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "1.5.1",
4
+ "version": "1.6.0",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -56,11 +56,11 @@
56
56
  "typescript": "5.8.2"
57
57
  },
58
58
  "optionalDependencies": {
59
- "codeblog-app-darwin-arm64": "1.5.1",
60
- "codeblog-app-darwin-x64": "1.5.1",
61
- "codeblog-app-linux-arm64": "1.5.1",
62
- "codeblog-app-linux-x64": "1.5.1",
63
- "codeblog-app-windows-x64": "1.5.1"
59
+ "codeblog-app-darwin-arm64": "1.5.2",
60
+ "codeblog-app-darwin-x64": "1.5.2",
61
+ "codeblog-app-linux-arm64": "1.5.2",
62
+ "codeblog-app-linux-x64": "1.5.2",
63
+ "codeblog-app-windows-x64": "1.5.2"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/amazon-bedrock": "^4.0.60",
package/src/ai/chat.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import { streamText, type CoreMessage } from "ai"
2
2
  import { AIProvider } from "./provider"
3
+ import { chatTools } from "./tools"
3
4
  import { Log } from "../util/log"
4
5
 
5
6
  const log = Log.create({ service: "ai-chat" })
6
7
 
8
+ const SYSTEM_PROMPT = `You are CodeBlog AI — an assistant for the CodeBlog developer forum (codeblog.ai).
9
+
10
+ You help developers with everything on the platform:
11
+ - Scan and analyze their local IDE coding sessions
12
+ - Write and publish blog posts from coding sessions
13
+ - Browse, search, read, comment, vote on forum posts
14
+ - Manage bookmarks, notifications, debates, tags, trending topics
15
+ - Manage agents, view dashboard, follow users
16
+ - Generate weekly digests
17
+
18
+ You have 20+ tools. Use them whenever the user's request matches. Chain multiple tools if needed.
19
+ After a tool returns results, summarize them naturally for the user.
20
+
21
+ Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
22
+ Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
23
+
7
24
  export namespace AIChat {
8
25
  export interface Message {
9
26
  role: "user" | "assistant" | "system"
@@ -14,23 +31,12 @@ export namespace AIChat {
14
31
  onToken?: (token: string) => void
15
32
  onFinish?: (text: string) => void
16
33
  onError?: (error: Error) => void
34
+ onToolCall?: (name: string, args: unknown) => void
35
+ onToolResult?: (name: string, result: unknown) => void
17
36
  }
18
37
 
19
- const SYSTEM_PROMPT = `You are CodeBlog AI an assistant for the CodeBlog developer forum (codeblog.ai).
20
-
21
- You help developers:
22
- - Write engaging blog posts from their coding sessions
23
- - Analyze code and explain technical concepts
24
- - Draft comments and debate arguments
25
- - Summarize posts and discussions
26
- - Generate tags and titles for posts
27
-
28
- Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
29
- Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
30
-
31
- export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string) {
38
+ export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
32
39
  const model = await AIProvider.getModel(modelID)
33
-
34
40
  log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
35
41
 
36
42
  const coreMessages: CoreMessage[] = messages.map((m) => ({
@@ -42,15 +48,43 @@ Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a co
42
48
  model,
43
49
  system: SYSTEM_PROMPT,
44
50
  messages: coreMessages,
51
+ tools: chatTools,
52
+ maxSteps: 5,
53
+ abortSignal: signal,
45
54
  })
46
55
 
47
56
  let full = ""
48
57
  try {
49
- for await (const chunk of result.textStream) {
50
- full += chunk
51
- callbacks.onToken?.(chunk)
58
+ for await (const part of result.fullStream) {
59
+ switch (part.type) {
60
+ case "text-delta": {
61
+ // AI SDK v6 uses .text, older versions use .textDelta
62
+ const delta = (part as any).text ?? (part as any).textDelta ?? ""
63
+ if (delta) {
64
+ full += delta
65
+ callbacks.onToken?.(delta)
66
+ }
67
+ break
68
+ }
69
+ case "tool-call":
70
+ callbacks.onToolCall?.(part.toolName, part.args)
71
+ break
72
+ case "tool-result":
73
+ callbacks.onToolResult?.((part as any).toolName, (part as any).result ?? {})
74
+ break
75
+ case "error": {
76
+ const msg = part.error instanceof Error ? part.error.message : String(part.error)
77
+ log.error("stream part error", { error: msg })
78
+ callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
79
+ break
80
+ }
81
+ }
82
+ }
83
+ // If fullStream text-delta didn't capture text, try result.text as fallback
84
+ if (!full.trim()) {
85
+ try { full = await result.text } catch {}
52
86
  }
53
- callbacks.onFinish?.(full)
87
+ callbacks.onFinish?.(full || "(No response)")
54
88
  } catch (err) {
55
89
  const error = err instanceof Error ? err : new Error(String(err))
56
90
  log.error("stream error", { error: error.message })
@@ -63,13 +97,13 @@ Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a co
63
97
  return full
64
98
  }
65
99
 
66
- export async function generate(prompt: string, modelID?: string): Promise<string> {
100
+ export async function generate(prompt: string, modelID?: string) {
67
101
  let result = ""
68
102
  await stream([{ role: "user", content: prompt }], { onFinish: (text) => (result = text) }, modelID)
69
103
  return result
70
104
  }
71
105
 
72
- export async function analyzeAndPost(sessionContent: string, modelID?: string): Promise<{ title: string; content: string; tags: string[]; summary: string }> {
106
+ export async function analyzeAndPost(sessionContent: string, modelID?: string) {
73
107
  const prompt = `Analyze this coding session and write a blog post about it.
74
108
 
75
109
  The post should:
@@ -0,0 +1,89 @@
1
+ // AI provider auto-detection and configuration
2
+
3
+ function looksLikeApi(r: Response) {
4
+ const ct = r.headers.get("content-type") || ""
5
+ return ct.includes("json") || ct.includes("text/plain")
6
+ }
7
+
8
+ export async function probe(base: string, key: string): Promise<"openai" | "anthropic" | null> {
9
+ const clean = base.replace(/\/+$/, "")
10
+ try {
11
+ const r = await fetch(`${clean}/v1/models`, {
12
+ headers: { Authorization: `Bearer ${key}` },
13
+ signal: AbortSignal.timeout(8000),
14
+ })
15
+ if (r.ok || ((r.status === 401 || r.status === 403) && looksLikeApi(r))) return "openai"
16
+ } catch {}
17
+ try {
18
+ const r = await fetch(`${clean}/v1/messages`, {
19
+ method: "POST",
20
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
21
+ body: JSON.stringify({ model: "test", max_tokens: 1, messages: [] }),
22
+ signal: AbortSignal.timeout(8000),
23
+ })
24
+ if (r.status !== 404 && looksLikeApi(r)) return "anthropic"
25
+ } catch {}
26
+ return null
27
+ }
28
+
29
+ const KEY_PREFIX_MAP: Record<string, string> = {
30
+ "sk-ant-": "anthropic",
31
+ "AIza": "google",
32
+ "xai-": "xai",
33
+ "gsk_": "groq",
34
+ "sk-or-": "openrouter",
35
+ "pplx-": "perplexity",
36
+ }
37
+
38
+ const ENV_MAP: Record<string, string> = {
39
+ anthropic: "ANTHROPIC_API_KEY",
40
+ openai: "OPENAI_API_KEY",
41
+ google: "GOOGLE_GENERATIVE_AI_API_KEY",
42
+ xai: "XAI_API_KEY",
43
+ groq: "GROQ_API_KEY",
44
+ openrouter: "OPENROUTER_API_KEY",
45
+ perplexity: "PERPLEXITY_API_KEY",
46
+ "openai-compatible": "OPENAI_COMPATIBLE_API_KEY",
47
+ }
48
+
49
+ export function detectProvider(key: string) {
50
+ for (const [prefix, provider] of Object.entries(KEY_PREFIX_MAP)) {
51
+ if (key.startsWith(prefix)) return provider
52
+ }
53
+ return "openai"
54
+ }
55
+
56
+ export async function saveProvider(url: string, key: string): Promise<{ provider: string; error?: string }> {
57
+ const { Config } = await import("../config")
58
+
59
+ if (url) {
60
+ const detected = await probe(url, key)
61
+ if (!detected) return { provider: "", error: "Could not connect. Check URL and key." }
62
+
63
+ const provider = detected === "anthropic" ? "anthropic" : "openai-compatible"
64
+ const envKey = detected === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_COMPATIBLE_API_KEY"
65
+ const envBase = detected === "anthropic" ? "ANTHROPIC_BASE_URL" : "OPENAI_COMPATIBLE_BASE_URL"
66
+ process.env[envKey] = key
67
+ process.env[envBase] = url
68
+
69
+ const cfg = await Config.load()
70
+ const providers = cfg.providers || {}
71
+ providers[provider] = { api_key: key, base_url: url }
72
+ await Config.save({ providers })
73
+ return { provider: `${detected} format` }
74
+ }
75
+
76
+ const provider = detectProvider(key)
77
+ if (ENV_MAP[provider]) process.env[ENV_MAP[provider]] = key
78
+
79
+ const cfg = await Config.load()
80
+ const providers = cfg.providers || {}
81
+ providers[provider] = { api_key: key }
82
+ await Config.save({ providers })
83
+ return { provider }
84
+ }
85
+
86
+ export function mask(s: string) {
87
+ if (s.length <= 8) return s
88
+ return s.slice(0, 4) + "\u2022".repeat(Math.min(s.length - 8, 20)) + s.slice(-4)
89
+ }
@@ -173,14 +173,6 @@ export namespace AIProvider {
173
173
  return {}
174
174
  }
175
175
 
176
- // Refresh models.dev in background (lazy, only on first use)
177
- let modelsDevInitialized = false
178
- function ensureModelsDev() {
179
- if (modelsDevInitialized) return
180
- modelsDevInitialized = true
181
- fetchModelsDev().catch(() => {})
182
- }
183
-
184
176
  // ---------------------------------------------------------------------------
185
177
  // Get API key for a provider
186
178
  // ---------------------------------------------------------------------------
@@ -255,13 +247,13 @@ export namespace AIProvider {
255
247
  return getLanguageModel(builtin.providerID, id, apiKey, undefined, base)
256
248
  }
257
249
 
258
- // Try models.dev
250
+ // Try models.dev (only if the user has a key for that provider)
259
251
  const modelsDev = await fetchModelsDev()
260
252
  for (const [providerID, provider] of Object.entries(modelsDev)) {
261
253
  const p = provider as any
262
254
  if (p.models?.[id]) {
263
255
  const apiKey = await getApiKey(providerID)
264
- if (!apiKey) throw noKeyError(providerID)
256
+ if (!apiKey) continue
265
257
  const npm = p.models[id].provider?.npm || p.npm || "@ai-sdk/openai-compatible"
266
258
  const base = await getBaseUrl(providerID)
267
259
  return getLanguageModel(providerID, id, apiKey, npm, base || p.api)
@@ -278,6 +270,19 @@ export namespace AIProvider {
278
270
  return getLanguageModel(providerID, mid, apiKey, undefined, base)
279
271
  }
280
272
 
273
+ // Fallback: try any configured provider that has a base_url (custom/openai-compatible)
274
+ const cfg = await Config.load()
275
+ if (cfg.providers) {
276
+ for (const [providerID, p] of Object.entries(cfg.providers)) {
277
+ if (!p.api_key) continue
278
+ const base = p.base_url || (await getBaseUrl(providerID))
279
+ if (base) {
280
+ log.info("fallback: sending unknown model to provider with base_url", { provider: providerID, model: id })
281
+ return getLanguageModel(providerID, id, p.api_key, undefined, base)
282
+ }
283
+ }
284
+ }
285
+
281
286
  throw new Error(`Unknown model: ${id}. Run: codeblog config --list`)
282
287
  }
283
288
 
@@ -291,8 +296,12 @@ export namespace AIProvider {
291
296
  if (!sdk) {
292
297
  const createFn = BUNDLED_PROVIDERS[pkg]
293
298
  if (!createFn) throw new Error(`No bundled provider for ${pkg}. Provider ${providerID} not supported.`)
294
- const opts: Record<string, unknown> = { apiKey }
295
- if (baseURL) opts.baseURL = baseURL
299
+ const opts: Record<string, unknown> = { apiKey, name: providerID }
300
+ if (baseURL) {
301
+ // @ai-sdk/openai-compatible expects baseURL to include /v1
302
+ const clean = baseURL.replace(/\/+$/, "")
303
+ opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
304
+ }
296
305
  if (providerID === "openrouter") {
297
306
  opts.headers = { "HTTP-Referer": "https://codeblog.ai/", "X-Title": "codeblog" }
298
307
  }
@@ -339,15 +348,109 @@ export namespace AIProvider {
339
348
  return false
340
349
  }
341
350
 
351
+ // ---------------------------------------------------------------------------
352
+ // Fetch models dynamically from a provider's /v1/models endpoint
353
+ // ---------------------------------------------------------------------------
354
+ export async function fetchModels(providerID: string): Promise<ModelInfo[]> {
355
+ const apiKey = await getApiKey(providerID)
356
+ if (!apiKey) return []
357
+ const base = await getBaseUrl(providerID)
358
+ if (!base) {
359
+ // For known providers without custom base URL, use models.dev
360
+ const modelsDev = await fetchModelsDev()
361
+ const p = modelsDev[providerID] as any
362
+ if (p?.models) {
363
+ return Object.entries(p.models).map(([id, m]: [string, any]) => ({
364
+ id,
365
+ providerID,
366
+ name: m.name || id,
367
+ contextWindow: m.limit?.context || 0,
368
+ outputTokens: m.limit?.output || 0,
369
+ }))
370
+ }
371
+ return []
372
+ }
373
+ // Try OpenAI-compatible /v1/models
374
+ try {
375
+ const url = `${base.replace(/\/+$/, "").replace(/\/v1$/, "")}/v1/models`
376
+ const r = await fetch(url, {
377
+ headers: { Authorization: `Bearer ${apiKey}` },
378
+ signal: AbortSignal.timeout(10000),
379
+ })
380
+ if (!r.ok) return []
381
+ const json = await r.json() as any
382
+ const models = json.data || json.models || []
383
+ return models.map((m: any) => ({
384
+ id: m.id || m.name || "",
385
+ providerID,
386
+ name: m.id || m.name || "",
387
+ contextWindow: m.context_length || m.context_window || 0,
388
+ outputTokens: m.max_output_tokens || m.max_tokens || 0,
389
+ })).filter((m: ModelInfo) => m.id)
390
+ } catch {
391
+ return []
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Fetch models for all configured providers
397
+ // ---------------------------------------------------------------------------
398
+ export async function fetchAllModels(): Promise<ModelInfo[]> {
399
+ const cfg = await Config.load()
400
+ const seen = new Set<string>()
401
+ const result: ModelInfo[] = []
402
+
403
+ // Collect configured provider IDs (check env vars + config in one pass)
404
+ const ids = new Set<string>()
405
+ for (const [providerID, envKeys] of Object.entries(PROVIDER_ENV)) {
406
+ if (envKeys.some((k) => process.env[k])) ids.add(providerID)
407
+ else if (cfg.providers?.[providerID]?.api_key) ids.add(providerID)
408
+ }
409
+ if (cfg.providers) {
410
+ for (const providerID of Object.keys(cfg.providers)) {
411
+ if (cfg.providers[providerID].api_key) ids.add(providerID)
412
+ }
413
+ }
414
+
415
+ const settled = await Promise.allSettled([...ids].map((id) => fetchModels(id)))
416
+ for (const entry of settled) {
417
+ if (entry.status !== "fulfilled") continue
418
+ for (const m of entry.value) {
419
+ const key = `${m.providerID}/${m.id}`
420
+ if (seen.has(key)) continue
421
+ seen.add(key)
422
+ result.push(m)
423
+ }
424
+ }
425
+
426
+ return result
427
+ }
428
+
342
429
  // ---------------------------------------------------------------------------
343
430
  // List available models with key status (for codeblog config --list)
344
431
  // ---------------------------------------------------------------------------
345
432
  export async function available(): Promise<Array<{ model: ModelInfo; hasKey: boolean }>> {
346
433
  const result: Array<{ model: ModelInfo; hasKey: boolean }> = []
434
+ const seen = new Set<string>()
435
+
436
+ // Dynamic models from configured providers (always have keys)
437
+ const dynamic = await fetchAllModels()
438
+ for (const model of dynamic) {
439
+ const key = `${model.providerID}/${model.id}`
440
+ if (seen.has(key)) continue
441
+ seen.add(key)
442
+ result.push({ model, hasKey: true })
443
+ }
444
+
445
+ // Merge builtin models (may or may not have keys)
347
446
  for (const model of Object.values(BUILTIN_MODELS)) {
348
- const key = await getApiKey(model.providerID)
349
- result.push({ model, hasKey: !!key })
447
+ const key = `${model.providerID}/${model.id}`
448
+ if (seen.has(key)) continue
449
+ seen.add(key)
450
+ const apiKey = await getApiKey(model.providerID)
451
+ result.push({ model, hasKey: !!apiKey })
350
452
  }
453
+
351
454
  return result
352
455
  }
353
456