chatccc 0.2.245 → 0.2.247

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,13 +1,14 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.15",
4
- "description": "A lightweight DeepSeek-optimized coding agent with OpenAI-compatible model support.",
3
+ "version": "0.1.16",
4
+ "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
7
7
  "agent",
8
8
  "deepseek",
9
9
  "coding-agent",
10
10
  "openai-compatible",
11
+ "anthropic",
11
12
  "cli"
12
13
  ],
13
14
  "repository": {
@@ -47,6 +48,7 @@
47
48
  "typecheck": "node node_modules/typescript/bin/tsc --noEmit"
48
49
  },
49
50
  "dependencies": {
51
+ "@ai-sdk/anthropic": "^3.0.105",
50
52
  "@ai-sdk/openai-compatible": "^2.0.47",
51
53
  "@vscode/ripgrep": "^1.18.0",
52
54
  "ai": "^6.0.184"
@@ -15,12 +15,18 @@ const rawLogWriteLineMock = vi.fn();
15
15
  const rawLogCloseMock = vi.fn();
16
16
  const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
17
17
  const originalStreaming = config.streaming;
18
+ const originalProvider = config.provider;
18
19
  const createOpenAICompatibleMock = vi.fn(() => (modelId: string) => ({ modelId }));
20
+ const createAnthropicMock = vi.fn(() => (modelId: string) => ({ modelId, provider: "anthropic" }));
19
21
 
20
22
  vi.mock("@ai-sdk/openai-compatible", () => ({
21
23
  createOpenAICompatible: createOpenAICompatibleMock,
22
24
  }));
23
25
 
26
+ vi.mock("@ai-sdk/anthropic", () => ({
27
+ createAnthropic: createAnthropicMock,
28
+ }));
29
+
24
30
  vi.mock("ai", () => ({
25
31
  streamText: streamTextMock,
26
32
  generateText: generateTextMock,
@@ -49,6 +55,7 @@ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
49
55
  }
50
56
 
51
57
  beforeEach(() => {
58
+ config.provider = "openai";
52
59
  config.streaming = true;
53
60
  });
54
61
 
@@ -59,12 +66,64 @@ afterEach(() => {
59
66
  rawLogWriteLineMock.mockReset();
60
67
  rawLogCloseMock.mockReset();
61
68
  config.rawStreamLogs = structuredClone(originalRawStreamLogs);
69
+ config.provider = originalProvider;
62
70
  config.streaming = originalStreaming;
63
71
  createOpenAICompatibleMock.mockClear();
72
+ createAnthropicMock.mockClear();
64
73
  vi.useRealTimers();
65
74
  });
66
75
 
67
76
  describe("ChatSession response transport", () => {
77
+ it("uses the OpenAI-compatible provider by default", async () => {
78
+ const { ChatSession } = await import("../index.js");
79
+
80
+ new ChatSession({ apiKey: "sk-test", baseURL: "https://gateway.example", model: "model-a" });
81
+
82
+ expect(createOpenAICompatibleMock).toHaveBeenLastCalledWith(expect.objectContaining({
83
+ baseURL: "https://gateway.example",
84
+ apiKey: "sk-test",
85
+ }));
86
+ expect(createAnthropicMock).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("uses Anthropic Messages with the same base URL and appends /v1 when needed", async () => {
90
+ const { ChatSession } = await import("../index.js");
91
+
92
+ new ChatSession({
93
+ provider: "anthropic",
94
+ apiKey: "sk-test",
95
+ baseURL: "https://gateway.example/",
96
+ model: "model-a",
97
+ });
98
+
99
+ expect(createAnthropicMock).toHaveBeenLastCalledWith({
100
+ baseURL: "https://gateway.example/v1",
101
+ apiKey: "sk-test",
102
+ });
103
+ expect(createOpenAICompatibleMock).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("keeps an existing /v1 suffix for Anthropic and streams without OpenAI-only effort options", async () => {
107
+ const { ChatSession } = await import("../index.js");
108
+ streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
109
+ const session = new ChatSession({
110
+ provider: "anthropic",
111
+ apiKey: "sk-test",
112
+ baseURL: "https://gateway.example/v1/",
113
+ model: "model-a",
114
+ effort: "high",
115
+ });
116
+
117
+ await collect(session.chat("hello"));
118
+
119
+ expect(createAnthropicMock).toHaveBeenLastCalledWith({
120
+ baseURL: "https://gateway.example/v1",
121
+ apiKey: "sk-test",
122
+ });
123
+ expect(streamTextMock).toHaveBeenCalledOnce();
124
+ expect(streamTextMock.mock.calls[0]?.[0]).not.toHaveProperty("providerOptions");
125
+ });
126
+
68
127
  it("asks the provider to include usage in streaming responses", async () => {
69
128
  const { ChatSession } = await import("../index.js");
70
129
 
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
 
3
3
  import { ChatSession } from "../index.js";
4
- import { config } from "../config.js";
4
+ import { config, normalizeDeepCccProvider } from "../config.js";
5
5
 
6
6
  const originalDeepSeekApiKey = process.env.DEEPSEEK_API_KEY;
7
7
  const originalDeepCccApiKey = config.apiKey;
@@ -16,6 +16,14 @@ afterEach(() => {
16
16
  });
17
17
 
18
18
  describe("builtin ChatSession config", () => {
19
+ it("defaults provider selection to openai and accepts anthropic case-insensitively", () => {
20
+ expect(normalizeDeepCccProvider(undefined)).toBe("openai");
21
+ expect(normalizeDeepCccProvider("")).toBe("openai");
22
+ expect(normalizeDeepCccProvider("OPENAI")).toBe("openai");
23
+ expect(normalizeDeepCccProvider("Anthropic")).toBe("anthropic");
24
+ expect(() => normalizeDeepCccProvider("antropic")).toThrow(/openai.*anthropic/i);
25
+ });
26
+
19
27
  it("uses the builtin ~/.deepccc config when no apiKey is passed", () => {
20
28
  expect(() => new ChatSession()).not.toThrow();
21
29
  });
@@ -22,6 +22,10 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
22
22
  createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
23
23
  }));
24
24
 
25
+ vi.mock("@ai-sdk/anthropic", () => ({
26
+ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId })),
27
+ }));
28
+
25
29
  vi.mock("ai", () => ({
26
30
  streamText: aiMocks.streamText,
27
31
  generateText: aiMocks.generateText,
@@ -25,9 +25,13 @@ vi.mock("../config.js", async () => {
25
25
  };
26
26
  });
27
27
 
28
- vi.mock("@ai-sdk/openai-compatible", () => ({
29
- createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
30
- }));
28
+ vi.mock("@ai-sdk/openai-compatible", () => ({
29
+ createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
30
+ }));
31
+
32
+ vi.mock("@ai-sdk/anthropic", () => ({
33
+ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId })),
34
+ }));
31
35
 
32
36
  vi.mock("ai", () => ({
33
37
  streamText: aiMocks.streamText,
@@ -51,13 +51,19 @@ interface JsonLine {
51
51
  [key: string]: unknown;
52
52
  }
53
53
 
54
- function parsePositiveIntegerOption(name: string, value: string): number {
54
+ function parsePositiveIntegerOption(name: string, value: string): number {
55
55
  const parsed = Number(value);
56
56
  if (!Number.isInteger(parsed) || parsed <= 0) {
57
57
  throw new Error(`${name} must be a positive integer`);
58
58
  }
59
59
  return parsed;
60
- }
60
+ }
61
+
62
+ function parseProviderOption(value: string): "openai" | "anthropic" {
63
+ const normalized = value.trim().toLowerCase();
64
+ if (normalized === "openai" || normalized === "anthropic") return normalized;
65
+ throw new Error(`--provider must be "openai" or "anthropic", received: ${value}`);
66
+ }
61
67
 
62
68
  function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
63
69
  const config: ChatSessionConfig = {};
@@ -72,7 +78,10 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
72
78
  for (let i = 0; i < argv.length; i++) {
73
79
  const arg = argv[i];
74
80
  const next = argv[i + 1];
75
- if (arg === "--model" && next !== undefined) {
81
+ if (arg === "--provider" && next !== undefined) {
82
+ config.provider = parseProviderOption(next);
83
+ i++;
84
+ } else if (arg === "--model" && next !== undefined) {
76
85
  config.model = next;
77
86
  i++;
78
87
  } else if (arg === "--effort" && next !== undefined) {
@@ -131,10 +140,11 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
131
140
  "",
132
141
  "Usage: deepccc [options]",
133
142
  "",
134
- "Options:",
135
- ` --model <name> Model name (current default ${appConfig.model})`,
143
+ "Options:",
144
+ ` --provider <name> API protocol: openai or anthropic (current default ${appConfig.provider})`,
145
+ ` --model <name> Model name (current default ${appConfig.model})`,
136
146
  ` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.effort)`,
137
- ` --base-url <url> OpenAI-compatible API base URL (current default ${appConfig.baseURL})`,
147
+ ` --base-url <url> Provider API base URL (current default ${appConfig.baseURL})`,
138
148
  " --api-key <key> API key",
139
149
  " --cwd <path> Working directory",
140
150
  " --max-steps <n> Optional tool-step limit. Omit for no step limit",
@@ -375,7 +385,8 @@ async function runRepl(args: ParsedArgs): Promise<void> {
375
385
  }
376
386
 
377
387
  console.log(`${C.dim}DeepCCC agent${C.reset}`);
378
- console.log(`${C.dim}Model: ${args.config.model ?? appConfig.model}${C.reset}`);
388
+ console.log(`${C.dim}Provider: ${args.config.provider ?? appConfig.provider}${C.reset}`);
389
+ console.log(`${C.dim}Model: ${args.config.model ?? appConfig.model}${C.reset}`);
379
390
  console.log(`${C.dim}Directory: ${cwd}${C.reset}`);
380
391
  console.log(`${C.dim}Session: ${resolvedSession.sessionId} (${resolvedSession.mode === "new" ? "new" : "resumed"})${C.reset}`);
381
392
  console.log(`${C.dim}Type a message to chat. Double Ctrl+C interrupts generation or exits. Type exit to quit.${C.reset}`);
@@ -2,7 +2,11 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
+ export type DeepCccProvider = "openai" | "anthropic";
6
+
5
7
  export interface DeepCccConfig {
8
+ /** API protocol/provider. Defaults to OpenAI-compatible. */
9
+ provider: DeepCccProvider;
6
10
  apiKey: string;
7
11
  baseURL: string;
8
12
  model: string;
@@ -23,6 +27,7 @@ export const RAW_STREAM_LOGS_DIR = join(DEEPCCC_HOME, "raw-stream-logs");
23
27
  const CONFIG_PATH = join(DEEPCCC_HOME, "config.json");
24
28
 
25
29
  const DEFAULT_CONFIG: DeepCccConfig = {
30
+ provider: "openai",
26
31
  apiKey: "",
27
32
  baseURL: "https://api.deepseek.com/v1",
28
33
  model: "deepseek-v4-pro",
@@ -60,6 +65,13 @@ function numberEnv(name: string): number | undefined {
60
65
  return Number.isFinite(value) && value >= 0 ? value : undefined;
61
66
  }
62
67
 
68
+ export function normalizeDeepCccProvider(value: unknown): DeepCccProvider {
69
+ if (value === undefined || value === null || String(value).trim() === "") return "openai";
70
+ const normalized = String(value).trim().toLowerCase();
71
+ if (normalized === "openai" || normalized === "anthropic") return normalized;
72
+ throw new Error(`DEEPCCC_PROVIDER/provider must be "openai" or "anthropic", received: ${String(value)}`);
73
+ }
74
+
63
75
  function loadConfig(): DeepCccConfig {
64
76
  const file = readConfigFile();
65
77
  const rawLogs: Partial<DeepCccConfig["rawStreamLogs"]> = file.rawStreamLogs && typeof file.rawStreamLogs === "object"
@@ -67,6 +79,7 @@ function loadConfig(): DeepCccConfig {
67
79
  : {};
68
80
 
69
81
  return {
82
+ provider: normalizeDeepCccProvider(env("DEEPCCC_PROVIDER") ?? file.provider ?? DEFAULT_CONFIG.provider),
70
83
  apiKey: env("DEEPCCC_API_KEY") ?? env("DEEPSEEK_API_KEY") ?? file.apiKey ?? DEFAULT_CONFIG.apiKey,
71
84
  baseURL: env("DEEPCCC_BASE_URL") ?? env("DEEPSEEK_BASE_URL") ?? file.baseURL ?? DEFAULT_CONFIG.baseURL,
72
85
  model: env("DEEPCCC_MODEL") ?? env("DEEPSEEK_MODEL") ?? file.model ?? DEFAULT_CONFIG.model,
@@ -4,14 +4,20 @@
4
4
  * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块调用。
5
5
  */
6
6
 
7
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
7
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
8
+ import { createAnthropic } from "@ai-sdk/anthropic";
8
9
  import { generateText, isLoopFinished, stepCountIs, streamText, type TextStreamPart } from "ai";
9
10
  import { existsSync, readFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
 
14
- import { config as appConfig, RAW_STREAM_LOGS_DIR } from "./config.js";
15
+ import {
16
+ config as appConfig,
17
+ normalizeDeepCccProvider,
18
+ RAW_STREAM_LOGS_DIR,
19
+ type DeepCccProvider,
20
+ } from "./config.js";
15
21
  import {
16
22
  createRawStreamLog,
17
23
  type RawStreamLogHandle,
@@ -168,17 +174,24 @@ export function loadPlatformCommandPrompt(
168
174
  return "";
169
175
  }
170
176
 
171
- function normalizeMaxSteps(value: number | undefined): number | undefined {
177
+ function normalizeMaxSteps(value: number | undefined): number | undefined {
172
178
  if (value === undefined) return undefined;
173
179
  if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
174
180
  throw new Error("maxSteps must be a positive integer when provided");
175
181
  }
176
- return value;
177
- }
182
+ return value;
183
+ }
184
+
185
+ function normalizeAnthropicBaseURL(baseURL: string): string {
186
+ const normalized = baseURL.trim().replace(/\/+$/, "");
187
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
188
+ }
178
189
 
179
- export interface ChatSessionConfig {
180
- /** OpenAI-compatible service base URL. Defaults to DEEPCCC_BASE_URL/config. */
181
- baseURL?: string;
190
+ export interface ChatSessionConfig {
191
+ /** API protocol/provider. Defaults to DEEPCCC_PROVIDER/config, then openai. */
192
+ provider?: DeepCccProvider;
193
+ /** Provider service base URL. Defaults to DEEPCCC_BASE_URL/config. */
194
+ baseURL?: string;
182
195
  /** API key. Defaults to DEEPCCC_API_KEY/config. */
183
196
  apiKey?: string;
184
197
  /** Model id. Defaults to DEEPCCC_MODEL/config. */
@@ -252,8 +265,9 @@ interface ChatMessage {
252
265
  content: string;
253
266
  }
254
267
 
255
- export class ChatSession {
256
- private model: any;
268
+ export class ChatSession {
269
+ private model: any;
270
+ private provider: DeepCccProvider;
257
271
  private cwd: string;
258
272
  private context: BuiltinContextManager;
259
273
  private compactionTimeoutMs: number;
@@ -276,17 +290,26 @@ export class ChatSession {
276
290
  );
277
291
  }
278
292
 
279
- const baseURL = overrides.baseURL ?? appConfig.baseURL;
280
- const modelId = overrides.model ?? appConfig.model;
281
- this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
282
-
283
- const provider = createOpenAICompatible({
284
- name: "deepccc",
285
- baseURL,
286
- apiKey,
287
- includeUsage: true,
288
- });
289
- this.model = provider(modelId);
293
+ const baseURL = overrides.baseURL ?? appConfig.baseURL;
294
+ const modelId = overrides.model ?? appConfig.model;
295
+ this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
296
+ this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
297
+
298
+ if (this.provider === "anthropic") {
299
+ const provider = createAnthropic({
300
+ baseURL: normalizeAnthropicBaseURL(baseURL),
301
+ apiKey,
302
+ });
303
+ this.model = provider(modelId);
304
+ } else {
305
+ const provider = createOpenAICompatible({
306
+ name: "deepccc",
307
+ baseURL,
308
+ apiKey,
309
+ includeUsage: true,
310
+ });
311
+ this.model = provider(modelId);
312
+ }
290
313
  this.cwd = options.cwd ?? process.cwd();
291
314
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
292
315
  this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
@@ -393,7 +416,9 @@ export class ChatSession {
393
416
  abortSignal: signal,
394
417
  // DeepSeek OpenAI 兼容接口:providerOptions.deepseek.reasoningEffort
395
418
  // 由 @ai-sdk/openai-compatible 自动映射为请求体 reasoning_effort 字段
396
- ...(this.effort ? { providerOptions: { deepseek: { reasoningEffort: this.effort } } } : {}),
419
+ ...(this.provider === "openai" && this.effort
420
+ ? { providerOptions: { deepseek: { reasoningEffort: this.effort } } }
421
+ : {}),
397
422
  };
398
423
  let stream: AsyncIterable<TextStreamPart<any>>;
399
424
  if (appConfig.streaming) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.245",
3
+ "version": "0.2.247",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "postinstall": "node scripts/postinstall-sharp-check.mjs"
50
50
  },
51
51
  "dependencies": {
52
+ "@ai-sdk/anthropic": "^3.0.105",
52
53
  "@ai-sdk/openai-compatible": "^2.0.47",
53
54
  "@larksuiteoapi/node-sdk": "^1.59.0",
54
55
  "@openilink/openilink-sdk-node": "^0.6.0",
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  import { config } from "../../deepccc-agent/src/config.ts";
8
8
  import { estimateBuiltinContextTokens } from "../../deepccc-agent/src/context.ts";
@@ -13,11 +13,16 @@ const createRawStreamLogMock = vi.fn();
13
13
  const rawLogWriteLineMock = vi.fn();
14
14
  const rawLogCloseMock = vi.fn();
15
15
  const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
16
+ const originalProvider = config.provider;
16
17
 
17
18
  vi.mock("@ai-sdk/openai-compatible", () => ({
18
19
  createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
19
20
  }));
20
21
 
22
+ vi.mock("@ai-sdk/anthropic", () => ({
23
+ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId })),
24
+ }));
25
+
21
26
  vi.mock("ai", () => ({
22
27
  streamText: streamTextMock,
23
28
  generateText: generateTextMock,
@@ -45,6 +50,10 @@ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
45
50
  for (const part of parts) yield part;
46
51
  }
47
52
 
53
+ beforeEach(() => {
54
+ config.provider = "openai";
55
+ });
56
+
48
57
  afterEach(() => {
49
58
  streamTextMock.mockReset();
50
59
  generateTextMock.mockReset();
@@ -52,6 +61,7 @@ afterEach(() => {
52
61
  rawLogWriteLineMock.mockReset();
53
62
  rawLogCloseMock.mockReset();
54
63
  config.rawStreamLogs = structuredClone(originalRawStreamLogs);
64
+ config.provider = originalProvider;
55
65
  vi.useRealTimers();
56
66
  });
57
67
 
@@ -22,6 +22,10 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
22
22
  createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
23
23
  }));
24
24
 
25
+ vi.mock("@ai-sdk/anthropic", () => ({
26
+ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId })),
27
+ }));
28
+
25
29
  vi.mock("ai", () => ({
26
30
  streamText: aiMocks.streamText,
27
31
  generateText: aiMocks.generateText,
@@ -170,7 +174,7 @@ describe("builtin file-tools permission integration", () => {
170
174
  describe("builtin ChatSession permission integration", () => {
171
175
  it("permissionMode bypass lets high-risk run_command execute", async () => {
172
176
  const session = new ChatSession(
173
- { apiKey: "sk-test" },
177
+ { apiKey: "sk-test", provider: "openai" },
174
178
  { sessionId: "perm-bypass", permissionMode: "bypass" },
175
179
  );
176
180
  streamTextMock.mockReturnValueOnce({ textStream: textStream("hi") });
@@ -182,7 +186,7 @@ describe("builtin ChatSession permission integration", () => {
182
186
 
183
187
  it("default ask mode without resolver denies high-risk run_command", async () => {
184
188
  const session = new ChatSession(
185
- { apiKey: "sk-test" },
189
+ { apiKey: "sk-test", provider: "openai" },
186
190
  { sessionId: "perm-ask" },
187
191
  );
188
192
  streamTextMock.mockReturnValueOnce({ textStream: textStream("hi") });
@@ -197,7 +201,11 @@ describe("builtin ChatSession permission integration", () => {
197
201
  describe("ccc-adapter uses bypass mode (aligns with claude/codex)", () => {
198
202
  it("tools created via ccc adapter run high-risk commands without asking", async () => {
199
203
  const { createCccAdapter } = await import("../adapters/ccc-adapter.ts");
200
- const adapter = createCccAdapter({ apiKey: "sk-test", contextDir: testHome.dir });
204
+ const adapter = createCccAdapter({
205
+ apiKey: "sk-test",
206
+ provider: "openai",
207
+ contextDir: testHome.dir,
208
+ });
201
209
  const { sessionId } = await adapter.createSession(testHome.dir);
202
210
  streamTextMock.mockReturnValueOnce({ textStream: textStream("hi") });
203
211
 
@@ -11,6 +11,10 @@ vi.mock("@ai-sdk/openai-compatible", () => ({
11
11
  createOpenAICompatible: vi.fn(() => (modelId: string) => ({ modelId })),
12
12
  }));
13
13
 
14
+ vi.mock("@ai-sdk/anthropic", () => ({
15
+ createAnthropic: vi.fn(() => (modelId: string) => ({ modelId })),
16
+ }));
17
+
14
18
  vi.mock("ai", () => ({
15
19
  streamText: streamTextMock,
16
20
  generateText: generateTextMock,
@@ -54,6 +58,7 @@ describe("createCccAdapter", () => {
54
58
  const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-meta-"));
55
59
  const adapter = createCccAdapter({
56
60
  apiKey: "sk-test",
61
+ provider: "openai",
57
62
  contextDir,
58
63
  model: "deepseek-v4-pro",
59
64
  });
@@ -74,6 +79,7 @@ describe("createCccAdapter", () => {
74
79
  const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-stream-"));
75
80
  const adapter = createCccAdapter({
76
81
  apiKey: "sk-test",
82
+ provider: "openai",
77
83
  contextDir,
78
84
  model: "deepseek-v4-flash",
79
85
  });
@@ -101,6 +107,7 @@ describe("createCccAdapter", () => {
101
107
  const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-tools-"));
102
108
  const adapter = createCccAdapter({
103
109
  apiKey: "sk-test",
110
+ provider: "openai",
104
111
  contextDir,
105
112
  model: "deepseek-v4-flash",
106
113
  });
@@ -136,6 +143,7 @@ describe("createCccAdapter", () => {
136
143
  const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-status-"));
137
144
  const adapter = createCccAdapter({
138
145
  apiKey: "sk-test",
146
+ provider: "openai",
139
147
  contextDir,
140
148
  compactAtTokens: 1,
141
149
  keepRecentMessages: 1,
@@ -168,6 +176,7 @@ describe("createCccAdapter", () => {
168
176
  const contextDir = await mkdtemp(join(tmpdir(), "chatccc-ccc-adapter-effort-"));
169
177
  const adapter = createCccAdapter({
170
178
  apiKey: "sk-test",
179
+ provider: "openai",
171
180
  contextDir,
172
181
  effort: "xhigh",
173
182
  });
@@ -72,7 +72,7 @@ const baseAppConfig: AppConfig = {
72
72
  onDemandMonthlyBudget: 1000,
73
73
  },
74
74
  codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", alternativeModel: "initial-codex-alt-model", effort: "initial-codex-effort", fastMode: false },
75
- ccc: { enabled: true, defaultAgent: false, DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model", alternativeModel: "initial-ccc-alt-model", effort: "initial-ccc-effort" },
75
+ ccc: { enabled: true, defaultAgent: false, DEEPSEEK_API_KEY: "initial-ccc-key", DEEPSEEK_BASE_URL: "https://initial.deepseek.test/v1", model: "initial-ccc-model", alternativeModel: "initial-ccc-alt-model", effort: "initial-ccc-effort", provider: "" },
76
76
  };
77
77
 
78
78
  // 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { normalizeCccProviderOverride } from "../config-utils.ts";
3
+
4
+ describe("normalizeCccProviderOverride", () => {
5
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
6
+
7
+ afterEach(() => {
8
+ warnSpy.mockClear();
9
+ });
10
+
11
+ it("returns empty string for missing / non-string values (no override)", () => {
12
+ expect(normalizeCccProviderOverride(undefined)).toBe("");
13
+ expect(normalizeCccProviderOverride(null)).toBe("");
14
+ expect(normalizeCccProviderOverride(42)).toBe("");
15
+ expect(normalizeCccProviderOverride({})).toBe("");
16
+ expect(warnSpy).not.toHaveBeenCalled();
17
+ });
18
+
19
+ it("treats empty/whitespace and legacy 'default' as no override", () => {
20
+ expect(normalizeCccProviderOverride("")).toBe("");
21
+ expect(normalizeCccProviderOverride(" ")).toBe("");
22
+ expect(normalizeCccProviderOverride("default")).toBe("");
23
+ expect(normalizeCccProviderOverride("DEFAULT")).toBe("");
24
+ expect(warnSpy).not.toHaveBeenCalled();
25
+ });
26
+
27
+ it("normalizes openai / anthropic case-insensitively", () => {
28
+ expect(normalizeCccProviderOverride("openai")).toBe("openai");
29
+ expect(normalizeCccProviderOverride("OPENAI")).toBe("openai");
30
+ expect(normalizeCccProviderOverride(" anthropic ")).toBe("anthropic");
31
+ expect(normalizeCccProviderOverride("Anthropic")).toBe("anthropic");
32
+ expect(warnSpy).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it("ignores invalid values with a warning", () => {
36
+ expect(normalizeCccProviderOverride("gemini")).toBe("");
37
+ expect(normalizeCccProviderOverride("oai")).toBe("");
38
+ expect(warnSpy).toHaveBeenCalledTimes(2);
39
+ });
40
+ });
@@ -31,10 +31,12 @@ describe("CCC Agent ChatCCC configuration", () => {
31
31
  DEEPSEEK_BASE_URL: "https://chatccc.example.com/v1",
32
32
  model: "chatccc-model",
33
33
  effort: "high",
34
+ provider: "",
34
35
  });
35
36
 
36
37
  getAdapterForTool("ccc");
37
38
 
39
+ // provider 留空时不传 → ChatSession 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER)
38
40
  expect(createCccAdapterMock).toHaveBeenCalledWith({
39
41
  apiKey: "chatccc-api-key",
40
42
  baseURL: "https://chatccc.example.com/v1",
@@ -42,4 +44,23 @@ describe("CCC Agent ChatCCC configuration", () => {
42
44
  effort: "high",
43
45
  });
44
46
  });
47
+
48
+ it("forwards ccc.provider override to createCccAdapter", () => {
49
+ Object.assign(config.ccc, {
50
+ DEEPSEEK_API_KEY: "chatccc-api-key",
51
+ DEEPSEEK_BASE_URL: "https://chatccc.example.com/v1",
52
+ model: "chatccc-model",
53
+ effort: "",
54
+ provider: "anthropic",
55
+ });
56
+
57
+ getAdapterForTool("ccc");
58
+
59
+ expect(createCccAdapterMock).toHaveBeenCalledWith({
60
+ apiKey: "chatccc-api-key",
61
+ baseURL: "https://chatccc.example.com/v1",
62
+ model: "chatccc-model",
63
+ provider: "anthropic",
64
+ });
65
+ });
45
66
  });
@@ -107,6 +107,7 @@ describe("unflattenConfig", () => {
107
107
  CHATCCC_CCC_MODEL: "deepseek-v4-flash",
108
108
  CHATCCC_CCC_ALTERNATIVE_MODEL: "deepseek-v4-pro",
109
109
  CHATCCC_CCC_EFFORT: "max",
110
+ CHATCCC_CCC_PROVIDER: "anthropic",
110
111
  }),
111
112
  ).toEqual({
112
113
  ccc: {
@@ -117,6 +118,7 @@ describe("unflattenConfig", () => {
117
118
  model: "deepseek-v4-flash",
118
119
  alternativeModel: "deepseek-v4-pro",
119
120
  effort: "max",
121
+ provider: "anthropic",
120
122
  },
121
123
  });
122
124
  });
@@ -49,6 +49,7 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
49
49
 
50
50
  const chatConfig: ChatSessionConfig = {
51
51
  apiKey: options.apiKey,
52
+ ...(options.provider !== undefined ? { provider: options.provider } : {}),
52
53
  ...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
53
54
  ...(options.model !== undefined ? { model: options.model } : {}),
54
55
  ...(options.effort !== undefined ? { effort: options.effort } : {}),