CHAPTER 01

项目搭建与 API 客户端

初始化 TypeScript / Python 项目,实现多模型 LLM 客户端

1. 什么是 AI Agent

在动手写代码之前,先建立一个关键认知:AI Agent 不是聊天机器人

普通的 Chatbot 只能接收文本、生成文本 —— 你问它一个问题,它给你一段回答,仅此而已。而 AI Agent 能够感知环境、自主推理、采取行动,形成一个持续运转的循环:

感知 Perceive 读取文件、搜索代码 获取命令输出 推理 Reason 分析问题、制定计划 决定下一步操作 行动 Act 修改文件、运行命令 调用 API 观察结果,继续循环

这个感知 → 推理 → 行动的循环,就是 Agent 的核心运作模式。与 Chatbot 的"一问一答"不同,Agent 会自主决定调用什么工具、以什么顺序执行、什么时候需要更多信息、什么时候任务完成。

Tool Use 是 Agent 的关键能力
LLM 本身只能生成文本,无法直接操作文件或执行命令。Tool Use(工具调用)协议是让 LLM 从"文本生成器"变成"行动者"的桥梁:LLM 通过结构化的 JSON 请求告诉宿主程序"我想调用某个工具",宿主执行后把结果返回给 LLM,LLM 再决定下一步。这个机制我们将在第 4 章详细实现。

AI Agent 有很多形态 —— 客服 Agent、数据分析 Agent、自动化运维 Agent。我们要构建的是 Coding Agent:一个以代码仓库为"环境"、以文件读写和命令执行为"工具"、以 LLM 为"大脑"的编程助手。

2. 我们要构建什么

在这个课程中,我们将从零开始构建一个 AI Coding Agent —— 一个能理解你的代码库、执行文件操作、运行命令的智能编程助手,类似于 Claude Code、Cursor Agent 的核心功能。

本课程提供 TypeScriptPython 两种语言实现,你可以选择自己熟悉的语言跟随。LLM API 本质上是 HTTP + JSON 的交互,这两门语言都有天然优势:TypeScript 的类型系统帮你精确描述 API 结构,Python 的 dataclassProtocol 同样清晰;两者的 async/await 语法让流式处理非常自然,且 Anthropic 和 OpenAI 都提供一等公民级别的官方 SDK。

整个项目大约 18 章,从最基础的 API 调用开始,逐步构建出一个功能完整的 Agent:

与 LLM 对话

API 客户端、流式响应、多轮对话、Tool Use —— 这是 Agent 的通信基础。

工具系统

文件读写、命令执行、代码搜索 —— Agent 操作代码库的"双手"。

Agent Loop

核心循环、自主规划、错误处理 —— Agent 自主思考和行动的"大脑"。

CLI 体验

REPL、Markdown 渲染、安全防护 —— 让 Agent 好用且安全的"界面"。

本章是第一步:搭建项目骨架,定义核心类型,并实现能同时对接 Anthropic(Claude)OpenAI 兼容模型(DeepSeek、Qwen 等)的 LLM 客户端。

3. 项目初始化

项目结构

typescript/
├── src/
│ ├── llm/
│ │ ├── types.ts # 核心类型定义
│ │ ├── anthropic.ts # Anthropic Provider
│ │ ├── openai-compatible.ts # OpenAI 兼容 Provider
│ │ ├── factory.ts # 工厂函数
│ │ ├── helpers.ts # 辅助函数
│ │ └── index.ts # 统一导出
│ └── history.ts # 对话历史管理
├── tests/
│ ├── llm.test.ts
│ ├── stream.test.ts
│ ├── history.test.ts
│ └── tools.test.ts
├── package.json
└── tsconfig.json
python/
├── src/
│ ├── llm/
│ │ ├── __init__.py # 统一导出
│ │ ├── types.py # 核心类型定义
│ │ ├── anthropic_provider.py # Anthropic Provider
│ │ ├── openai_compatible.py # OpenAI 兼容 Provider
│ │ └── factory.py # 工厂函数
│ └── ...
├── tests/
│ └── test_llm.py
└── pyproject.toml

项目配置

package.json

{
  "name": "ai-coding-agent",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.39.0",
    "openai": "^4.80.0"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  }
}

依赖非常精简:@anthropic-ai/sdkopenai 是两个官方 SDK,vitest 是测试框架。注意 "type": "module" 表示我们使用 ES Module。

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "tests"]
}
为什么选择 ES2022 + Node16?
target: "ES2022" 让我们可以使用 Array.at()、顶层 await 等现代特性。module: "Node16" 配合 moduleResolution: "Node16" 是 Node.js 原生 ESM 支持的标准配置,import 路径必须带 .js 后缀(即使源码是 .ts)。

strict: true 开启所有严格类型检查,这在与 LLM API 交互时尤其重要 —— 你会希望编译器帮你捕获类型不匹配的问题,而不是在运行时遇到奇怪的错误。

pyproject.toml

[project]
name = "ai-coding-agent"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "anthropic>=0.39.0",
    "openai>=1.50.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.24",
]

依赖同样精简:anthropicopenai 两个官方 SDK,pytest + pytest-asyncio 做测试。推荐使用 Python 3.11+,可以使用 X | Y 联合类型语法。

Python 项目配置
我们使用 pyproject.toml 作为项目配置文件,这是 Python 现代项目的标准做法。dataclassProtocol 是 Python 的类型系统核心工具,前者定义数据结构,后者定义接口契约(类似 TypeScript 的 interface)。

from __future__ import annotations 开启延迟注解求值,让类型注解在运行时不被立即求值,避免循环引用问题。

4. 核心类型定义

在写任何实现代码之前,先定义好类型。这些类型是整个 Agent 的"语言" —— 所有模块都通过它们通信。

// Core message type for LLM conversation
export interface Message {
  role: "user" | "assistant";
  content: string;
}

// Response from a chat completion
export interface ChatResponse {
  text: string;
  stopReason: "end_turn" | "max_tokens";
  usage: { inputTokens: number; outputTokens: number };
}

// Options for chat completion
export interface ChatOptions {
  system?: string;
  maxTokens?: number;
}

// Unified interface for LLM providers
export interface LLMProvider {
  chat(messages: Message[], options?: ChatOptions): Promise<ChatResponse>;
}
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Protocol


@dataclass
class Message:
    """Core message type for LLM conversation."""
    role: str  # "user" or "assistant"
    content: str


@dataclass
class ChatResponse:
    """Response from a chat completion."""
    text: str
    stop_reason: str  # "end_turn" or "max_tokens"
    usage: dict = field(default_factory=dict)


@dataclass
class ChatOptions:
    """Options for chat completion."""
    system: str | None = None
    max_tokens: int | None = None


class LLMProvider(Protocol):
    """Unified interface for LLM providers."""

    async def chat(
        self, messages: list[Message], options: ChatOptions | None = None
    ) -> ChatResponse: ...

这段代码值得仔细看。让我们逐个拆解:

消息内容的双重表示

Messagecontent 字段是 string | ContentBlock[],这是一个关键设计。简单的文本对话用 string 就够了,但当涉及 Tool Use 时,一条消息可能同时包含文字和工具调用请求,必须用 ContentBlock[] 来表达。

为什么 ContentBlock 要用联合类型(Union Type)?
TypeScript 的可辨识联合(Discriminated Union)是处理 LLM 响应的利器。每个 block 都有一个 type 字段作为标签,你可以用 switch(block.type) 来安全地处理不同类型,编译器会自动推断每个分支的类型。

例如 if (block.type === "tool_use") 之后,TypeScript 就知道 blockToolUseBlock,可以安全访问 block.nameblock.input

LLMProvider 接口

LLMProvider 只有两个方法:chatstream。这是我们对所有 LLM 服务商的抽象 —— 不管是 Claude、GPT、DeepSeek 还是 Qwen,都必须实现这个接口。chat 返回完整响应,stream 返回一个异步可迭代对象,逐块吐出文本。

stopReason 的三种情况

stopReason含义Agent 应该做什么
end_turnLLM 正常结束回复展示给用户
tool_useLLM 想调用工具执行工具,把结果送回 LLM
max_tokens达到 token 上限被截断可能需要继续请求

5. Anthropic Provider 实现

现在来实现第一个 Provider —— 对接 Anthropic 的 Claude 模型。

import Anthropic from "@anthropic-ai/sdk";
import type {
  Message,
  ChatResponse,
  ChatOptions,
  LLMProvider,
} from "./types.js";

export interface AnthropicConfig {
  apiKey: string;
  model?: string;
}

export class AnthropicProvider implements LLMProvider {
  private client: Anthropic;
  private model: string;

  constructor(config: AnthropicConfig) {
    this.client = new Anthropic({ apiKey: config.apiKey });
    this.model = config.model ?? "claude-sonnet-4-20250514";
  }

  async chat(
    messages: Message[],
    options?: ChatOptions
  ): Promise<ChatResponse> {
    const params: Record<string, unknown> = {
      model: this.model,
      max_tokens: options?.maxTokens ?? 4096,
      messages: messages.map((m) => ({ role: m.role, content: m.content })),
    };
    if (options?.system) params.system = options.system;

    const response = await this.client.messages.create(
      params as Anthropic.MessageCreateParamsNonStreaming
    );

    // Extract text from response content blocks
    const text = response.content
      .filter((b) => b.type === "text")
      .map((b) => (b as Anthropic.TextBlock).text)
      .join("");

    return {
      text,
      stopReason:
        response.stop_reason === "end_turn" ? "end_turn" : "max_tokens",
      usage: {
        inputTokens: response.usage.input_tokens,
        outputTokens: response.usage.output_tokens,
      },
    };
  }
}
from __future__ import annotations

from dataclasses import dataclass

from anthropic import AsyncAnthropic

from .types import ChatOptions, ChatResponse, Message


@dataclass
class AnthropicConfig:
    api_key: str
    model: str = "claude-sonnet-4-20250514"


class AnthropicProvider:
    """LLM provider for Anthropic Claude models."""

    def __init__(self, config: AnthropicConfig) -> None:
        self._client = AsyncAnthropic(api_key=config.api_key)
        self._model = config.model

    async def chat(
        self, messages: list[Message], options: ChatOptions | None = None
    ) -> ChatResponse:
        options = options or ChatOptions()

        params: dict = {
            "model": self._model,
            "max_tokens": options.max_tokens or 4096,
            "messages": [
                {"role": m.role, "content": m.content} for m in messages
            ],
        }
        if options.system:
            params["system"] = options.system

        response = await self._client.messages.create(**params)

        # Extract text from response content blocks
        text = "".join(
            b.text for b in response.content if b.type == "text"
        )

        stop_reason = (
            "end_turn" if response.stop_reason == "end_turn" else "max_tokens"
        )

        return ChatResponse(
            text=text,
            stop_reason=stop_reason,
            usage={
                "input_tokens": response.usage.input_tokens,
                "output_tokens": response.usage.output_tokens,
            },
        )

几个关键点:

关于 as const:在构造 ContentBlock 时使用 type: "text" as const,是为了让 TypeScript 将 type 的类型推断为字面量 "text" 而非宽泛的 string,否则无法满足联合类型的约束。

6. OpenAI 兼容 Provider

很多国产大模型(DeepSeek、Qwen、智谱等)都兼容 OpenAI 的 API 格式,只需改变 baseURLmodel 即可。我们只需实现一个 OpenAICompatibleProvider,就能同时支持所有这些模型。

import OpenAI from "openai";
import type {
  Message,
  ChatResponse,
  ChatOptions,
  LLMProvider,
} from "./types.js";

export interface OpenAICompatibleConfig {
  apiKey: string;
  baseURL: string;
  model: string;
}

export class OpenAICompatibleProvider implements LLMProvider {
  private client: OpenAI;
  private model: string;

  constructor(config: OpenAICompatibleConfig) {
    this.client = new OpenAI({
      apiKey: config.apiKey,
      baseURL: config.baseURL,
    });
    this.model = config.model;
  }

  // Format messages for OpenAI API, prepending system message if present
  private formatMessages(
    messages: Message[],
    system?: string
  ): OpenAI.ChatCompletionMessageParam[] {
    const formatted: OpenAI.ChatCompletionMessageParam[] = [];
    if (system) {
      formatted.push({ role: "system", content: system });
    }
    for (const m of messages) {
      formatted.push({ role: m.role, content: m.content });
    }
    return formatted;
  }

  async chat(
    messages: Message[],
    options?: ChatOptions
  ): Promise<ChatResponse> {
    const response = await this.client.chat.completions.create({
      model: this.model,
      max_tokens: options?.maxTokens ?? 4096,
      messages: this.formatMessages(messages, options?.system),
    });

    const choice = response.choices[0];
    return {
      text: choice.message.content ?? "",
      stopReason:
        choice.finish_reason === "stop" ? "end_turn" : "max_tokens",
      usage: {
        inputTokens: response.usage?.prompt_tokens ?? 0,
        outputTokens: response.usage?.completion_tokens ?? 0,
      },
    };
  }
}

Python 版本的 OpenAI 兼容 Provider 结构与 TypeScript 版一致,使用 openai 官方 SDK。由于篇幅较长,这里展示核心的 chat 方法签名和思路:

from __future__ import annotations

from dataclasses import dataclass

from openai import AsyncOpenAI

from .types import ChatOptions, ChatResponse, Message


@dataclass
class OpenAICompatibleConfig:
    api_key: str
    base_url: str
    model: str


class OpenAICompatibleProvider:
    """LLM provider for OpenAI-compatible APIs (DeepSeek, Qwen, etc.)."""

    def __init__(self, config: OpenAICompatibleConfig) -> None:
        self._client = AsyncOpenAI(
            api_key=config.api_key, base_url=config.base_url
        )
        self._model = config.model

    def _format_messages(
        self, messages: list[Message], system: str | None = None
    ) -> list[dict]:
        """Format messages for OpenAI API, prepending system message if present."""
        formatted: list[dict] = []
        if system:
            formatted.append({"role": "system", "content": system})
        for m in messages:
            formatted.append({"role": m.role, "content": m.content})
        return formatted

    async def chat(
        self, messages: list[Message], options: ChatOptions | None = None
    ) -> ChatResponse:
        options = options or ChatOptions()

        response = await self._client.chat.completions.create(
            model=self._model,
            max_tokens=options.max_tokens or 4096,
            messages=self._format_messages(messages, options.system),
        )

        choice = response.choices[0]
        stop_reason = (
            "end_turn" if choice.finish_reason == "stop" else "max_tokens"
        )

        return ChatResponse(
            text=choice.message.content or "",
            stop_reason=stop_reason,
            usage={
                "input_tokens": getattr(response.usage, "prompt_tokens", 0),
                "output_tokens": getattr(
                    response.usage, "completion_tokens", 0
                ),
            },
        )
Anthropic 和 OpenAI 协议的关键差异
这两个 Provider 做的事情本质上是一样的,但 API 细节差别不小:

特性AnthropicOpenAI
System Prompt独立的 system 字段第一条 role: "system" 消息
Tool 定义input_schemaparameters(嵌在 function 里)
Tool 调用结果tool_use_id(在 user 消息的 content 中)tool_call_id(独立的 role: "tool" 消息)
停止原因tool_use / end_turntool_calls / stop

我们的 LLMProvider 接口正是为了屏蔽这些差异。上层代码只需要关心 ChatResponseContentBlock,不需要知道底层用的是哪家的 API。

7. 工厂模式统一创建

有了两个 Provider,我们需要一个统一的入口来创建它们。使用工厂模式,调用者只需要传入配置,不需要 import 具体的 Provider 类。

import { AnthropicProvider, type AnthropicConfig } from "./anthropic.js";
import {
  OpenAICompatibleProvider,
  type OpenAICompatibleConfig,
} from "./openai-compatible.js";
import type { LLMProvider } from "./types.js";

export interface ProviderConfig {
  provider: "anthropic" | "openai-compatible";
  apiKey: string;
  model?: string;
  baseURL?: string;
}

// Create an LLM provider instance from config
export function createProvider(config: ProviderConfig): LLMProvider {
  if (config.provider === "anthropic") {
    return new AnthropicProvider({
      apiKey: config.apiKey,
      model: config.model,
    } as AnthropicConfig);
  }

  if (config.provider !== "openai-compatible") {
    throw new Error(`Unknown provider: ${config.provider}`);
  }

  // OpenAI-compatible provider requires baseURL and model
  if (!config.baseURL) {
    throw new Error("baseURL is required for openai-compatible provider");
  }
  if (!config.model) {
    throw new Error("model is required for openai-compatible provider");
  }

  return new OpenAICompatibleProvider({
    apiKey: config.apiKey,
    baseURL: config.baseURL,
    model: config.model,
  } as OpenAICompatibleConfig);
}
from __future__ import annotations

from dataclasses import dataclass

from .anthropic_provider import AnthropicConfig, AnthropicProvider
from .openai_compatible import OpenAICompatibleConfig, OpenAICompatibleProvider


@dataclass
class ProviderConfig:
    provider: str  # "anthropic" or "openai-compatible"
    api_key: str
    model: str | None = None
    base_url: str | None = None


def create_provider(config: ProviderConfig):
    """Create an LLM provider instance from config."""
    if config.provider == "anthropic":
        return AnthropicProvider(
            AnthropicConfig(api_key=config.api_key, model=config.model or "claude-sonnet-4-20250514")
        )

    if config.provider == "openai-compatible":
        if not config.base_url:
            raise ValueError("base_url is required for openai-compatible provider")
        if not config.model:
            raise ValueError("model is required for openai-compatible provider")
        return OpenAICompatibleProvider(
            OpenAICompatibleConfig(
                api_key=config.api_key,
                base_url=config.base_url,
                model=config.model,
            )
        )

    raise ValueError(f"Unknown provider: {config.provider}")

使用示例:

// 使用 Claude
const claude = createProvider({
  provider: "anthropic",
  apiKey: process.env.ANTHROPIC_API_KEY!,
});

// 使用 DeepSeek
const deepseek = createProvider({
  provider: "openai-compatible",
  apiKey: process.env.DEEPSEEK_API_KEY!,
  baseURL: "https://api.deepseek.com",
  model: "deepseek-chat",
});

// 两者用法完全一样
const response = await claude.chat([{ role: "user", content: "Hello!" }]);
import os
from src.llm.factory import ProviderConfig, create_provider

# 使用 Claude
claude = create_provider(ProviderConfig(
    provider="anthropic",
    api_key=os.environ["ANTHROPIC_API_KEY"],
))

# 两者用法完全一样
response = await claude.chat([Message(role="user", content="Hello!")])

统一导出

TypeScript 将所有类型和实现统一导出,方便外部使用:

export type {
  Message,
  ChatResponse,
  ChatOptions,
  LLMProvider,
} from "./types.js";
export { AnthropicProvider, type AnthropicConfig } from "./anthropic.js";
export {
  OpenAICompatibleProvider,
  type OpenAICompatibleConfig,
} from "./openai-compatible.js";
export { createProvider, type ProviderConfig } from "./factory.js";

Python 将所有类型和实现统一导出:

from .types import Message, ChatResponse, ChatOptions, LLMProvider
from .anthropic_provider import AnthropicProvider, AnthropicConfig
from .openai_compatible import OpenAICompatibleProvider, OpenAICompatibleConfig
from .factory import create_provider, ProviderConfig

__all__ = [
    "Message",
    "ChatResponse",
    "ChatOptions",
    "LLMProvider",
    "AnthropicProvider",
    "AnthropicConfig",
    "OpenAICompatibleProvider",
    "OpenAICompatibleConfig",
    "create_provider",
    "ProviderConfig",
]

8. 测试

我们通过 mock SDK 来编写单元测试,不需要真实的 API Key。TypeScript 使用 vitest + vi.mock,Python 使用 pytest + unittest.mock

import { describe, it, expect, vi } from "vitest";
import { AnthropicProvider } from "../src/llm/anthropic.js";
import { OpenAICompatibleProvider } from "../src/llm/openai-compatible.js";
import { createProvider } from "../src/llm/factory.js";
import type { Message } from "../src/llm/types.js";

// ── AnthropicProvider ──

vi.mock("@anthropic-ai/sdk", () => {
  return {
    default: class {
      messages = {
        create: vi.fn().mockResolvedValue({
          content: [{ type: "text", text: "Hello from Claude!" }],
          stop_reason: "end_turn",
          usage: { input_tokens: 10, output_tokens: 5 },
        }),
      };
    },
  };
});

vi.mock("openai", () => {
  return {
    default: class {
      chat = {
        completions: {
          create: vi.fn().mockResolvedValue({
            choices: [
              {
                message: { role: "assistant", content: "Hello from GPT!" },
                finish_reason: "stop",
              },
            ],
            usage: { prompt_tokens: 10, completion_tokens: 5 },
          }),
        },
      };
    },
  };
});

describe("AnthropicProvider", () => {
  it("should send a message and return a ChatResponse", async () => {
    const provider = new AnthropicProvider({ apiKey: "test-key" });
    const messages: Message[] = [{ role: "user", content: "Hi" }];
    const response = await provider.chat(messages);

    expect(response.text).toBe("Hello from Claude!");
    expect(response.stopReason).toBe("end_turn");
    expect(response.usage.inputTokens).toBe(10);
    expect(response.usage.outputTokens).toBe(5);
  });

  it("should pass system prompt and maxTokens", async () => {
    const provider = new AnthropicProvider({
      apiKey: "test-key",
      model: "claude-haiku-4-5-20251001",
    });
    const messages: Message[] = [{ role: "user", content: "Hi" }];
    await provider.chat(messages, {
      system: "You are helpful.",
      maxTokens: 1024,
    });

    const mockCreate = (provider as any).client.messages.create;
    const callArgs = mockCreate.mock.calls[0][0];
    expect(callArgs.system).toBe("You are helpful.");
    expect(callArgs.max_tokens).toBe(1024);
    expect(callArgs.model).toBe("claude-haiku-4-5-20251001");
  });
});

// ── OpenAICompatibleProvider ──

describe("OpenAICompatibleProvider", () => {
  it("should send a message and return a ChatResponse", async () => {
    const provider = new OpenAICompatibleProvider({
      apiKey: "test-key",
      baseURL: "https://api.deepseek.com",
      model: "deepseek-chat",
    });
    const messages: Message[] = [{ role: "user", content: "Hi" }];
    const response = await provider.chat(messages);

    expect(response.text).toBe("Hello from GPT!");
    expect(response.stopReason).toBe("end_turn");
    expect(response.usage.inputTokens).toBe(10);
    expect(response.usage.outputTokens).toBe(5);
  });

  it("should prepend system message", async () => {
    const provider = new OpenAICompatibleProvider({
      apiKey: "test-key",
      baseURL: "https://api.deepseek.com",
      model: "deepseek-chat",
    });
    const messages: Message[] = [{ role: "user", content: "Hi" }];
    await provider.chat(messages, { system: "Be helpful." });

    const mockCreate = (provider as any).client.chat.completions.create;
    const callArgs = mockCreate.mock.calls[0][0];
    expect(callArgs.messages[0]).toEqual({
      role: "system",
      content: "Be helpful.",
    });
  });
});

// ── Factory ──

describe("createProvider", () => {
  it("should create AnthropicProvider", () => {
    const p = createProvider({ provider: "anthropic", apiKey: "key" });
    expect(p).toBeInstanceOf(AnthropicProvider);
  });

  it("should create OpenAICompatibleProvider", () => {
    const p = createProvider({
      provider: "openai-compatible",
      apiKey: "key",
      baseURL: "https://api.example.com",
      model: "model-1",
    });
    expect(p).toBeInstanceOf(OpenAICompatibleProvider);
  });

  it("should throw if baseURL missing for openai-compatible", () => {
    expect(() =>
      createProvider({
        provider: "openai-compatible",
        apiKey: "key",
        model: "m",
      })
    ).toThrow("baseURL");
  });

  it("should throw if model missing for openai-compatible", () => {
    expect(() =>
      createProvider({
        provider: "openai-compatible",
        apiKey: "key",
        baseURL: "https://api.example.com",
      })
    ).toThrow("model");
  });

  it("should throw for unknown provider", () => {
    expect(() =>
      createProvider({ provider: "unknown" as any, apiKey: "key" })
    ).toThrow();
  });
});
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from src.llm.anthropic_provider import AnthropicConfig, AnthropicProvider
from src.llm.factory import ProviderConfig, create_provider
from src.llm.openai_compatible import OpenAICompatibleConfig, OpenAICompatibleProvider
from src.llm.types import ChatOptions, Message


# ── AnthropicProvider ──


@pytest.mark.asyncio
class TestAnthropicProvider:
    async def test_send_message_and_return_response(self):
        with patch("src.llm.anthropic_provider.AsyncAnthropic"):
            provider = AnthropicProvider(AnthropicConfig(api_key="test-key"))

        mock_block = MagicMock()
        mock_block.type = "text"
        mock_block.text = "Hello from Claude!"
        mock_response = MagicMock()
        mock_response.content = [mock_block]
        mock_response.stop_reason = "end_turn"
        mock_response.usage.input_tokens = 10
        mock_response.usage.output_tokens = 5

        provider._client.messages.create = AsyncMock(return_value=mock_response)

        messages = [Message(role="user", content="Hi")]
        response = await provider.chat(messages)

        assert response.text == "Hello from Claude!"
        assert response.stop_reason == "end_turn"
        assert response.usage["input_tokens"] == 10
        assert response.usage["output_tokens"] == 5

    async def test_pass_system_prompt_and_max_tokens(self):
        with patch("src.llm.anthropic_provider.AsyncAnthropic"):
            provider = AnthropicProvider(
                AnthropicConfig(api_key="test-key", model="claude-haiku-4-5-20251001")
            )

        mock_block = MagicMock()
        mock_block.type = "text"
        mock_block.text = "Hi"
        mock_response = MagicMock()
        mock_response.content = [mock_block]
        mock_response.stop_reason = "end_turn"
        mock_response.usage.input_tokens = 5
        mock_response.usage.output_tokens = 1

        provider._client.messages.create = AsyncMock(return_value=mock_response)

        messages = [Message(role="user", content="Hi")]
        await provider.chat(messages, ChatOptions(system="Be helpful.", max_tokens=1024))

        call_kwargs = provider._client.messages.create.call_args[1]
        assert call_kwargs["system"] == "Be helpful."
        assert call_kwargs["max_tokens"] == 1024
        assert call_kwargs["model"] == "claude-haiku-4-5-20251001"


# ── OpenAICompatibleProvider ──


@pytest.mark.asyncio
class TestOpenAICompatibleProvider:
    async def test_send_message_and_return_response(self):
        with patch("src.llm.openai_compatible.AsyncOpenAI"):
            provider = OpenAICompatibleProvider(
                OpenAICompatibleConfig(
                    api_key="test-key",
                    base_url="https://api.deepseek.com",
                    model="deepseek-chat",
                )
            )

        mock_choice = MagicMock()
        mock_choice.message.content = "Hello from DeepSeek!"
        mock_choice.finish_reason = "stop"
        mock_response = MagicMock()
        mock_response.choices = [mock_choice]
        mock_response.usage.prompt_tokens = 10
        mock_response.usage.completion_tokens = 5

        provider._client.chat.completions.create = AsyncMock(
            return_value=mock_response
        )

        messages = [Message(role="user", content="Hi")]
        response = await provider.chat(messages)

        assert response.text == "Hello from DeepSeek!"
        assert response.stop_reason == "end_turn"
        assert response.usage["input_tokens"] == 10
        assert response.usage["output_tokens"] == 5

    async def test_prepend_system_message(self):
        with patch("src.llm.openai_compatible.AsyncOpenAI"):
            provider = OpenAICompatibleProvider(
                OpenAICompatibleConfig(
                    api_key="test-key",
                    base_url="https://api.deepseek.com",
                    model="deepseek-chat",
                )
            )

        mock_choice = MagicMock()
        mock_choice.message.content = "Hi"
        mock_choice.finish_reason = "stop"
        mock_response = MagicMock()
        mock_response.choices = [mock_choice]
        mock_response.usage.prompt_tokens = 5
        mock_response.usage.completion_tokens = 1

        provider._client.chat.completions.create = AsyncMock(
            return_value=mock_response
        )

        messages = [Message(role="user", content="Hi")]
        await provider.chat(messages, ChatOptions(system="Be helpful."))

        call_kwargs = provider._client.chat.completions.create.call_args[1]
        assert call_kwargs["messages"][0] == {
            "role": "system",
            "content": "Be helpful.",
        }


# ── Factory ──


class TestCreateProvider:
    @patch("src.llm.anthropic_provider.AsyncAnthropic")
    def test_create_anthropic_provider(self, _mock):
        p = create_provider(ProviderConfig(provider="anthropic", api_key="key"))
        assert isinstance(p, AnthropicProvider)

    @patch("src.llm.openai_compatible.AsyncOpenAI")
    def test_create_openai_compatible_provider(self, _mock):
        p = create_provider(
            ProviderConfig(
                provider="openai-compatible",
                api_key="key",
                base_url="https://api.example.com",
                model="model-1",
            )
        )
        assert isinstance(p, OpenAICompatibleProvider)

    def test_throw_if_base_url_missing(self):
        with pytest.raises(ValueError, match="base_url"):
            create_provider(
                ProviderConfig(
                    provider="openai-compatible", api_key="key", model="m"
                )
            )

    def test_throw_if_model_missing(self):
        with pytest.raises(ValueError, match="model"):
            create_provider(
                ProviderConfig(
                    provider="openai-compatible",
                    api_key="key",
                    base_url="https://api.example.com",
                )
            )

    def test_throw_for_unknown_provider(self):
        with pytest.raises(ValueError, match="Unknown"):
            create_provider(ProviderConfig(provider="unknown", api_key="key"))
Mock 的思路vi.mock 替换了整个 npm 模块。我们用一个简单的类来模拟 Anthropic 和 OpenAI 的 SDK,返回预定义的响应。这样测试不依赖网络,运行速度极快,且可以精确控制返回值来测试各种边界情况。

9. 运行验证

# 安装依赖 $ cd typescript $ npm install # 运行测试 $ npm test ✓ tests/llm.test.ts (5 tests) 12ms ✓ tests/stream.test.ts (4 tests) 8ms ✓ tests/history.test.ts (9 tests) 5ms ✓ tests/tools.test.ts (9 tests) 10ms Test Files 4 passed (4) Tests 27 passed (27) Start at 10:30:00 Duration 1.2s
# 创建虚拟环境并安装依赖 $ cd python $ python3 -m venv .venv $ source .venv/bin/activate $ pip install anthropic openai pytest pytest-asyncio # 运行测试 $ python -m pytest tests/ -v tests/test_llm.py::TestAnthropicProvider::test_send_message_and_return_response PASSED tests/test_llm.py::TestCreateProvider::test_create_anthropic_provider PASSED tests/test_llm.py::TestCreateProvider::test_throw_for_unknown_provider PASSED ========== 3 passed in 0.12s ==========

本章小结

在这一章中,我们完成了:

  1. 项目搭建:TypeScript(ESM + Vitest)/ Python(dataclass + pytest),依赖精简到只有两个 SDK。
  2. 核心类型MessageContentBlockChatResponseLLMProvider —— 这些类型会贯穿整个课程。
  3. 双 ProviderAnthropicProvider 直连 Claude,OpenAICompatibleProvider 支持 DeepSeek/Qwen 等所有兼容 OpenAI 格式的模型。
  4. 工厂模式createProvider 统一入口,上层代码无需关心具体实现。

下一章我们将实现流式响应,让 Agent 能像 ChatGPT 那样逐字输出回复。