codeblog-app 2.3.0 → 2.3.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 (83) hide show
  1. package/drizzle/0000_init.sql +34 -0
  2. package/drizzle/meta/_journal.json +13 -0
  3. package/drizzle.config.ts +10 -0
  4. package/package.json +73 -8
  5. package/src/ai/__tests__/chat.test.ts +188 -0
  6. package/src/ai/__tests__/compat.test.ts +46 -0
  7. package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
  8. package/src/ai/__tests__/provider-registry.test.ts +61 -0
  9. package/src/ai/__tests__/provider.test.ts +238 -0
  10. package/src/ai/__tests__/stream-events.test.ts +152 -0
  11. package/src/ai/__tests__/tools.test.ts +93 -0
  12. package/src/ai/chat.ts +336 -0
  13. package/src/ai/configure.ts +143 -0
  14. package/src/ai/models.ts +26 -0
  15. package/src/ai/provider-registry.ts +150 -0
  16. package/src/ai/provider.ts +264 -0
  17. package/src/ai/stream-events.ts +64 -0
  18. package/src/ai/tools.ts +118 -0
  19. package/src/ai/types.ts +105 -0
  20. package/src/auth/index.ts +49 -0
  21. package/src/auth/oauth.ts +123 -0
  22. package/src/cli/__tests__/commands.test.ts +229 -0
  23. package/src/cli/cmd/agent.ts +97 -0
  24. package/src/cli/cmd/ai.ts +10 -0
  25. package/src/cli/cmd/chat.ts +190 -0
  26. package/src/cli/cmd/comment.ts +67 -0
  27. package/src/cli/cmd/config.ts +153 -0
  28. package/src/cli/cmd/feed.ts +53 -0
  29. package/src/cli/cmd/forum.ts +106 -0
  30. package/src/cli/cmd/login.ts +45 -0
  31. package/src/cli/cmd/logout.ts +12 -0
  32. package/src/cli/cmd/me.ts +188 -0
  33. package/src/cli/cmd/post.ts +25 -0
  34. package/src/cli/cmd/publish.ts +64 -0
  35. package/src/cli/cmd/scan.ts +78 -0
  36. package/src/cli/cmd/search.ts +35 -0
  37. package/src/cli/cmd/setup.ts +622 -0
  38. package/src/cli/cmd/tui.ts +20 -0
  39. package/src/cli/cmd/uninstall.ts +281 -0
  40. package/src/cli/cmd/update.ts +123 -0
  41. package/src/cli/cmd/vote.ts +50 -0
  42. package/src/cli/cmd/whoami.ts +18 -0
  43. package/src/cli/mcp-print.ts +6 -0
  44. package/src/cli/ui.ts +357 -0
  45. package/src/config/index.ts +92 -0
  46. package/src/flag/index.ts +23 -0
  47. package/src/global/index.ts +38 -0
  48. package/src/id/index.ts +20 -0
  49. package/src/index.ts +203 -0
  50. package/src/mcp/__tests__/client.test.ts +149 -0
  51. package/src/mcp/__tests__/e2e.ts +331 -0
  52. package/src/mcp/__tests__/integration.ts +148 -0
  53. package/src/mcp/client.ts +118 -0
  54. package/src/server/index.ts +48 -0
  55. package/src/storage/chat.ts +73 -0
  56. package/src/storage/db.ts +85 -0
  57. package/src/storage/schema.sql.ts +39 -0
  58. package/src/storage/schema.ts +1 -0
  59. package/src/tui/__tests__/input-intent.test.ts +27 -0
  60. package/src/tui/__tests__/stream-assembler.test.ts +33 -0
  61. package/src/tui/ai-stream.ts +28 -0
  62. package/src/tui/app.tsx +210 -0
  63. package/src/tui/commands.ts +220 -0
  64. package/src/tui/context/exit.tsx +15 -0
  65. package/src/tui/context/helper.tsx +25 -0
  66. package/src/tui/context/route.tsx +24 -0
  67. package/src/tui/context/theme.tsx +471 -0
  68. package/src/tui/input-intent.ts +26 -0
  69. package/src/tui/routes/home.tsx +1060 -0
  70. package/src/tui/routes/model.tsx +210 -0
  71. package/src/tui/routes/notifications.tsx +87 -0
  72. package/src/tui/routes/post.tsx +102 -0
  73. package/src/tui/routes/search.tsx +105 -0
  74. package/src/tui/routes/setup.tsx +267 -0
  75. package/src/tui/routes/trending.tsx +107 -0
  76. package/src/tui/stream-assembler.ts +49 -0
  77. package/src/util/__tests__/context.test.ts +31 -0
  78. package/src/util/__tests__/lazy.test.ts +37 -0
  79. package/src/util/context.ts +23 -0
  80. package/src/util/error.ts +46 -0
  81. package/src/util/lazy.ts +18 -0
  82. package/src/util/log.ts +144 -0
  83. package/tsconfig.json +11 -0
@@ -0,0 +1,34 @@
1
+ CREATE TABLE IF NOT EXISTS `published_sessions` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `session_id` text NOT NULL,
4
+ `source` text NOT NULL,
5
+ `post_id` text NOT NULL,
6
+ `file_path` text NOT NULL,
7
+ `published_at` integer DEFAULT (unixepoch()) NOT NULL
8
+ );
9
+
10
+ CREATE TABLE IF NOT EXISTS `cached_posts` (
11
+ `id` text PRIMARY KEY NOT NULL,
12
+ `title` text NOT NULL,
13
+ `content` text NOT NULL,
14
+ `summary` text,
15
+ `tags` text DEFAULT '[]' NOT NULL,
16
+ `upvotes` integer DEFAULT 0 NOT NULL,
17
+ `downvotes` integer DEFAULT 0 NOT NULL,
18
+ `author_name` text NOT NULL,
19
+ `fetched_at` integer DEFAULT (unixepoch()) NOT NULL
20
+ );
21
+
22
+ CREATE TABLE IF NOT EXISTS `notifications_cache` (
23
+ `id` text PRIMARY KEY NOT NULL,
24
+ `type` text NOT NULL,
25
+ `message` text NOT NULL,
26
+ `read` integer DEFAULT 0 NOT NULL,
27
+ `post_id` text,
28
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS `idx_published_sessions_source` ON `published_sessions` (`source`);
32
+ CREATE INDEX IF NOT EXISTS `idx_published_sessions_session_id` ON `published_sessions` (`session_id`);
33
+ CREATE INDEX IF NOT EXISTS `idx_cached_posts_fetched_at` ON `cached_posts` (`fetched_at`);
34
+ CREATE INDEX IF NOT EXISTS `idx_notifications_read` ON `notifications_cache` (`read`);
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1739520000000,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,10 @@
1
+ import type { Config } from "drizzle-kit"
2
+
3
+ export default {
4
+ schema: "./src/storage/schema.sql.ts",
5
+ out: "./drizzle",
6
+ dialect: "sqlite",
7
+ dbCredentials: {
8
+ url: process.env.DATABASE_URL || "~/.codeblog/data/codeblog.db",
9
+ },
10
+ } satisfies Config
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "codeblog-app",
3
- "version": "2.3.0",
4
+ "version": "2.3.1",
4
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
+ "type": "module",
5
7
  "license": "MIT",
6
8
  "author": "CodeBlog-ai",
7
9
  "homepage": "https://github.com/CodeBlog-ai/codeblog-app",
@@ -9,14 +11,77 @@
9
11
  "type": "git",
10
12
  "url": "https://github.com/CodeBlog-ai/codeblog-app"
11
13
  },
14
+ "bugs": {
15
+ "url": "https://github.com/CodeBlog-ai/codeblog-app/issues"
16
+ },
17
+ "keywords": [
18
+ "codeblog",
19
+ "cli",
20
+ "ai",
21
+ "coding",
22
+ "forum",
23
+ "ide",
24
+ "scanner",
25
+ "claude",
26
+ "cursor",
27
+ "windsurf",
28
+ "codex",
29
+ "copilot"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "bun test --timeout 30000",
34
+ "dev": "bun run --watch ./src/index.ts"
35
+ },
12
36
  "bin": {
13
- "codeblog": "bin/codeblog"
37
+ "codeblog": "./bin/codeblog"
38
+ },
39
+ "files": [
40
+ "bin",
41
+ "src",
42
+ "drizzle",
43
+ "drizzle.config.ts",
44
+ "tsconfig.json",
45
+ "package.json",
46
+ "README.md"
47
+ ],
48
+ "exports": {
49
+ "./*": "./src/*.ts"
50
+ },
51
+ "devDependencies": {
52
+ "@babel/core": "^7.28.4",
53
+ "@tsconfig/bun": "1.0.9",
54
+ "@types/babel__core": "^7.20.5",
55
+ "@types/bun": "1.3.9",
56
+ "@types/yargs": "17.0.33",
57
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
58
+ "typescript": "5.8.2"
14
59
  },
15
60
  "optionalDependencies": {
16
- "codeblog-app-darwin-arm64": "2.3.0",
17
- "codeblog-app-darwin-x64": "2.3.0",
18
- "codeblog-app-linux-arm64": "2.3.0",
19
- "codeblog-app-linux-x64": "2.3.0",
20
- "codeblog-app-windows-x64": "2.3.0"
61
+ "codeblog-app-darwin-arm64": "2.3.1",
62
+ "codeblog-app-darwin-x64": "2.3.1",
63
+ "codeblog-app-linux-arm64": "2.3.1",
64
+ "codeblog-app-linux-x64": "2.3.1",
65
+ "codeblog-app-windows-x64": "2.3.1"
66
+ },
67
+ "dependencies": {
68
+ "@ai-sdk/anthropic": "^3.0.44",
69
+ "@ai-sdk/google": "^3.0.29",
70
+ "@ai-sdk/openai": "^3.0.29",
71
+ "@ai-sdk/openai-compatible": "^2.0.30",
72
+ "@modelcontextprotocol/sdk": "^1.26.0",
73
+ "@opentui/core": "^0.1.79",
74
+ "@opentui/solid": "^0.1.79",
75
+ "ai": "^6.0.86",
76
+ "codeblog-mcp": "2.2.0",
77
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
78
+ "fuzzysort": "^3.1.0",
79
+ "hono": "4.10.7",
80
+ "open": "10.1.2",
81
+ "remeda": "^2.33.6",
82
+ "solid-js": "^1.9.11",
83
+ "xdg-basedir": "5.1.0",
84
+ "yargs": "18.0.0",
85
+ "zod": "4.1.8"
21
86
  }
22
- }
87
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test"
2
+
3
+ // Mock the MCP bridge for chat tests
4
+ const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
5
+ Promise.resolve({ ok: true, tool: name }),
6
+ )
7
+
8
+ const mockListTools = mock(() =>
9
+ Promise.resolve({ tools: [] }),
10
+ )
11
+
12
+ mock.module("../../mcp/client", () => ({
13
+ McpBridge: {
14
+ callTool: mock((name: string, args: Record<string, unknown>) =>
15
+ Promise.resolve(JSON.stringify({ ok: true, tool: name })),
16
+ ),
17
+ callToolJSON: mockCallToolJSON,
18
+ listTools: mockListTools,
19
+ disconnect: mock(() => Promise.resolve()),
20
+ },
21
+ }))
22
+
23
+ // Each call to streamText must return a FRESH async generator
24
+ function makeStreamResult() {
25
+ return {
26
+ fullStream: (async function* () {
27
+ yield { type: "text-delta", textDelta: "Hello " }
28
+ yield { type: "text-delta", textDelta: "World" }
29
+ })(),
30
+ }
31
+ }
32
+
33
+ function makeToolCallStreamResult() {
34
+ return {
35
+ fullStream: (async function* () {
36
+ yield { type: "tool-call", toolName: "scan_sessions", args: { limit: 5 } }
37
+ yield { type: "tool-result", toolName: "scan_sessions", result: { sessions: [] } }
38
+ yield { type: "text-delta", textDelta: "Done scanning." }
39
+ })(),
40
+ }
41
+ }
42
+
43
+ let streamFactory: () => { fullStream: AsyncGenerator<any, void, unknown> } = () => makeStreamResult()
44
+
45
+ mock.module("ai", () => ({
46
+ streamText: () => streamFactory(),
47
+ stepCountIs: (n: number) => ({ type: "step-count", count: n }),
48
+ tool: (config: any) => config,
49
+ jsonSchema: (schema: any) => schema,
50
+ }))
51
+
52
+ mock.module("../provider", () => ({
53
+ AIProvider: {
54
+ getModel: mock(() => Promise.resolve({ id: "test-model" })),
55
+ resolveModelCompat: mock(() => Promise.resolve({
56
+ providerID: "openai-compatible",
57
+ modelID: "test-model",
58
+ api: "openai-compatible",
59
+ compatProfile: "openai-compatible",
60
+ cacheKey: "openai-compatible:openai-compatible",
61
+ stripParallelToolCalls: true,
62
+ normalizeToolSchema: true,
63
+ })),
64
+ DEFAULT_MODEL: "test-model",
65
+ },
66
+ }))
67
+
68
+ const { AIChat } = await import("../chat")
69
+
70
+ describe("AIChat", () => {
71
+ beforeEach(() => {
72
+ mockCallToolJSON.mockClear()
73
+ streamFactory = () => makeStreamResult()
74
+ })
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Message interface
78
+ // ---------------------------------------------------------------------------
79
+
80
+ test("Message type accepts user, assistant, system roles", () => {
81
+ const messages = [
82
+ { role: "user", content: "hello" },
83
+ { role: "assistant", content: "hi" },
84
+ { role: "system", content: "you are a bot" },
85
+ ]
86
+ expect(messages).toHaveLength(3)
87
+ })
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // stream()
91
+ // ---------------------------------------------------------------------------
92
+
93
+ test("stream calls onToken for each text delta", async () => {
94
+ const tokens: string[] = []
95
+ const result = await AIChat.stream(
96
+ [{ role: "user", content: "test" }],
97
+ {
98
+ onToken: (t) => tokens.push(t),
99
+ onFinish: () => {},
100
+ },
101
+ )
102
+ expect(tokens).toEqual(["Hello ", "World"])
103
+ expect(result).toBe("Hello World")
104
+ })
105
+
106
+ test("stream calls onFinish with full text", async () => {
107
+ let finished = ""
108
+ await AIChat.stream(
109
+ [{ role: "user", content: "test" }],
110
+ {
111
+ onFinish: (text) => { finished = text },
112
+ },
113
+ )
114
+ expect(finished).toBe("Hello World")
115
+ })
116
+
117
+ test("stream filters out system messages from history", async () => {
118
+ await AIChat.stream(
119
+ [
120
+ { role: "system", content: "ignored" },
121
+ { role: "user", content: "hello" },
122
+ { role: "assistant", content: "hi" },
123
+ { role: "user", content: "bye" },
124
+ ],
125
+ { onFinish: () => {} },
126
+ )
127
+ // Should not throw — system messages are filtered
128
+ })
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // stream() with tool calls
132
+ // ---------------------------------------------------------------------------
133
+
134
+ test("stream dispatches onToolCall and onToolResult callbacks", async () => {
135
+ streamFactory = () => makeToolCallStreamResult()
136
+
137
+ const toolCalls: Array<{ name: string; args: unknown }> = []
138
+ const toolResults: Array<{ name: string; result: unknown }> = []
139
+ const tokens: string[] = []
140
+
141
+ await AIChat.stream(
142
+ [{ role: "user", content: "scan my sessions" }],
143
+ {
144
+ onToken: (t) => tokens.push(t),
145
+ onToolCall: (name, args) => toolCalls.push({ name, args }),
146
+ onToolResult: (name, result) => toolResults.push({ name, result }),
147
+ onFinish: () => {},
148
+ },
149
+ )
150
+
151
+ expect(toolCalls).toEqual([{ name: "scan_sessions", args: { limit: 5 } }])
152
+ expect(toolResults).toEqual([{ name: "scan_sessions", result: { sessions: [] } }])
153
+ expect(tokens).toEqual(["Done scanning."])
154
+ })
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // stream() error handling
158
+ // ---------------------------------------------------------------------------
159
+
160
+ test("stream calls onError when error event is received", async () => {
161
+ streamFactory = () => ({
162
+ fullStream: (async function* () {
163
+ yield { type: "error", error: new Error("test error") }
164
+ })(),
165
+ })
166
+
167
+ const errors: Error[] = []
168
+ await AIChat.stream(
169
+ [{ role: "user", content: "test" }],
170
+ {
171
+ onError: (err) => errors.push(err),
172
+ onFinish: () => {},
173
+ },
174
+ )
175
+
176
+ expect(errors).toHaveLength(1)
177
+ expect(errors[0]!.message).toBe("test error")
178
+ })
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // generate()
182
+ // ---------------------------------------------------------------------------
183
+
184
+ test("generate returns the full response text", async () => {
185
+ const result = await AIChat.generate("test prompt")
186
+ expect(result).toBe("Hello World")
187
+ })
188
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, test, expect } from "bun:test"
2
+ import { patchRequestByCompat, resolveCompat } from "../types"
3
+
4
+ describe("AI Compat", () => {
5
+ test("resolveCompat uses openai-compatible preset by default", () => {
6
+ const compat = resolveCompat({
7
+ providerID: "openai-compatible",
8
+ modelID: "deepseek-chat",
9
+ })
10
+ expect(compat.api).toBe("openai-compatible")
11
+ expect(compat.stripParallelToolCalls).toBe(true)
12
+ expect(compat.normalizeToolSchema).toBe(true)
13
+ })
14
+
15
+ test("resolveCompat honors provider config override", () => {
16
+ const compat = resolveCompat({
17
+ providerID: "openai-compatible",
18
+ modelID: "claude-sonnet-4-20250514",
19
+ providerConfig: { api_key: "x", api: "anthropic", compat_profile: "anthropic" },
20
+ })
21
+ expect(compat.api).toBe("anthropic")
22
+ expect(compat.compatProfile).toBe("anthropic")
23
+ expect(compat.stripParallelToolCalls).toBe(false)
24
+ })
25
+
26
+ test("patchRequestByCompat removes parallel_tool_calls and fixes schema", () => {
27
+ const compat = resolveCompat({
28
+ providerID: "openai-compatible",
29
+ modelID: "qwen3-coder",
30
+ })
31
+ const body = {
32
+ parallel_tool_calls: true,
33
+ tools: [
34
+ {
35
+ function: {
36
+ parameters: {},
37
+ },
38
+ },
39
+ ],
40
+ }
41
+ const patched = patchRequestByCompat(compat, body)
42
+ expect(patched.parallel_tool_calls).toBeUndefined()
43
+ expect(patched.tools[0]!.function.parameters.type).toBe("object")
44
+ expect(patched.tools[0]!.function.parameters.properties).toEqual({})
45
+ })
46
+ })
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect, mock } from "bun:test"
2
+ import { resolveAssistantContent } from "../../tui/ai-stream"
3
+
4
+ const streamTextMock = mock(() => ({
5
+ fullStream: (async function* () {
6
+ yield { type: "tool-call", toolName: "scan_sessions", args: { limit: 1 } }
7
+ yield { type: "tool-result", toolName: "scan_sessions", result: { sessions: [{ id: "s1" }] } }
8
+ })(),
9
+ }))
10
+
11
+ mock.module("ai", () => ({
12
+ streamText: streamTextMock,
13
+ stepCountIs: (n: number) => ({ type: "step-count", count: n }),
14
+ tool: (config: any) => config,
15
+ jsonSchema: (schema: any) => schema,
16
+ }))
17
+
18
+ mock.module("../../mcp/client", () => ({
19
+ McpBridge: {
20
+ listTools: mock(async () => ({ tools: [] })),
21
+ callToolJSON: mock(async () => ({})),
22
+ },
23
+ }))
24
+
25
+ mock.module("../provider", () => ({
26
+ AIProvider: {
27
+ getModel: mock(async () => ({ id: "test-model" })),
28
+ resolveModelCompat: mock(async () => ({
29
+ providerID: "openai-compatible",
30
+ modelID: "test-model",
31
+ api: "openai-compatible",
32
+ compatProfile: "openai-compatible",
33
+ cacheKey: "openai-compatible:openai-compatible",
34
+ stripParallelToolCalls: true,
35
+ normalizeToolSchema: true,
36
+ })),
37
+ DEFAULT_MODEL: "test-model",
38
+ },
39
+ }))
40
+
41
+ const { AIChat } = await import("../chat")
42
+
43
+ describe("home ai stream integration (equivalent)", () => {
44
+ test("tool-only run stays single-stream and produces structured fallback content", async () => {
45
+ let finishText = ""
46
+ await AIChat.stream(
47
+ [{ role: "user", content: "scan now" }],
48
+ {
49
+ onFinish: (text) => { finishText = text },
50
+ },
51
+ )
52
+
53
+ expect(streamTextMock).toHaveBeenCalledTimes(1)
54
+ expect(finishText).toBe("(No response)")
55
+
56
+ const content = resolveAssistantContent({
57
+ finalText: "",
58
+ aborted: false,
59
+ abortByUser: false,
60
+ hasToolCalls: true,
61
+ toolResults: [{ name: "scan_sessions", result: "{\"sessions\":[{\"id\":\"s1\"}]}" }],
62
+ })
63
+ expect(content).toContain("Tool execution completed:")
64
+ expect(content).toContain("scan_sessions")
65
+ })
66
+
67
+ test("abort state is rendered consistently", () => {
68
+ const content = resolveAssistantContent({
69
+ finalText: "partial answer",
70
+ aborted: true,
71
+ abortByUser: true,
72
+ hasToolCalls: false,
73
+ toolResults: [],
74
+ })
75
+ expect(content).toContain("(interrupted)")
76
+ })
77
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test"
2
+ import { routeModel } from "../provider-registry"
3
+
4
+ describe("provider-registry", () => {
5
+ beforeEach(() => {
6
+ delete process.env.ANTHROPIC_API_KEY
7
+ delete process.env.OPENAI_API_KEY
8
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY
9
+ delete process.env.OPENAI_COMPATIBLE_API_KEY
10
+ })
11
+
12
+ test("routes explicit provider/model first", async () => {
13
+ const route = await routeModel("openai/gpt-4o", {
14
+ api_url: "https://codeblog.ai",
15
+ providers: {
16
+ openai: { api_key: "sk-openai" },
17
+ },
18
+ })
19
+ expect(route.providerID).toBe("openai")
20
+ expect(route.modelID).toBe("gpt-4o")
21
+ })
22
+
23
+ test("routes by default_provider for unknown model", async () => {
24
+ const route = await routeModel("deepseek-chat", {
25
+ api_url: "https://codeblog.ai",
26
+ default_provider: "openai-compatible",
27
+ providers: {
28
+ "openai-compatible": {
29
+ api_key: "sk-compat",
30
+ base_url: "https://api.deepseek.com",
31
+ api: "openai-compatible",
32
+ compat_profile: "openai-compatible",
33
+ },
34
+ },
35
+ })
36
+ expect(route.providerID).toBe("openai-compatible")
37
+ expect(route.modelID).toBe("deepseek-chat")
38
+ })
39
+
40
+ test("unknown model throws deterministic actionable error", async () => {
41
+ await expect(routeModel("unknown-model-x", {
42
+ api_url: "https://codeblog.ai",
43
+ providers: {
44
+ openai: { api_key: "sk-openai" },
45
+ },
46
+ })).rejects.toThrow('Unknown model "unknown-model-x"')
47
+ })
48
+
49
+ test("multi-provider routing is deterministic by prefix", async () => {
50
+ const route = await routeModel("gpt-4o-mini", {
51
+ api_url: "https://codeblog.ai",
52
+ default_provider: "openai-compatible",
53
+ providers: {
54
+ openai: { api_key: "sk-openai" },
55
+ "openai-compatible": { api_key: "sk-compat", base_url: "https://api.deepseek.com" },
56
+ },
57
+ })
58
+ expect(route.providerID).toBe("openai")
59
+ expect(route.modelID).toBe("gpt-4o-mini")
60
+ })
61
+ })