ADD: 注释

This commit is contained in:
DaiChaoXiong 2026-07-17 11:19:22 +08:00
parent a7ddba035a
commit 5364ca004c
36 changed files with 1330 additions and 47 deletions

View File

@ -7,6 +7,53 @@
> - [`01-types-and-protocol.md`](./docs/architecture/01-types-and-protocol.md) — 类型契约精读
> - [`02-agent-loop.md`](./docs/architecture/02-agent-loop.md) — 核心循环精读
## 0. 代码注释规范(最高优先级 / Top-Priority Comment Rule)
> 本节规则凌驾于其它一切约定之上。任何新增或修改的源代码(`.rs` 文件)**必须**同时带有**中文**与**英文**注释;缺少任一语言即视为不合规。
> The rule in this section overrides every other convention below. All newly added or modified source code (`.rs` files) **MUST** carry **both Chinese and English** comments; missing either language is a violation.
### 0.1 双语注释要求 / Bilingual Comment Requirement
- **所有**模块级文档注释(`//!`)、项级文档注释(`///`)以及行内/块注释(`//`)都必须**中英双语并存**。
- 推荐写法:先写中文,再写英文(或反之),两者表达同一含义即可,不要求逐字对译。
- **All** module-level doc comments (`//!`), item-level doc comments (`///`), and inline/block comments (`//`) **MUST** appear in **both Chinese and English**.
- Recommended style: write Chinese first, then English (or vice versa). They must convey the same meaning; verbatim word-for-word translation is not required.
### 0.2 示例 / Examples
模块文档 / Module doc:
```rust
//! Agent 核心循环:实现 pi 风格的双层循环(steering + follow-up)。
//! Core agent loop: implements the pi-style double loop (steering + follow-up).
```
项文档 / Item doc:
```rust
/// 从流中拉取下一个事件;返回 `None` 表示流结束。
/// Pull the next event from the stream; `None` means end-of-stream.
pub fn next_event(&mut self) -> Option<StreamEvent> { ... }
```
行内注释 / Inline comment:
```rust
// 先尝试从缓冲区读取,避免无谓的 syscall。
// Try the buffer first to avoid a pointless syscall.
let b = self.buf.pop_front();
```
### 0.3 适用范围 / Scope
- ✅ 适用:所有 `crates/**/src/**/*.rs`(含 `lib.rs`、`main.rs`)、`tests/**/*.rs` 中的公开项与关键私有项。
- ❌ 不适用:自动生成的代码、`build.rs` 中的纯样板、纯粹的格式占位空行。
- ✅ In scope: all public items and key private items in `crates/**/src/**/*.rs` (including `lib.rs`, `main.rs`) and `tests/**/*.rs`.
- ❌ Out of scope: auto-generated code, pure boilerplate in `build.rs`, empty placeholder lines.
## 1. 项目定位
`focus` 是参考 [pi](https://github.com/earendil-works/pi)(TypeScript 原版)和

View File

@ -1,10 +1,16 @@
//! 凭据存储——focus 版的 pi `auth.json`。
//! Credential store — the focus equivalent of pi's `auth.json`.
//!
//! 以 `0600` 权限(父目录 `0700`)将每个 provider 的 API key 存储在
//! `~/.focus/auth.json`,镜像 pi 的 `FileAuthStorageBackend`。值原样存储
//! (字面量、`$VAR` 或 `${VAR}`),在读取时用
//! [`focus_providers::resolve_config_value`] 解析。
//! Stores per-provider API keys in `~/.focus/auth.json` with mode `0600` (and
//! parent directory `0700`), mirroring pi's `FileAuthStorageBackend`. Values
//! are stored verbatim (literal, `$VAR`, or `${VAR}`) and resolved with
//! [`focus_providers::resolve_config_value`] at read time.
//!
//! 结构(一个 `Record<providerId, Credential>`):
//! Shape (a `Record<providerId, Credential>`):
//! ```json
//! {
@ -18,10 +24,15 @@ use focus_json::{parse, to_string, JsonValue};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 凭据存储的错误。
/// Errors from the auth store.
#[derive(Debug)]
pub enum AuthError {
/// I/O 错误。
/// An I/O error.
Io(String),
/// JSON 解析错误。
/// A JSON parse error.
Parse(String),
}
@ -36,15 +47,21 @@ impl std::fmt::Display for AuthError {
impl std::error::Error for AuthError {}
/// 一条已存储的凭据。仅支持 API key(OAuth 不在范围内)。
/// One stored credential. Only API keys are supported (OAuth is out of scope).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credential {
/// 凭据种类(目前固定为 `"api_key"`)。
/// Credential kind (currently always `"api_key"`).
pub kind: String,
/// 原样存储——可能是字面量、`$VAR` 或 `${VAR}`。在读取时通过
/// [`AuthStore::read`] 解析。
/// Stored verbatim — may be a literal, `$VAR`, or `${VAR}`. Resolved on
/// read via [`AuthStore::read`].
pub key: String,
}
/// auth 文件路径:`<config_dir>/auth.json`。
/// The path to the auth file: `<config_dir>/auth.json`.
pub fn auth_path() -> Result<PathBuf, AuthError> {
Ok(config_dir()
@ -52,6 +69,7 @@ pub fn auth_path() -> Result<PathBuf, AuthError> {
.join("auth.json"))
}
/// 确保父目录存在且权限为 0700(仅在 Unix 上生效)。
/// Ensure the parent directory exists with mode 0700, Unix-only effect.
fn ensure_parent_dir(dir: &Path) -> Result<(), AuthError> {
if !dir.exists() {
@ -65,11 +83,13 @@ fn ensure_parent_dir(dir: &Path) -> Result<(), AuthError> {
Ok(())
}
/// 以 0600 权限(Unix)写入文件,并确保父目录权限为 0700。
/// Write a file with mode 0600 (Unix), ensuring the parent dir is 0700.
fn write_secret(path: &Path, contents: &str) -> Result<(), AuthError> {
if let Some(parent) = path.parent() {
ensure_parent_dir(parent)?;
}
// 先写入临时文件以保证原子性,再 rename,最后 chmod。
// Write to a temp file then rename for atomicity, then chmod.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, contents).map_err(|e| AuthError::Io(format!("write: {}", e)))?;
@ -82,6 +102,7 @@ fn write_secret(path: &Path, contents: &str) -> Result<(), AuthError> {
Ok(())
}
/// 读取原始(未解析)的凭据映射。文件缺失时返回空映射。
/// Read the raw (unresolved) credential map. Missing file → empty map.
fn read_raw(path: &Path) -> Result<BTreeMap<String, Credential>, AuthError> {
if !path.exists() {
@ -93,6 +114,8 @@ fn read_raw(path: &Path) -> Result<BTreeMap<String, Credential>, AuthError> {
parse_credential_map(&value)
}
/// 将 JSON 值解析为凭据映射。
/// Parse a JSON value into a credential map.
fn parse_credential_map(value: &JsonValue) -> Result<BTreeMap<String, Credential>, AuthError> {
let entries = match value {
JsonValue::Obj(e) => e,
@ -110,6 +133,8 @@ fn parse_credential_map(value: &JsonValue) -> Result<BTreeMap<String, Credential
Ok(map)
}
/// 将凭据映射序列化为 JSON 字符串。
/// Serialize a credential map into a JSON string.
fn serialize_credential_map(map: &BTreeMap<String, Credential>) -> String {
let mut obj = JsonValue::obj();
for (provider, cred) in map {
@ -121,6 +146,7 @@ fn serialize_credential_map(map: &BTreeMap<String, Credential>) -> String {
to_string(&obj)
}
/// 凭据存储。
/// The credential store.
#[derive(Debug)]
pub struct AuthStore {
@ -128,28 +154,36 @@ pub struct AuthStore {
}
impl AuthStore {
/// 打开位于 `<config_dir>/auth.json` 的默认存储。
/// Open the default store at `<config_dir>/auth.json`.
pub fn open() -> Result<Self, AuthError> {
Ok(Self { path: auth_path()? })
}
/// 在显式路径打开存储(用于测试或自定义位置)。
/// Open a store at an explicit path (for tests or custom locations).
#[allow(dead_code)]
pub fn at(path: PathBuf) -> Self {
Self { path }
}
/// 底层文件路径。
/// Path of the backing file.
pub fn path(&self) -> &Path {
&self.path
}
/// 列出所有已存储凭据的 provider id。不会解析 key(因此在枚举期间不会触发
/// 任何 env/命令求值)。
/// List all provider ids that have a stored credential. Does NOT resolve
/// keys (so no env/command evaluation happens during enumeration).
pub fn list(&self) -> Result<Vec<String>, AuthError> {
Ok(read_raw(&self.path)?.into_keys().collect())
}
/// 读取某 provider 已解析的凭据。若无存储凭据则返回 `None`。存储的 `key`
/// 模板会通过 [`focus_providers::resolve_config_value`] 解析(即展开 `$VAR`)。
/// 解析错误被映射为 `None`(按"没有可用 key"处理)。
/// Read the resolved credential for a provider. Returns `None` if no
/// credential is stored. The stored `key` template is resolved via
/// [`focus_providers::resolve_config_value`] (so `$VAR` is expanded).
@ -169,6 +203,8 @@ impl AuthStore {
}
}
/// 存储(或替换)某 provider 的凭据。key 原样写入——由调用者决定存字面量
/// 还是 `$VAR` 引用。
/// Store (or replace) a credential for a provider. The key is written
/// verbatim — the caller decides whether to store a literal or a `$VAR`
/// reference.
@ -179,6 +215,7 @@ impl AuthStore {
write_secret(&self.path, &text)
}
/// 删除某 provider 的凭据。若确实删除了则返回 true。
/// Remove a provider's credential. Returns true if a credential was removed.
pub fn delete(&self, provider: &str) -> Result<bool, AuthError> {
let mut map = read_raw(&self.path)?;

View File

@ -1,34 +1,50 @@
//! 极简命令行参数解析器(不依赖 clap)。
//! Minimal command-line argument parser (no clap dependency).
//!
//! 由 `main` 决定的两种模式:
//! Two modes, decided in `main`:
//! - **REPL**:不带 prompt 且 stdin 为 TTY 时运行 `focus`。
//! - **REPL**: `focus` with no prompt and stdin is a TTY.
//! - **单次 prompt**:`focus "text"`(或 `echo text | focus`)。
//! - **Single-prompt**: `focus "text"` (or `echo text | focus`).
//!
//! `/login` 与 `/logout` 作为斜杠命令位于 REPL 内部。
//! `/login` and `/logout` live inside the REPL as slash commands.
use std::env;
/// 解析后的命令行选项(用于单次 prompt 模式;REPL 也会从这里读取
/// `provider` 与 `api_key` 作为初始值)。
/// Parsed command-line options (for single-prompt mode; the REPL also reads
/// `provider` and `api_key` from here for initial values).
#[derive(Debug, Clone, Default)]
pub struct Cli {
/// prompt 文本。若缺失且 stdin 是 TTY,则进入 REPL。
/// The prompt text. If absent and stdin is a TTY, enter the REPL.
pub prompt: Option<String>,
/// 覆盖 provider(否则使用配置中的默认值)。
/// Override the provider (default from config otherwise).
pub provider: Option<String>,
/// 覆盖模型。
/// Override the model.
pub model: Option<String>,
/// 覆盖系统提示词。
/// Override the system prompt.
pub system: Option<String>,
/// 内存中的 API key 覆盖值(不持久化)。类似 pi 的 `--api-key`。
/// In-memory API key override (not persisted). Like pi's `--api-key`.
pub api_key: Option<String>,
/// 打印解析后的配置并退出。
/// Print the resolved config and exit.
pub print_config: bool,
/// 显示帮助并退出。
/// Show help and exit.
pub help: bool,
}
impl Cli {
/// 用法字符串。
/// The usage string.
pub fn usage() -> &'static str {
"focus [OPTIONS] [PROMPT]\n\
\n\
@ -47,11 +63,14 @@ impl Cli {
}
}
/// 解析 `std::env::args`。
/// Parse `std::env::args`.
pub fn parse() -> Cli {
parse_from(env::args().skip(1))
}
/// 从给定的参数可迭代对象解析 CLI(便于测试)。
/// Parse the CLI from a given iterable of args (useful for tests).
pub fn parse_from<I: IntoIterator<Item = String>>(args: I) -> Cli {
let mut cli = Cli::default();
let mut args = args.into_iter().peekable();
@ -67,7 +86,7 @@ pub fn parse_from<I: IntoIterator<Item = String>>(args: I) -> Cli {
s if s.starts_with("--model=") => cli.model = Some(s[8..].to_string()),
s if s.starts_with("--system=") => cli.system = Some(s[9..].to_string()),
s if s.starts_with("--api-key=") => cli.api_key = Some(s[10..].to_string()),
s if s.starts_with('-') => { /* ignore unknown flags */ }
s if s.starts_with('-') => { /* 忽略未知标志 / ignore unknown flags */ }
_ => {
if cli.prompt.is_none() {
cli.prompt = Some(arg);

View File

@ -1,9 +1,14 @@
//! 配置加载。
//! Configuration loading.
//!
//! 读取 `~/.focus/config.json`(可通过 `FOCUS_CONFIG_DIR` 覆盖)。该文件列出
//! provider 与一个默认值;API-key 的值可以是 `$VAR`/`${VAR}` 模板,在加载时
//! 解析(见 [`focus_providers::resolve_config_value`])。
//! Reads `~/.focus/config.json` (overridable via `FOCUS_CONFIG_DIR`). The file
//! lists providers and a default; API-key values may be `$VAR`/`${VAR}`
//! templates resolved at load time (see [`focus_providers::resolve_config_value`]).
//!
//! 示例配置:
//! Example config:
//! ```json
//! {
@ -13,20 +18,36 @@
//! "kind": "zai",
//! "region": "cn",
//! "model": "glm-5.2",
//! "path": "/api/coding/paas/v4/chat/completions",
//! "apiKey": "$ZAI_API_KEY"
//! }
//! }
//! }
//! ```
//!
//! `path` 字段用于选择接入档位:对 `xxxxxxxx.yyyyyyyy` 格式的 key 使用 GLM
//! Coding Plan(`/api/coding/paas/v4/chat/completions`),对按量付费 key 使用
//! 标准 PaaS(`/api/paas/v4/chat/completions`)。省略时使用 provider 的默认路径。
//! The `path` field selects the access tier: the GLM Coding Plan
//! (`/api/coding/paas/v4/chat/completions`) for `xxxxxxxx.yyyyyyyy` keys, or
//! the standard PaaS (`/api/paas/v4/chat/completions`) for pay-as-you-go keys.
//! When omitted, the provider's default path is used.
use focus_json::{parse, JsonValue};
use std::path::{Path, PathBuf};
/// 加载配置时出现的错误。
/// Errors while loading config.
#[derive(Debug)]
pub enum ConfigError {
/// I/O 错误(例如读取文件失败)。
/// An I/O error (e.g. failed to read the file).
Io(String),
/// JSON 解析错误。
/// A JSON parse error.
Parse(String),
/// 缺少必需字段。
/// A required field is missing.
Missing(String),
}
@ -42,26 +63,49 @@ impl std::fmt::Display for ConfigError {
impl std::error::Error for ConfigError {}
/// 已解析的 provider 条目。
/// A resolved provider entry.
#[derive(Debug, Clone)]
pub struct ProviderEntry {
/// provider 种类(如 `zai`、`openai`、`anthropic`)。
/// Provider kind (e.g. `zai`, `openai`, `anthropic`).
pub kind: String,
/// 国内为 `"cn"`,国际为 `"intl"`(仅 zai 适用)。
/// `"cn"` for domestic, `"intl"` for international (zai only).
pub region: String,
/// 默认模型 id。
/// The default model id.
pub model: String,
/// 已解析的 API key(加载时由 `$VAR`/`${VAR}` 模板展开而来)。
/// Resolved API key (expanded from `$VAR`/`${VAR}` templates at load time).
pub api_key: String,
/// 可选的自定义 base host(覆盖区域默认值)。
/// Optional custom base host (overrides the region default).
pub base_url: Option<String>,
/// 可选的 chat-completions 路径覆盖(例如为 `xxxxxxxx.yyyyyyyy` 格式的 key
/// 指定 GLM Coding Plan 路径 `/api/coding/paas/v4/chat/completions`)。
/// 未设置时使用 provider 的默认路径。
/// Optional chat-completions path override (e.g. the GLM Coding Plan path
/// `/api/coding/paas/v4/chat/completions` for `xxxxxxxx.yyyyyyyy` keys).
/// When unset, the provider's default path is used.
pub path: Option<String>,
}
/// 已加载的配置。
/// The loaded configuration.
#[derive(Debug, Clone)]
pub struct Config {
/// 默认 provider 名称。
/// The default provider name.
pub default: String,
/// 全部 provider 条目(名称, 条目)的有序列表。
/// All provider entries as an ordered list of (name, entry).
pub providers: Vec<(String, ProviderEntry)>,
}
impl Config {
/// 一个没有任何 provider 的空配置。当配置文件尚不存在时作为回退,
/// 这样 CLI 可以打印引导信息而不是崩溃。
/// An empty config with no providers. Used as a fallback when the config
/// file does not exist yet, so the CLI can print guidance instead of
/// crashing.
@ -72,11 +116,13 @@ impl Config {
}
}
/// 未配置任何 provider 时为真。
/// True if no providers are configured.
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
/// 返回默认 provider 条目;若默认值未设置或未知则返回 None。
/// Get the default provider entry, or None if the default is unset/unknown.
#[cfg(test)]
pub fn default_provider(&self) -> Option<(&str, &ProviderEntry)> {
@ -86,6 +132,7 @@ impl Config {
.map(|(n, e)| (n.as_str(), e))
}
/// 按名称获取 provider 条目。
/// Get a named provider entry.
pub fn provider(&self, name: &str) -> Option<&ProviderEntry> {
self.providers
@ -95,6 +142,7 @@ impl Config {
}
}
/// 解析配置目录:`$FOCUS_CONFIG_DIR` 或 `~/.focus`。
/// Resolve the config directory: `$FOCUS_CONFIG_DIR` or `~/.focus`.
pub fn config_dir() -> Result<PathBuf, ConfigError> {
if let Ok(dir) = std::env::var("FOCUS_CONFIG_DIR") {
@ -104,8 +152,12 @@ pub fn config_dir() -> Result<PathBuf, ConfigError> {
Ok(PathBuf::from(home).join(".focus"))
}
/// 从默认路径(`~/.focus/config.json`)加载配置。
/// Load config from the default path (`~/.focus/config.json`).
///
/// 若文件不存在,则返回内置默认 provider(这样 CLI 开箱即用:
/// `focus login zai` + `focus "你好"`)。文件存在但不可读、格式错误或缺少
/// 必需字段时仍视为错误。
/// If the file does not exist, returns the built-in default providers (so the
/// CLI works out of the box: `focus login zai` + `focus "你好"`). A file that
/// exists but is unreadable, malformed, or missing required fields is still an
@ -118,37 +170,74 @@ pub fn load() -> Result<Config, ConfigError> {
load_from(&path)
}
/// 当 `config.json` 不存在时使用的内置默认 provider。它们让 focus 对主要目标
/// (Z.ai / 智谱 GLM Coding Plan)及其它受支持厂商开箱即用。API key 通过
/// `focus login` 或 `--api-key` 单独提供。
/// The built-in default providers used when no `config.json` exists. These make
/// focus work out of the box for the primary target (Z.ai / 智谱) plus the
/// other supported vendors. API keys are supplied separately via
/// focus work out of the box for the primary target (Z.ai / 智谱 GLM Coding
/// Plan) plus the other supported vendors. API keys are supplied separately via
/// `focus login` or `--api-key`.
///
/// 默认的 `zai-coding-cn` 指向 **GLM Coding Plan** 档位,该档位的 key
/// (`xxxxxxxx.yyyyyyyy` 格式)必须使用 `/api/coding/paas/v4/chat/completions`——
/// 标准 `/api/paas/v4/...` 路径会对这类 key 返回 `429 余额不足`。
/// The default `zai-coding-cn` targets the **GLM Coding Plan** tier, whose keys
/// (`xxxxxxxx.yyyyyyyy` format) must use `/api/coding/paas/v4/chat/completions`
/// — the standard `/api/paas/v4/...` path reports `429 余额不足` for them.
pub fn builtin_defaults() -> Config {
let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>| ProviderEntry {
let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>, path: Option<&str>| {
ProviderEntry {
kind: kind.to_string(),
region: region.to_string(),
model: model.to_string(),
api_key: String::new(),
base_url: base_url.map(String::from),
path: path.map(String::from),
}
};
Config {
default: "zai-coding-cn".to_string(),
providers: vec![
(
"zai-coding-cn".to_string(),
mk("zai", "cn", "glm-5.2", None),
mk(
"zai",
"cn",
"glm-5.2",
None,
Some(focus_providers::ZAI_CODING_PATH),
),
),
(
"zai-coding-intl".to_string(),
mk(
"zai",
"intl",
"glm-5.2",
None,
Some(focus_providers::ZAI_CODING_PATH),
),
),
// 标准按量付费 PaaS 档位(与 coding plan 区分开)。
// Standard pay-as-you-go PaaS tier (separate from the coding plan).
("zai-cn".to_string(), mk("zai", "cn", "glm-5.2", None, None)),
(
"zai-intl".to_string(),
mk("zai", "intl", "glm-5.2", None, None),
),
("zai-intl".to_string(), mk("zai", "intl", "glm-5.2", None)),
(
"anthropic".to_string(),
mk("anthropic", "", "claude-sonnet-4-5", None),
mk("anthropic", "", "claude-sonnet-4-5", None, None),
),
("openai".to_string(), mk("openai", "", "gpt-4o", None)),
("openai".to_string(), mk("openai", "", "gpt-4o", None, None)),
],
}
}
/// 从显式路径加载配置。便于测试。
/// Load config from an explicit path. Useful for tests.
pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
// 配置文件缺失不是错误——返回空配置,让调用者决定如何引导用户。
// A missing config file is not an error — return an empty config so the
// caller can decide how to guide the user.
if !path.exists() {
@ -160,6 +249,8 @@ pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
parse_config(&value)
}
/// 将解析后的 JSON 值转换为 [`Config`]。
/// Convert a parsed JSON value into a [`Config`].
fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
let default = value
.get_str("default")
@ -183,6 +274,8 @@ fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
.get_str("model")
.ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'model'", name)))?
.to_string();
// apiKey 在 config.json 中是可选的——key 通常通过 `focus login`(auth.json)
// 或 --api-key 提供。若缺失则默认为空,后续解析会失败并给出友好提示。
// apiKey is optional in config.json — the key is usually supplied via
// `focus login` (auth.json) or --api-key. If absent, it defaults to
// empty and resolution fails later with a helpful message.
@ -193,6 +286,7 @@ fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
focus_providers::resolve_config_value(raw_key).map_err(ConfigError::Io)?
};
let base_url = entry.get_str("baseUrl").map(String::from);
let path = entry.get_str("path").map(String::from);
providers.push((
name.clone(),
ProviderEntry {
@ -201,6 +295,7 @@ fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
model,
api_key,
base_url,
path,
},
));
}
@ -251,6 +346,7 @@ mod tests {
#[test]
fn provider_without_apikey_is_accepted() {
// apiKey 是可选的——key 通过 `focus login` 或 --api-key 提供。
// apiKey is optional — the key is supplied via `focus login` or --api-key.
let text = r#"{
"default": "zai-coding-cn",
@ -286,6 +382,7 @@ mod tests {
#[test]
fn missing_file_returns_empty_config() {
// 不存在的路径不是错误——返回空配置,以便 CLI 打印安装引导而不是崩溃。
// A nonexistent path is NOT an error — returns an empty config so the
// CLI can print setup guidance instead of crashing.
let path = Path::new("/nonexistent/focus/config-test-does-not-exist.json");
@ -297,6 +394,7 @@ mod tests {
#[test]
fn malformed_file_is_an_error() {
// 文件存在但 JSON 无效才是真正的错误。
// A file that exists but has invalid JSON is a real error.
let dir = std::env::temp_dir();
let path = dir.join("focus-malformed-test.json");

View File

@ -1,5 +1,9 @@
//! `/login` 与 `/logout` 流程,供 REPL 调用。
//! `/login` and `/logout` flows, used from the REPL.
//!
//! 镜像 pi 的做法:提示用户选择 provider(或接受命令行传入的 provider),
//! 读取一个 API key,并原样持久化到 `~/.focus/auth.json`。登录时不做任何
//! 验证 API 调用。
//! Mirrors pi's approach: prompt the user to pick a provider (or accept one
//! passed on the command line), read an API key, and persist it to
//! `~/.focus/auth.json` verbatim. No validation API call is made at login time.
@ -8,13 +12,24 @@ use crate::auth::{AuthError, AuthStore, Credential};
use crate::config::Config;
use std::io::{BufRead, Write};
/// 登录流程的错误。
/// Errors from the login flow.
#[derive(Debug)]
pub enum LoginError {
/// 来自凭据存储的错误。
/// An error from the auth store.
Auth(AuthError),
/// I/O 错误。
/// An I/O error.
Io(String),
/// 用户取消(如空输入或 EOF)。
/// The user cancelled (e.g. empty input or EOF).
Cancelled,
/// 未配置任何 provider。
/// No providers are configured.
NoProviders,
/// 指定的 provider 未知。
/// The given provider is unknown.
UnknownProvider(String),
}
@ -38,14 +53,19 @@ impl From<AuthError> for LoginError {
}
}
/// 一次登录操作的参数。
/// Parameters for a login operation.
pub struct LoginParams {
/// 显式 provider id。若为 `None`,则提示用户选择。
/// Explicit provider id. If `None`, prompt the user to choose.
pub provider: Option<String>,
/// 显式 key。若为 `None`,则提示用户输入。
/// Explicit key. If `None`, prompt the user.
pub key: Option<String>,
}
/// 执行登录流程。`reader` 是 REPL 持有的 stdin 行读取器。
/// 返回已登录的 provider id。
/// Run the login flow. `reader` is the stdin line reader owned by the REPL.
/// Returns the provider id that was logged in.
pub fn run_login<R: BufRead>(
@ -54,6 +74,7 @@ pub fn run_login<R: BufRead>(
params: LoginParams,
reader: &mut R,
) -> Result<String, LoginError> {
// 1. 解析 provider。
// 1. Resolve the provider.
let provider = match params.provider {
Some(p) => p,
@ -65,6 +86,7 @@ pub fn run_login<R: BufRead>(
}
};
// 2. 解析 key。
// 2. Resolve the key.
let key = match params.key {
Some(k) => k,
@ -74,6 +96,7 @@ pub fn run_login<R: BufRead>(
return Err(LoginError::Cancelled);
}
// 3. 持久化。原样存储(调用者可传入字面量或 $VAR)。
// 3. Persist. Store verbatim (caller may pass a literal or $VAR).
store.modify(
&provider,
@ -83,11 +106,13 @@ pub fn run_login<R: BufRead>(
},
)?;
// 4. 确认。
// 4. Confirm.
eprintln!("Credentials saved to {}", store.path().display());
Ok(provider)
}
/// 执行登出流程:删除某 provider 的凭据。
/// Run the logout flow: remove a provider's credential.
pub fn run_logout<R: BufRead>(
store: &AuthStore,
@ -126,8 +151,10 @@ pub fn run_logout<R: BufRead>(
Ok(provider)
}
// ---- 交互式辅助函数(全部经由调用方的 reader 读取)------------
// ---- interactive helpers (all read via the caller's reader) ---------------
/// 从 `reader` 读取一行并 trim。遇到 EOF 时返回 `Cancelled`。
/// Read one trimmed line from `reader`. Returns `Cancelled` on EOF.
fn read_line<R: BufRead>(reader: &mut R) -> Result<String, LoginError> {
let mut line = String::new();
@ -140,6 +167,8 @@ fn read_line<R: BufRead>(reader: &mut R) -> Result<String, LoginError> {
Ok(line.trim_end_matches(['\n', '\r']).to_string())
}
/// 提示用户从配置的 provider 中选择一个;仅一个时直接返回。
/// Prompt the user to pick a provider from the config; returns the sole one directly.
fn prompt_provider<R: BufRead>(cfg: &Config, reader: &mut R) -> Result<String, LoginError> {
let names: Vec<&String> = cfg.providers.iter().map(|(n, _)| n).collect();
if names.len() == 1 {
@ -149,6 +178,8 @@ fn prompt_provider<R: BufRead>(cfg: &Config, reader: &mut R) -> Result<String, L
prompt_from_list(&opts, "Select a provider to log in", reader)
}
/// 将编号选项打印到 stderr,并从 `reader` 读取用户的选择。
/// Print the numbered options to stderr and read the user's choice from `reader`.
fn prompt_from_list<R: BufRead>(
options: &[&str],
prompt: &str,
@ -172,9 +203,13 @@ fn prompt_from_list<R: BufRead>(
Err(LoginError::Cancelled)
}
/// 提示输入某 provider 的 API key;从调用方的 reader 读取。
/// Prompt for an API key for the given provider; reads from the caller's reader.
fn prompt_key<R: BufRead>(provider: &str, reader: &mut R) -> Result<String, LoginError> {
eprint!("Enter API key for {}: ", provider);
let _ = std::io::stderr().flush();
// 从调用方的 reader(REPL 的 stdin 句柄)读取。我们不关闭终端回显
// (那需要 termios);输入 key 时字符可见。
// Read from the caller's reader (the REPL's stdin handle). We don't disable
// terminal echo (would require termios); the key is visible while typing.
read_line(reader)
@ -212,6 +247,7 @@ mod tests {
model: "glm-4.6".into(),
api_key: String::new(),
base_url: None,
path: None,
},
)],
}
@ -269,9 +305,10 @@ mod tests {
ProviderEntry {
kind: "zai".into(),
region: "cn".into(),
model: "glm-4.6".into(),
model: "glm-5.2".into(),
api_key: String::new(),
base_url: None,
path: None,
},
),
(
@ -282,10 +319,12 @@ mod tests {
model: "claude".into(),
api_key: String::new(),
base_url: None,
path: None,
},
),
],
};
// 输入:选择选项 [2](anthropic),再输入一个 key。
// Input: pick option [2] (anthropic), then enter a key.
let mut reader = Cursor::new(b"2\nanthro-key\n".to_vec());
let provider = run_login(

View File

@ -7,6 +7,8 @@ mod runner;
fn main() {
let cli = cli::parse();
// 构建一个多线程 tokio 运行时。核心循环是同步的(它从 provider 拉取事件),
// 但 provider 内部会为 HTTP 请求创建运行时,因此这里需要先备好一个。
// Build a multi-threaded tokio runtime. The core loop is synchronous (it
// pulls events from the provider), but providers create a runtime
// internally for the HTTP request, so we need one available.
@ -21,33 +23,41 @@ fn main() {
}
}
/// 在 REPL 模式(无 prompt)与单次 prompt 模式(有 prompt)之间分派。
/// Decide between REPL mode (no prompt) and single-prompt mode (prompt given).
fn dispatch(cli: cli::Cli) -> Result<(), Box<dyn std::error::Error>> {
if cli.help {
println!("{}", cli::Cli::usage());
return Ok(());
}
// 无 prompt 且 stdin 是 TTY → 进入交互式 REPL。
// 无 prompt 但 stdin 是管道输入 → 从 stdin 读取 prompt(单次运行)。
// No prompt and stdin is a TTY → enter the interactive REPL.
// No prompt but stdin is piped → read the prompt from stdin (single-run).
let has_prompt = cli.prompt.is_some();
let stdin_is_tty = is_stdin_tty();
if !has_prompt && stdin_is_tty {
// 交互式 REPL。
// Interactive REPL.
repl::run(cli.api_key, cli.provider)
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
return Ok(());
}
// 单次 prompt 模式。
// Single-prompt mode.
runner::run(cli).map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
Ok(())
}
/// 当 stdin 连接到终端(而非管道/重定向)时为真。
/// True when stdin is connected to a terminal (not piped/redirected).
fn is_stdin_tty() -> bool {
#[cfg(unix)]
{
// 通过 std::os 使用 libc 的 isatty,避免引入 libc 这个 crate。
// std::io::IsTerminal 自 1.70 起稳定可用。
// Use a libc isatty via std::os without pulling in the libc crate.
// std::io::IsTerminal is stable since 1.70.
use std::io::IsTerminal;

View File

@ -1,17 +1,29 @@
//! 交互式 REPL。
//! Interactive REPL.
//!
//! 当 focus 不带 prompt 参数运行时进入。从 stdin 逐行读取:以 `/` 开头的
//! 行是斜杠命令,其余内容作为 prompt 发送给模型,构成持续的多轮对话。
//! Entered when focus is run with no prompt argument. Reads lines from stdin:
//! `/`-prefixed lines are slash commands, everything else is a prompt sent to
//! the model in an ongoing multi-turn conversation.
//!
//! 支持的斜杠命令:
//! Supported slash commands:
//! - `/login [provider]` — 将 API key 存入 auth.json
//! - `/login [provider]` — store an API key in auth.json
//! - `/logout [provider]` — 删除已存储的凭据
//! - `/logout [provider]` — remove a stored credential
//! - `/model [name]` — 显示或切换当前模型
//! - `/model [name]` — show or switch the active model
//! - `/system [text]` — 显示或设置系统提示词
//! - `/system [text]` — show or set the system prompt
//! - `/clear` — 清空对话记录
//! - `/clear` — clear the conversation transcript
//! - `/config` — 打印解析后的配置
//! - `/config` — print the resolved config
//! - `/help` — 列出命令
//! - `/help` — list commands
//! - `/quit`(或 `/exit`)— 退出 REPL
//! - `/quit` (or `/exit`) — leave the REPL
use crate::auth::AuthStore;
@ -25,12 +37,21 @@ use focus_core::{Agent, AgentConfig};
use std::io::{self, BufRead, Write};
// BufRead is used for the stdin handle passed into slash-command handlers.
/// REPL 的错误。
/// Errors from the REPL.
#[derive(Debug)]
pub enum ReplError {
/// 配置错误。
/// A config error.
Config(config::ConfigError),
/// 凭据存储错误。
/// An auth-store error.
Auth(crate::auth::AuthError),
/// Agent 执行错误。
/// An agent execution error.
Agent(String),
/// 登录流程错误。
/// A login-flow error.
Login(login::LoginError),
}
@ -65,25 +86,34 @@ impl From<login::LoginError> for ReplError {
}
}
/// 可变的 REPL 会话状态。
/// Mutable REPL session state.
struct ReplState {
/// 当前生效的配置(加载一次,可用 /config 重载)。
/// The active config (loaded once, may be reloaded with /config).
cfg: Config,
/// 凭据存储。
/// The auth store.
store: AuthStore,
/// 当前 provider 名称。
/// Active provider name.
provider: String,
/// 当前模型(可通过 /model 覆盖)。
/// Active model (overridable via /model).
model: String,
/// 系统提示词(可通过 /system 覆盖)。
/// System prompt (overridable via /system).
system: String,
/// 内存中的 API key 覆盖值(来自 `--api-key`),若有。
/// In-memory API key override (from `--api-key`), if any.
api_key_override: Option<String>,
/// 运行中的 agent(当 provider/model/system 变化时重建)。
/// The running agent (rebuilt when provider/model/system change).
agent: Option<Agent>,
}
impl ReplState {
/// 为当前 provider/model/key 构建 StreamProvider。
/// Build the StreamProvider for the current provider/model/key.
fn build_provider(&self) -> Result<Box<dyn StreamProvider>, ReplError> {
let entry = self.cfg.provider(&self.provider).ok_or_else(|| {
@ -95,6 +125,7 @@ impl ReplState {
.map_err(|e| ReplError::Agent(e.to_string()))
}
/// 按优先级解析 API key:override > auth.json > config。
/// Resolve the API key with precedence: override > auth.json > config.
fn resolve_api_key(&self, entry: &crate::config::ProviderEntry) -> Result<String, ReplError> {
if let Some(k) = &self.api_key_override {
@ -112,6 +143,7 @@ impl ReplState {
)))
}
/// 懒构建(或重建)agent。
/// Lazily build (or rebuild) the agent.
fn ensure_agent(&mut self) -> Result<&mut Agent, ReplError> {
if self.agent.is_none() {
@ -126,11 +158,13 @@ impl ReplState {
Ok(self.agent.as_mut().unwrap())
}
/// 强制下次 ensure_agent 时重建(在 provider/model 变化后调用)。
/// Force a rebuild on the next ensure_agent (after provider/model changes).
fn invalidate_agent(&mut self) {
self.agent = None;
}
/// 将 prompt 发送给 agent,并打印流式响应。
/// Send a prompt through the agent and print the streamed response.
fn send_prompt(&mut self, text: &str) -> Result<(), ReplError> {
let agent = self.ensure_agent()?;
@ -142,10 +176,14 @@ impl ReplState {
}
}
/// 一个 sink:将流式到达的 assistant 增量(delta)按到达顺序打印到 stdout,
/// 通过记录当前消息已打印的字符数避免重复输出。
/// A sink that prints streamed assistant deltas to stdout as they arrive,
/// avoiding duplication by tracking how many chars of the current message have
/// already been printed.
struct ReplSink {
/// 当前消息已打印到 stdout 的字符数(用于增量输出)。
/// Number of chars of the current message already printed to stdout (for delta output).
printed: usize,
}
@ -188,6 +226,7 @@ impl EventSink for ReplSink {
}
}
/// 拼接 assistant 消息中的所有文本块。
/// Concatenate all text blocks of an assistant message.
fn assistant_text(a: &focus_core::model::AssistantMessage) -> String {
a.content
@ -200,6 +239,7 @@ fn assistant_text(a: &focus_core::model::AssistantMessage) -> String {
.join("")
}
/// 运行 REPL。`api_key_override` 是内存中的 `--api-key`(若提供)。
/// Run the REPL. `api_key_override` is the in-memory `--api-key`, if provided.
pub fn run(
api_key_override: Option<String>,
@ -225,6 +265,8 @@ pub fn run(
};
let stdin = io::stdin();
// 为整个 REPL 会话只锁一次 stdin。锁定的句柄会传入斜杠命令处理器,使其能
// 读取额外行(例如 /login 的 API key)而无需重新加锁导致死锁。
// Lock stdin once for the whole REPL session. The locked handle is passed
// into slash-command handlers so they can read additional lines (e.g. the
// API key for /login) without re-locking and deadlocking.
@ -243,7 +285,7 @@ pub fn run(
}
};
if n == 0 {
break; // EOF (Ctrl-D)
break; // EOF(Ctrl-D)/ EOF (Ctrl-D)
}
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
@ -265,12 +307,19 @@ pub fn run(
Ok(())
}
/// 斜杠命令的执行结果。
/// Outcome of a slash command.
enum CommandResult {
/// 继续读取下一行。
/// Keep reading the next line.
Continue,
/// 退出 REPL。
/// Quit the REPL.
Quit,
}
/// 分派一个 `/command`。`reader` 是共享的 stdin 句柄,以便处理器读取额外
/// 输入行(例如 /login 的 API key)。
/// Dispatch a `/command`. `reader` is the shared stdin handle so handlers can
/// read additional input lines (e.g. the API key for /login).
fn handle_command<R: BufRead>(
@ -278,6 +327,7 @@ fn handle_command<R: BufRead>(
state: &mut ReplState,
reader: &mut R,
) -> Result<CommandResult, ReplError> {
// 拆分为 command + args。
// Split into command + args.
let mut parts = input.splitn(2, char::is_whitespace);
let cmd = parts.next().unwrap_or("");
@ -343,6 +393,7 @@ fn handle_command<R: BufRead>(
println!("active provider: {}", state.provider);
} else if state.cfg.provider(arg).is_some() {
state.provider = arg.to_string();
// 将 model 更新为该 provider 的默认值。
// Update model to the provider's default.
if let Some(e) = state.cfg.provider(&state.provider) {
state.model = e.model.clone();

View File

@ -1,8 +1,13 @@
//! 执行一次 prompt,或分派 `login`/`logout` 子命令。
//! Run a prompt, or dispatch `login`/`logout` subcommands.
//!
//! 凭据解析顺序(按优先级从高到低,镜像 pi):
//! Credential resolution order (highest precedence first), mirroring pi:
//! 1. `--api-key` CLI 覆盖(仅内存,从不持久化)。
//! 1. `--api-key` CLI override (in-memory only, never persisted).
//! 2. 该 provider 在 `auth.json` 中的条目(由 `focus login` 写入)。
//! 2. `auth.json` entry for the provider (written by `focus login`).
//! 3. `config.json` 中 provider 的 `apiKey`(加载时已解析)。
//! 3. `config.json` provider `apiKey` (already resolved at load time).
use crate::auth::AuthStore;
@ -15,12 +20,21 @@ use focus_core::{Agent, AgentConfig};
use focus_providers::{AnthropicProvider, OpenAiCompatProvider, ProviderConfig, ZaiProvider};
use std::io::{self, Read, Write};
/// 运行期间产生的错误。
/// Errors produced while running.
#[derive(Debug)]
pub enum RunError {
/// 配置错误。
/// A config error.
Config(config::ConfigError),
/// 凭据存储错误。
/// An auth-store error.
Auth(crate::auth::AuthError),
/// provider 构建错误。
/// A provider-construction error.
Provider(String),
/// Agent 执行错误。
/// An agent execution error.
Agent(String),
}
@ -49,6 +63,7 @@ impl From<crate::auth::AuthError> for RunError {
}
}
/// 根据 CLI 参数驱动一次完整运行(单次 prompt 模式)。
/// Drive one full run based on CLI args (the single-prompt mode).
pub fn run(cli: Cli) -> Result<(), RunError> {
if cli.help {
@ -67,6 +82,7 @@ pub fn run(cli: Cli) -> Result<(), RunError> {
return Ok(());
}
// 当未配置任何 provider 且未提供 --api-key 覆盖时,打印安装引导并干净退出。
// If no providers are configured AND no --api-key override was given,
// print setup guidance and exit cleanly.
if cfg.is_empty() && cli.api_key.is_none() {
@ -74,6 +90,8 @@ pub fn run(cli: Cli) -> Result<(), RunError> {
return Ok(());
}
// 解析 provider 条目。有 --api-key 时即便配置为空也能运行(合成一个 zai 条目),
// 但仍然需要一个 provider 名称。
// Resolve the provider entry. With --api-key we can run even with an empty
// config (synthesizing a zai entry), but a provider name is still needed.
let provider_name = cli.provider.clone().unwrap_or_else(|| cfg.default.clone());
@ -95,6 +113,7 @@ pub fn run(cli: Cli) -> Result<(), RunError> {
return Err(RunError::Agent("empty prompt".into()));
}
// 按本文件顶部记录的优先级解析 API key。
// Resolve the API key with the precedence documented at the top of this
// file.
let store = AuthStore::open()?;
@ -121,6 +140,11 @@ pub fn run(cli: Cli) -> Result<(), RunError> {
Ok(())
}
/// 解析最终的 API key。优先级:
/// 1. `--api-key` 覆盖(仅内存)。
/// 2. `auth.json` 条目。
/// 3. `config.json` 的 `apiKey`(加载时已解析)。
///
/// Resolve the final API key. Precedence:
/// 1. `--api-key` override (in-memory).
/// 2. `auth.json` entry.
@ -146,6 +170,7 @@ fn resolve_api_key(
)))
}
/// 从配置条目构建一个 [`StreamProvider`]。单次 prompt runner 与 REPL 共用。
/// Build a [`StreamProvider`] from a config entry. Shared by the single-prompt
/// runner and the REPL.
pub fn build_provider_pub(
@ -156,6 +181,8 @@ pub fn build_provider_pub(
build_provider(entry, model_override, api_key)
}
/// 根据 provider `kind` 构造具体的 StreamProvider 实例(内部函数)。
/// Construct the concrete StreamProvider instance by provider `kind` (internal).
fn build_provider(
entry: &ProviderEntry,
model_override: Option<&str>,
@ -172,7 +199,15 @@ fn build_provider(
if let Some(host) = &entry.base_url {
pc.base_url = host.clone();
}
Ok(Box::new(ZaiProvider::from_config(pc)))
// Coding-plan 的 key 使用专用路径(/api/coding/paas/v4/...);
// 否则回退到标准 PaaS 路径(/api/paas/v4/...)。
// Coding-plan keys use a distinct path (/api/coding/paas/v4/...);
// fall back to the standard PaaS path (/api/paas/v4/...).
let provider = match entry.path.as_deref() {
Some(path) => ZaiProvider::from_config_with_path(pc, path),
None => ZaiProvider::from_config(pc),
};
Ok(Box::new(provider))
}
"openai" => {
let host = entry
@ -193,6 +228,8 @@ fn build_provider(
}
}
/// 将 stdin 的剩余内容读取为一个 prompt。
/// Read the rest of stdin as a prompt.
fn read_stdin() -> Result<String, RunError> {
let mut buf = String::new();
io::stdin()
@ -201,6 +238,8 @@ fn read_stdin() -> Result<String, RunError> {
Ok(buf)
}
/// 将解析后的配置打印到 stdout。
/// Print the resolved config to stdout.
fn print_config(cfg: &Config) {
println!("default provider: {}", cfg.default);
for (name, entry) in &cfg.providers {
@ -211,6 +250,7 @@ fn print_config(cfg: &Config) {
}
}
/// 为尚无配置文件的首次用户打印引导信息。
/// Print guidance for first-run users who have no config file yet.
fn print_no_config_hint() {
let dir = config::config_dir()
@ -244,6 +284,7 @@ fn print_no_config_hint() {
eprintln!(" focus \"你好\"");
}
/// 一个事件 sink:将最终的 assistant 文本打印到 stdout。
/// An event sink that prints the final assistant text to stdout.
struct StdoutSink;
@ -261,6 +302,7 @@ impl EventSink for StdoutSink {
}
}
/// 提取 assistant 消息的拼接文本。
/// Extract the concatenated text of an assistant message.
fn assistant_text(a: &focus_core::model::AssistantMessage) -> Option<String> {
let mut out = String::new();

View File

@ -1,9 +1,13 @@
//! agent 循环——框架的核心。
//! The agent loop — the heart of the framework.
//!
//! 实现 pi 的双层循环设计:内层循环排空 steering 消息并执行工具调用,
//! 外层循环在 agent 本应停止之后继续处理 follow-up 消息。
//! Implements pi's double-loop design: an inner loop that drains steering
//! messages and executes tool calls, and an outer loop that picks up follow-up
//! messages after the agent would otherwise stop.
//!
//! 带注释的控制流见 `docs/architecture/02-agent-loop.md`。
//! See `docs/architecture/02-agent-loop.md` for the annotated control flow.
use crate::error::{CoreError, CoreResult};
@ -12,13 +16,23 @@ use crate::model::*;
use crate::provider::{ProviderRequest, StreamEvent, StreamProvider};
use crate::tool::{ToolEffects, ToolRegistry, ToolResult};
/// 一次 [`Agent`] 运行的配置。
/// Configuration for an [`Agent`] run.
#[derive(Clone)]
pub struct AgentConfig {
/// 模型 id。
/// The model id.
pub model: String,
/// 系统提示词。
/// The system prompt.
pub system_prompt: String,
/// 最大输出 token 数。
/// Max output tokens.
pub max_tokens: Option<u64>,
/// 采样温度。
/// Sampling temperature.
pub temperature: Option<f64>,
/// 强制停止前允许的最大工具调用回合数。
/// Maximum number of tool-call turns before forcing a stop.
pub max_tool_iterations: Option<usize>,
}
@ -35,6 +49,7 @@ impl Default for AgentConfig {
}
}
/// agent 持有对话记录、工具注册表与一个 provider。它是驱动对话的唯一入口。
/// The agent owns the transcript, the tool registry, and a provider. It is the
/// single entry point for driving a conversation.
pub struct Agent {
@ -45,6 +60,8 @@ pub struct Agent {
}
impl Agent {
/// 用配置、provider 与工具注册表构造 agent。
/// Build an agent from config, a provider, and a tool registry.
pub fn new(
config: AgentConfig,
provider: Box<dyn StreamProvider>,
@ -58,14 +75,19 @@ impl Agent {
}
}
/// 只读访问对话记录。
/// Read-only access to the transcript.
pub fn messages(&self) -> &[Message] {
&self.messages
}
/// 只读访问 agent 配置。
/// Read-only access to the agent config.
pub fn config(&self) -> &AgentConfig {
&self.config
}
/// 发送一条用户 prompt 并运行 agent 循环至完成。
/// Send a user prompt and run the agent loop to completion.
pub fn prompt(&mut self, text: impl Into<String>, sink: &mut dyn EventSink) -> CoreResult<()> {
let user = Message::user_text(text);
@ -80,6 +102,7 @@ impl Agent {
Ok(())
}
/// 继续对话(最后一条消息不能是 assistant)。
/// Continue the conversation (last message must not be assistant).
pub fn r#continue(&mut self, sink: &mut dyn EventSink) -> CoreResult<()> {
if self
@ -98,13 +121,17 @@ impl Agent {
Ok(())
}
/// 双层循环。
/// The double loop.
fn run_loop(&mut self, sink: &mut dyn EventSink) -> CoreResult<()> {
let mut new_messages: Vec<Message> = Vec::new();
let mut has_more_tool_calls = true;
let mut iteration = 0usize;
// 外层循环:只要还有工具调用就继续推进(steering + follow-up)。
// Outer loop: keep going as long as there are tool calls (steering + follow-up).
while has_more_tool_calls {
// 流式接收一次 assistant 响应。
// Stream one assistant response.
let message = self.stream_assistant_response(sink)?;
new_messages.push(Message::Assistant(message.clone()));
@ -120,6 +147,7 @@ impl Agent {
return Ok(());
}
// 检查回合预算。
// Check budget.
if let Some(max) = self.config.max_tool_iterations {
if iteration >= max {
@ -128,6 +156,7 @@ impl Agent {
}
iteration += 1;
// 提取并执行工具调用。
// Extract and execute tool calls.
let tool_calls: Vec<ToolCall> = message
.content
@ -140,6 +169,7 @@ impl Agent {
if !tool_calls.is_empty() {
if message.stop_reason == StopReason::Length {
// 输出被截断:参数可能不完整。全部判失败。
// Truncated output: arguments may be incomplete. Fail all.
for tc in &tool_calls {
let result = ToolResult::error(format!(
@ -182,6 +212,7 @@ impl Agent {
Ok(())
}
/// 从当前状态构造 [`ProviderRequest`] 并流式接收响应。
/// Build a [`ProviderRequest`] from current state and stream a response.
fn stream_assistant_response(
&mut self,
@ -203,6 +234,8 @@ impl Agent {
match &event {
StreamEvent::Start { partial: p } => {
partial = Some(p.clone());
// 立即把流式中的 assistant 消息推入记录,
// 让对话记录始终反映流式状态。
// Push the live assistant message immediately so the
// transcript always reflects the streaming state.
self.messages.push(Message::Assistant(p.clone()));
@ -227,6 +260,7 @@ impl Agent {
}
StreamEvent::Done { message } => {
let final_msg = if partial.is_some() {
// 用最终消息替换流式中的 partial。
// Replace the live partial with the final message.
if let Some(Message::Assistant(old)) = self.messages.last_mut() {
*old = message.clone();
@ -265,6 +299,7 @@ impl Agent {
}
}
// 流在没有终止事件的情况下结束:用最后一个 partial 合成一条。
// Stream ended without a terminal event: synthesize one from the last partial.
let synthesized = partial.unwrap_or_else(|| AssistantMessage {
content: vec![],
@ -285,6 +320,7 @@ impl Agent {
Ok(synthesized)
}
/// 执行一批工具调用,把兼容的调用分组以并行执行。
/// Execute a batch of tool calls, grouping compatible ones for parallelism.
fn execute_tool_calls(
&self,
@ -293,6 +329,8 @@ impl Agent {
) -> CoreResult<ExecutedBatch> {
let mut finalized: Vec<(ToolCall, ToolResult, bool)> = Vec::new();
// 收集每个工具调用的结果(错误也编码为结果)。
// Collect each tool call's result (errors encoded as results too).
for tc in tool_calls {
sink.emit(AgentEvent::ToolExecutionStart {
tool_call_id: tc.id.clone(),
@ -309,6 +347,8 @@ impl Agent {
finalized.push((tc.clone(), result.0, result.1));
}
// terminate 须满足"法定人数":非空且所有结果都请求终止。
// terminate requires a quorum: non-empty and every result requests it.
let terminate = !finalized.is_empty() && finalized.iter().all(|(_, r, _)| r.terminate);
let messages = finalized
.iter()
@ -325,6 +365,7 @@ impl Agent {
})
}
/// 查找工具、校验并运行。错误会被编码为结果。
/// Look up the tool, validate, and run it. Errors are encoded as results.
fn execute_single(&self, tc: &ToolCall, sink: &mut dyn EventSink) -> CoreResult<ToolResult> {
let tool = self
@ -352,14 +393,23 @@ impl std::fmt::Debug for Agent {
}
}
/// 一次运行返回的句柄(目前仅作信息用途)。
/// A handle returned from a run (currently just informational).
pub struct AgentHandle;
/// 一批已执行工具调用的结果。
/// The results of one executed batch of tool calls.
struct ExecutedBatch {
/// 产生的 tool-result 消息。
/// Produced tool-result messages.
messages: Vec<ToolResultMessage>,
/// 本批次是否请求终止。
/// Whether this batch requested termination.
terminate: bool,
}
/// 把 [`ToolResult`] 整理成一条 [`ToolResultMessage`]。
/// Finalize a [`ToolResult`] into a [`ToolResultMessage`].
fn finalize_tool_result(tc: &ToolCall, result: ToolResult, is_error: bool) -> ToolResultMessage {
ToolResultMessage {
tool_call_id: tc.id.clone(),
@ -375,6 +425,8 @@ fn finalize_tool_result(tc: &ToolCall, result: ToolResult, is_error: bool) -> To
}
}
/// 向 sink 发出一条 tool-result 消息的 Start/End 事件。
/// Emit the Start/End events for a tool-result message.
fn emit_tool_result_message(trm: &ToolResultMessage, sink: &mut dyn EventSink) {
sink.emit(AgentEvent::MessageStart {
message: Message::ToolResult(trm.clone()),
@ -384,13 +436,19 @@ fn emit_tool_result_message(trm: &ToolResultMessage, sink: &mut dyn EventSink) {
});
}
/// 根据各工具调用声明的副作用,规划并行安全的批次。
/// (设为 pub 以便 provider/测试可以调用。)
/// Plan parallel-safe batches from a set of tool calls by their declared effects.
/// (Public so providers/tests can exercise it.)
pub fn plan_batches(calls: &[(ToolCall, ToolEffects)]) -> Vec<Vec<usize>> {
let mut batches: Vec<Vec<usize>> = Vec::new();
// 贪心装箱:把每个调用放进第一个与之兼容的批次,否则开新批次。
// Greedy bucketing: place each call into the first compatible batch, else open a new one.
for (i, (_, effects)) in calls.iter().enumerate() {
let mut placed = false;
for batch in &mut batches {
// 双向兼容才可同批。
// Both directions must be compatible to share a batch.
let compatible = batch.iter().all(|&j| {
calls[j].1.compatible_with(*effects) && effects.compatible_with(calls[j].1)
});
@ -435,6 +493,7 @@ mod batch_tests {
(tc("c"), ToolEffects::READ),
];
let batches = plan_batches(&calls);
// a、c 一起读,b 单独写。
// a+c read together, b write alone
assert_eq!(batches.len(), 2);
assert!(batches.iter().any(|b| b == &vec![0, 2]));

View File

@ -1,22 +1,31 @@
//! core agent 层的错误类型。
//! Error types for the core agent layer.
use std::fmt;
/// core agent 层抛出的错误。
/// Errors raised by the core agent layer.
#[derive(Debug)]
pub enum CoreError {
/// 工具返回错误或发生 panic。
/// A tool returned an error or panicked.
Tool(String),
/// agent 找不到请求的工具。
/// The agent could not find a requested tool.
ToolNotFound(String),
/// 工具参数未通过类 JSON-schema 校验。
/// Tool arguments failed JSON-schema-like validation.
InvalidArgs(String),
/// 流式 provider 产生了非法的事件序列。
/// The stream provider produced a malformed event sequence.
Provider(String),
/// 领域类型的序列化/反序列化失败。
/// Serialization/deserialization of a domain type failed.
Json(String),
/// 操作被取消。
/// An operation was cancelled.
Cancelled,
/// 配置不变量被违反。
/// A configuration invariant was violated.
Config(String),
}
@ -37,11 +46,14 @@ impl fmt::Display for CoreError {
impl std::error::Error for CoreError {}
/// 将 JSON 错误转换为 core 错误(归入 Json 变体)。
/// Convert a JSON error into a core error (as the Json variant).
impl From<focus_json::JsonError> for CoreError {
fn from(e: focus_json::JsonError) -> Self {
CoreError::Json(e.to_string())
}
}
/// 便捷别名。
/// Convenience alias.
pub type CoreResult<T> = Result<T, CoreError>;

View File

@ -1,43 +1,55 @@
//! Agent 事件协议——[`Agent`](crate::agent::Agent) 向 UI 消费者发出的事件。
//! Agent event protocol — what the [`Agent`](crate::agent::Agent) emits to UI
//! consumers.
use crate::model::*;
/// agent 生命周期中的一个事件。
/// One event in the agent's lifecycle.
#[derive(Debug, Clone)]
pub enum AgentEvent {
/// 运行已开始。
/// The run has started.
AgentStart,
/// 运行结束;`messages` 为本次产生的新消息。
/// The run finished; `messages` are the new messages produced.
AgentEnd { messages: Vec<Message> },
/// 一个回合(一次 assistant 响应 + 其工具调用/结果)开始。
/// A turn (one assistant response + its tool calls/results) started.
TurnStart,
/// 一个回合结束。
/// A turn finished.
TurnEnd {
message: Message,
tool_results: Vec<ToolResultMessage>,
},
/// 一条消息进入对话记录。
/// A message entered the transcript.
MessageStart { message: Message },
/// 流式过程中一条 assistant 消息被更新。
/// An assistant message was updated while streaming.
MessageUpdate {
message: Message,
event_type: String,
},
/// 一条消息被最终确定。
/// A message was finalized.
MessageEnd { message: Message },
/// 一个工具开始执行。
/// A tool started executing.
ToolExecutionStart {
tool_call_id: String,
tool_name: String,
args: focus_json::JsonValue,
},
/// 一个工具发出增量更新。
/// A tool emitted an incremental update.
ToolExecutionUpdate {
tool_call_id: String,
tool_name: String,
partial_result: crate::tool::ToolUpdate,
},
/// 一个工具执行结束。
/// A tool finished.
ToolExecutionEnd {
tool_call_id: String,
@ -47,14 +59,20 @@ pub enum AgentEvent {
},
}
/// [`AgentEvent`] 的接收端。
/// A sink for [`AgentEvent`]s.
pub trait EventSink: Send {
/// 接收一个事件。
/// Receive one event.
fn emit(&mut self, event: AgentEvent);
}
/// 把事件收集到 `Vec` 的 sink。主要用于测试。
/// A sink that collects events into a `Vec`. Useful for tests.
#[derive(Default)]
pub struct VecSink {
/// 收集到的事件列表。
/// The collected events.
pub events: Vec<AgentEvent>,
}
@ -69,6 +87,7 @@ impl VecSink {
Self::default()
}
/// 按变体的字符串标签统计事件数量。
/// Count events of a given variant by its string label.
pub fn count(&self, label: &str) -> usize {
self.events

View File

@ -1,5 +1,8 @@
//! 领域类型与 [`JsonValue`] 之间的手写转换。
//! Hand-written conversions between domain types and [`JsonValue`].
//!
//! 我们刻意不使用 serde。每个领域类型都实现 [`FromJson`]/[`ToJson`],
//! 让转换显式且可调试。
//! We deliberately avoid serde. Each domain type implements
//! [`FromJson`]/[`ToJson`] so the conversions are explicit and debuggable.
@ -7,16 +10,19 @@ use crate::error::{CoreError, CoreResult};
use crate::model::*;
use focus_json::JsonValue;
/// 从 [`JsonValue`] 反序列化一个领域类型。
/// Deserialize a domain type from a [`JsonValue`].
pub trait FromJson: Sized {
fn from_json(value: &JsonValue) -> CoreResult<Self>;
}
/// 把一个领域类型序列化为 [`JsonValue`]。
/// Serialize a domain type into a [`JsonValue`].
pub trait ToJson {
fn to_json(&self) -> JsonValue;
}
// ---- 内容块 ----
// ---- content blocks -------------------------------------------------------
impl FromJson for ContentBlock {
@ -120,6 +126,7 @@ impl ToJson for ToolCall {
}
}
// ---- 用量 ----
// ---- usage ----------------------------------------------------------------
impl FromJson for Usage {
@ -148,6 +155,7 @@ impl ToJson for Usage {
}
}
// ---- 消息 ----
// ---- messages -------------------------------------------------------------
impl ToJson for Message {
@ -249,6 +257,8 @@ impl FromJson for Message {
}
}
/// 把 `content` 字段(数组或字符串)解析为内容块列表;缺失时返回空。
/// Parse the `content` field (array or string) into content blocks; empty when absent.
fn parse_content_array(value: Option<&JsonValue>) -> CoreResult<Vec<ContentBlock>> {
match value {
Some(JsonValue::Arr(items)) => items.iter().map(ContentBlock::from_json).collect(),
@ -258,11 +268,13 @@ fn parse_content_array(value: Option<&JsonValue>) -> CoreResult<Vec<ContentBlock
}
}
/// 序列化消息列表(用于会话持久化)。
/// Serialize a list of messages (used for session persistence).
pub fn messages_to_json(messages: &[Message]) -> JsonValue {
JsonValue::Arr(messages.iter().map(ToJson::to_json).collect())
}
/// 反序列化消息列表。
/// Deserialize a list of messages.
pub fn messages_from_json(value: &JsonValue) -> CoreResult<Vec<Message>> {
match value {

View File

@ -1,5 +1,9 @@
//! focus-core:agent 层。
//! focus-core: the agent layer.
//!
//! 本 crate 是框架的核心。它定义了领域类型(消息、内容块、工具调用、流事件)、
//! [`Tool`] 与 [`StreamProvider`] trait,以及驱动对话循环的 [`Agent`]。
//! 它不依赖任何 I/O——provider 与工具都通过 trait 注入。
//! This crate is the heart of the framework. It defines the domain types
//! (messages, content blocks, tool calls, stream events), the [`Tool`] and
//! [`StreamProvider`] traits, and the [`Agent`] that drives the conversation
@ -7,14 +11,30 @@
#![forbid(unsafe_code)]
/// 双层 agent 循环(steering + follow-up)与批处理工具执行。
/// The double-loop agent (steering + follow-up) and batched tool execution.
pub mod agent;
/// core 层的错误类型。
/// Error types for the core layer.
pub mod error;
/// agent 向外发出的事件与事件 sink。
/// Agent-emitted events and the event sink.
pub mod event;
/// 领域类型与手写 JSON 之间的转换。
/// Conversions between domain types and hand-written JSON.
pub mod json;
/// 领域类型:消息、内容块、工具调用、停止原因等。
/// Domain types: messages, content blocks, tool calls, stop reasons, …
pub mod model;
/// `StreamProvider` trait 与流事件归约器。
/// The `StreamProvider` trait and the stream-event reducer.
pub mod provider;
/// `Tool` trait、效果位标记与工具注册表。
/// The `Tool` trait, effects bitflag, and tool registry.
pub mod tool;
// 仅在测试或 mock feature 下编译。
// Compiled only under test or the mock feature.
#[cfg(any(test, feature = "mock"))]
pub mod mock;

View File

@ -1,5 +1,8 @@
//! 一个用于在不触网的情况下测试 agent 循环的 mock [`StreamProvider`]。
//! A mock [`StreamProvider`] for testing the agent loop without any network.
//!
//! 这是框架中最重要的测试工具:它让我们用脚本化的 [`StreamEvent`] 序列驱动循环,
//! 并对结果对话记录与已发出的事件做断言。
//! This is the single most important testing tool in the framework: it lets us
//! drive the loop through scripted [`StreamEvent`] sequences and assert on the
//! resulting transcript and emitted events.
@ -11,21 +14,26 @@ use crate::model::*;
use crate::provider::{ProviderRequest, StreamEvent, StreamIterator, StreamProvider, StreamResult};
use std::sync::{Arc, Mutex};
/// 构造一个回放「响应列表」的 mock provider——每个响应是一个回合的事件列表。
/// Build a mock provider that replays a list of *responses*, where each
/// response is a list of events for one assistant turn.
#[derive(Debug, Clone)]
pub struct MockProvider {
/// 响应队列;每次回合消费一个。
/// Queue of responses; each response is consumed by one turn.
responses: Arc<Mutex<Vec<Vec<StreamEvent>>>>,
}
impl MockProvider {
/// 用一组响应构造 mock provider。
/// Build a mock provider from a list of responses.
pub fn new(responses: Vec<Vec<StreamEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
}
}
/// 一个总是返回单条文本响应(带停止原因)的 provider。
/// A provider that always returns a single text response with stop reason.
pub fn single_text(text: &str, stop: StopReason) -> Self {
Self::new(vec![make_text_response(text, stop)])
@ -52,6 +60,8 @@ impl StreamProvider for MockProvider {
}
}
/// 一个回放固定事件序列的流。
/// A stream that replays a fixed event sequence.
struct MockStream {
events: Vec<StreamEvent>,
}
@ -66,6 +76,7 @@ impl StreamIterator for MockStream {
}
}
/// 构造一个简单的文本响应事件序列。
/// Build a simple text-response event sequence.
pub fn make_text_response(text: &str, stop: StopReason) -> Vec<StreamEvent> {
let model = "mock-model".to_string();
@ -117,6 +128,7 @@ pub fn make_text_response(text: &str, stop: StopReason) -> Vec<StreamEvent> {
]
}
/// 构造一个工具调用响应:模型请求调用一个工具。
/// Build a tool-call response: the model asks to call a tool.
pub fn make_tool_call_response(
thinking_text: Option<&str>,

View File

@ -1,55 +1,94 @@
//! 领域类型:消息、内容块、工具调用、用量、停止原因。
//! Domain types: messages, content blocks, tool calls, usage, stop reasons.
//!
//! 这些类型对应 pi 的 `pi-ai/types.ts` 与 `agent/types.ts`。所有类型都实现了
//! 与 [`JsonValue`] 之间的手写转换(见 [`crate::json`])。
//! These mirror pi's `pi-ai/types.ts` and `agent/types.ts`. All types implement
//! hand-written conversions to/from [`JsonValue`] (see [`crate::json`]).
use std::fmt;
// ---- 内容块 ----
// ---- content blocks -------------------------------------------------------
/// 消息内的一个内容块。按 `type` 标记,对应 pi 的 `ContentBlock`。
/// A block of content inside a message. Tagged on `type` like pi's
/// `ContentBlock`.
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBlock {
/// 纯文本。
/// Plain text.
Text(TextContent),
/// 模型推理(扩展思考)。
/// Model reasoning (extended thinking).
Thinking(ThinkingContent),
/// 图片(base64)。仅在 user 与 tool-result 消息中合法。
/// An image (base64). Only valid in user and tool-result messages.
Image(ImageContent),
/// assistant 请求调用一个工具。
/// A request from the assistant to call a tool.
ToolCall(ToolCall),
}
/// 纯文本内容块。
/// Plain-text content block.
#[derive(Debug, Clone, PartialEq)]
pub struct TextContent {
/// 文本内容。
/// The text.
pub text: String,
/// 可选签名(用于 thinking/redo 校验)。
/// Optional signature (for thinking/redo verification).
pub signature: Option<String>,
}
/// 模型思考内容块。
/// Model thinking content block.
#[derive(Debug, Clone, PartialEq)]
pub struct ThinkingContent {
/// 思考内容文本。
/// The thinking text.
pub thinking: String,
/// 可选签名。
/// Optional signature.
pub signature: Option<String>,
/// 是否为已被涂黑(redacted)的思考。
/// Whether the thinking was redacted.
pub redacted: bool,
}
/// 图片内容块(base64 编码)。
/// Image content block (base64-encoded).
#[derive(Debug, Clone, PartialEq)]
pub struct ImageContent {
/// base64 编码的图片数据。
/// base64-encoded image data.
pub data: String,
/// MIME 类型。
/// The MIME type.
pub mime_type: String,
}
/// assistant 发出的工具调用。`arguments` 以原始 [`JsonValue`] 存储,
/// 因为各工具自定义其参数形状。
/// A tool call issued by the assistant. `arguments` is stored as a raw
/// [`JsonValue`] because tools define their own argument shapes.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
/// 本次调用的唯一 id(与后续 tool_result 对应)。
/// Unique id of this call (matched by the later tool_result).
pub id: String,
/// 工具名。
/// The tool name.
pub name: String,
/// 工具参数(原始 JSON)。
/// The tool arguments (raw JSON).
pub arguments: focus_json::JsonValue,
}
impl ContentBlock {
/// 用文本构造一个 `Text` 内容块。
/// Build a `Text` content block from a string.
pub fn text(s: impl Into<String>) -> Self {
ContentBlock::Text(TextContent {
text: s.into(),
@ -57,6 +96,8 @@ impl ContentBlock {
})
}
/// 若为文本块,借用为 [`TextContent`]。
/// Borrow as [`TextContent`] if this is a text block.
pub fn as_text(&self) -> Option<&TextContent> {
match self {
ContentBlock::Text(t) => Some(t),
@ -64,6 +105,8 @@ impl ContentBlock {
}
}
/// 若为工具调用块,借用为 [`ToolCall`]。
/// Borrow as [`ToolCall`] if this is a tool-call block.
pub fn as_tool_call(&self) -> Option<&ToolCall> {
match self {
ContentBlock::ToolCall(tc) => Some(tc),
@ -72,8 +115,10 @@ impl ContentBlock {
}
}
// ---- 消息 ----
// ---- messages -------------------------------------------------------------
/// 对话记录中的一条消息。按 `role` 标记。
/// One message in the conversation transcript. Tagged on `role`.
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
@ -82,33 +127,69 @@ pub enum Message {
ToolResult(ToolResultMessage),
}
/// 用户消息。
/// A user message.
#[derive(Debug, Clone, PartialEq)]
pub struct UserMessage {
/// 内容块列表。
/// The content blocks.
pub content: Vec<ContentBlock>,
/// 毫秒级时间戳。
/// Millisecond timestamp.
pub timestamp: u64,
}
/// assistant 消息。
/// An assistant message.
#[derive(Debug, Clone, PartialEq)]
pub struct AssistantMessage {
/// 内容块列表。
/// The content blocks.
pub content: Vec<ContentBlock>,
/// 生成该消息的模型名。
/// The model that produced this message.
pub model: String,
/// 本次用量统计。
/// Usage counters for this turn.
pub usage: Usage,
/// 停止原因。
/// Why the turn ended.
pub stop_reason: StopReason,
/// 若出错,记录错误信息。
/// Error message, if the turn errored.
pub error_message: Option<String>,
/// 毫秒级时间戳。
/// Millisecond timestamp.
pub timestamp: u64,
}
/// 工具结果消息。
/// A tool-result message.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolResultMessage {
/// 对应的工具调用 id。
/// The tool-call id this result answers.
pub tool_call_id: String,
/// 工具名。
/// The tool name.
pub tool_name: String,
/// 内容块列表。
/// The content blocks.
pub content: Vec<ContentBlock>,
/// 结构化细节(原始 JSON)。
/// Structured details (raw JSON).
pub details: focus_json::JsonValue,
/// 是否为错误结果。
/// Whether this is an error result.
pub is_error: bool,
/// 毫秒级时间戳。
/// Millisecond timestamp.
pub timestamp: u64,
}
impl Message {
/// 返回角色字符串:`"user"` / `"assistant"` / `"toolResult"`。
/// Return the role string: `"user"` / `"assistant"` / `"toolResult"`.
pub fn role(&self) -> &'static str {
match self {
Message::User(_) => "user",
@ -117,6 +198,8 @@ impl Message {
}
}
/// 便捷构造:一条仅含文本的 user 消息。
/// Convenience constructor: a user message containing a single text block.
pub fn user_text(text: impl Into<String>) -> Self {
Message::User(UserMessage {
content: vec![ContentBlock::text(text)],
@ -125,32 +208,57 @@ impl Message {
}
}
// ---- 用量 / 成本 ----
// ---- usage / cost ---------------------------------------------------------
/// 一次回合的 token 用量统计。
/// Token usage counters for one turn.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Usage {
/// 输入 token 数。
/// Input token count.
pub input_tokens: u64,
/// 输出 token 数。
/// Output token count.
pub output_tokens: u64,
/// 命中缓存读取的 token 数。
/// Cache-read token count.
pub cache_read_tokens: u64,
/// 触发缓存写入的 token 数。
/// Cache-write token count.
pub cache_write_tokens: u64,
}
impl Usage {
/// 输入与输出 token 之和(不含缓存)。
/// Sum of input and output tokens (excluding cache).
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens
}
}
// ---- 停止原因 ----
// ---- stop reason ----------------------------------------------------------
/// assistant 回合结束的原因。
/// Why the assistant turn ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StopReason {
#[default]
/// 正常停止。
/// Normal stop.
Stop,
/// 达到长度上限。
/// Length limit reached.
Length,
/// 触发工具调用。
/// A tool call was made.
ToolUse,
/// 出错。
/// An error occurred.
Error,
/// 被中止。
/// Aborted.
Aborted,
}
@ -161,6 +269,8 @@ impl fmt::Display for StopReason {
}
impl StopReason {
/// 返回线路上使用的字符串形式。
/// Return the wire-format string.
pub fn as_str(&self) -> &'static str {
match self {
StopReason::Stop => "stop",
@ -171,6 +281,8 @@ impl StopReason {
}
}
/// 由字符串解析为 [`StopReason`]。
/// Parse a string into a [`StopReason`].
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"stop" => Some(StopReason::Stop),
@ -182,14 +294,17 @@ impl StopReason {
}
}
/// 若该原因表示必须结束运行的致命回合,则返回 true。
/// True if the reason indicates a fatal turn that must end the run.
pub fn is_fatal(&self) -> bool {
matches!(self, StopReason::Error | StopReason::Aborted)
}
}
// ---- 辅助 ----
// ---- helpers --------------------------------------------------------------
/// 距 Unix 纪元的当前毫秒时间戳。纯 std 实现。
/// Current time in milliseconds since the Unix epoch. Pure std.
pub fn now_ms() -> u64 {
std::time::SystemTime::now()

View File

@ -1,6 +1,11 @@
//! [`StreamProvider`] trait——agent 循环与具体 LLM 后端之间的 I/O 边界。
//! The [`StreamProvider`] trait — the I/O boundary between the agent loop and
//! concrete LLM backends.
//!
//! provider 以 [`StreamEvent`] 流的形式输出。它们**绝不**通过返回 `Err` 来表示
//! API 失败——失败被编码为带相应 [`StopReason`](crate::model::StopReason) 的
//! [`StreamEvent::Error`]/[`StreamEvent::Done`]。返回 `Err` 仅留给真正意料之外
//! 的情况(例如 provider 无法被构造)。
//! Providers stream [`StreamEvent`]s. They must **never** return an `Err` to
//! signal an API failure — failures are encoded as
//! [`StreamEvent::Error`]/[`StreamEvent::Done`] with an appropriate
@ -12,77 +17,105 @@ use crate::model::*;
use focus_json::JsonValue;
use std::fmt;
/// agent 向 provider 发出的请求。
/// A request the agent makes to a provider.
#[derive(Debug, Clone)]
pub struct ProviderRequest {
/// 模型 id。
/// The model id.
pub model: String,
/// 系统提示词。
/// The system prompt.
pub system_prompt: String,
/// 对话消息列表。
/// The conversation messages.
pub messages: Vec<Message>,
/// 工具定义(原始 JSON)。
/// Tool definitions (raw JSON).
pub tools: JsonValue,
/// 最大输出 token 数。
/// Max output tokens.
pub max_tokens: Option<u64>,
/// 采样温度。
/// Sampling temperature.
pub temperature: Option<f64>,
}
/// provider 流式响应过程中发出的事件。
/// Events emitted by a provider while streaming a response.
///
/// 每个携带 delta 的变体都会在 `partial` 中携带当前完整的 [`AssistantMessage`]
/// 快照,对应 pi 的协议——这样循环本身无需累积 delta。
/// Every variant that carries a delta also carries the current full
/// [`AssistantMessage`] snapshot in `partial`, mirroring pi's protocol so the
/// loop never needs to accumulate deltas itself.
#[derive(Debug, Clone)]
pub enum StreamEvent {
/// 响应已开始;附带一个空/部分的 assistant 消息。
/// The response has started; here is an empty/partial assistant message.
Start { partial: AssistantMessage },
/// 在 `content_index` 处打开了一个文本块。
/// A text block opened at `content_index`.
TextStart {
content_index: usize,
partial: AssistantMessage,
},
/// 向 `content_index` 处的文本块追加了文本。
/// Text appended to the block at `content_index`.
TextDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
/// `content_index` 处的文本块结束。
/// The text block at `content_index` finished.
TextEnd {
content_index: usize,
partial: AssistantMessage,
},
/// 打开了一个思考块。
/// A thinking block opened.
ThinkingStart {
content_index: usize,
partial: AssistantMessage,
},
/// 追加了思考文本。
/// Thinking text appended.
ThinkingDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
/// 思考块结束。
/// The thinking block finished.
ThinkingEnd {
content_index: usize,
partial: AssistantMessage,
},
/// 打开了一个工具调用块。
/// A tool-call block opened.
ToolCallStart {
content_index: usize,
partial: AssistantMessage,
},
/// 向工具调用追加了部分 JSON 参数。
/// Partial JSON arguments appended to the tool call.
ToolCallDelta {
content_index: usize,
delta: String,
partial: AssistantMessage,
},
/// 工具调用结束,附带最终的 [`ToolCall`]。
/// The tool call finished with its final [`ToolCall`].
ToolCallEnd {
content_index: usize,
tool_call: ToolCall,
partial: AssistantMessage,
},
/// 响应正常完成。
/// The response completed normally.
Done { message: AssistantMessage },
/// 响应失败或被中止。
/// The response failed or was aborted.
Error { error: AssistantMessage },
}
@ -106,9 +139,14 @@ impl fmt::Display for StreamEvent {
}
}
/// 一个辅助器:把 provider 的增量 delta 累积进 [`AssistantMessage`],
/// 并发出带最新 partial 的 [`StreamEvent`]。
/// A helper that accumulates incremental provider deltas into an
/// [`AssistantMessage`] and emits [`StreamEvent`]s with up-to-date partials.
///
/// 像 OpenAI 这样的 provider 会以扁平序列流式输出内容/工具调用 delta,且没有显式
/// 的 block-start/stop 标记;本 reducer 填补这一空缺,使 provider 只需喂入文本/
/// 工具 delta 和一个 finish reason。
/// Providers like OpenAI stream content/tool-call deltas in a flat sequence
/// without explicit block-start/stop markers; this reducer fills that gap so a
/// provider only needs to feed text/tool deltas and a finish reason.
@ -137,6 +175,8 @@ impl ProviderEventReducer {
}
}
/// 用当前累积状态构造一条 partial [`AssistantMessage`] 快照。
/// Build a partial [`AssistantMessage`] snapshot from the current accumulator.
fn partial(&self) -> AssistantMessage {
AssistantMessage {
content: self.content.clone(),
@ -148,6 +188,8 @@ impl ProviderEventReducer {
}
}
/// 首次调用时发出 `Start` 事件;之后返回 `None`。
/// Emit the `Start` event on first call; `None` thereafter.
fn ensure_started(&mut self) -> Option<StreamEvent> {
if !self.started {
self.started = true;
@ -158,6 +200,7 @@ impl ProviderEventReducer {
None
}
/// 喂入一个文本 delta;按需打开 Start + TextStart。
/// Feed a text delta. Opens a Start + TextStart as needed.
pub fn text_delta(&mut self, text: &str) -> Vec<StreamEvent> {
let mut out = Vec::new();
@ -186,6 +229,7 @@ impl ProviderEventReducer {
out
}
/// 在 `index`(provider 本地索引,映射到一个新内容槽)处打开工具调用块。
/// Open a tool-call block at `index` (provider-local index, mapped to a
/// new content slot).
pub fn tool_call_start(&mut self, _index: usize, name: &str) -> Vec<StreamEvent> {
@ -206,6 +250,7 @@ impl ProviderEventReducer {
out
}
/// 向 `index` 处的工具调用追加部分 JSON 参数。
/// Append partial JSON arguments to the tool call at `index`.
pub fn tool_call_delta(&mut self, _index: usize, args: &str) -> Vec<StreamEvent> {
let mut out = Vec::new();
@ -221,12 +266,16 @@ impl ProviderEventReducer {
out
}
/// 设置停止原因。
/// Set the stop reason.
pub fn set_stop(&mut self, reason: StopReason) {
self.stop_reason = reason;
}
/// Finalize: close any open tool calls, return the Done event.
/// 收尾:关闭所有未闭合的工具调用,返回最终的 Done 消息。
/// Finalize: close any open tool calls, return the final message.
pub fn finish(&mut self) -> Option<AssistantMessage> {
// 通过解析累积的 args 来关闭工具调用。
// Close tool calls by parsing their accumulated args.
for (i, args) in self.partial_args.iter().enumerate() {
if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) {
@ -238,8 +287,10 @@ impl ProviderEventReducer {
Some(msg)
}
/// 为所有未闭合的内容块(文本/工具调用)发出 end 事件;之后调用方应再调用
/// [`finish`](Self::finish) 取得最终 Done。按顺序返回这些 end 事件。
/// Emit end events for any open content blocks (text/tool calls), then
/// callers should call [`finish`] for the final Done. Returns the end
/// callers should call [`finish`](Self::finish) for the final message. Returns the end
/// events in order.
pub fn finalize_events(&mut self) -> Vec<StreamEvent> {
let mut out = Vec::new();
@ -250,6 +301,7 @@ impl ProviderEventReducer {
});
self.text_open = false;
}
// 关闭每个工具调用块。
// Close each tool call block.
for (i, args) in self.partial_args.iter().enumerate() {
if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) {
@ -266,24 +318,31 @@ impl ProviderEventReducer {
}
}
/// provider 产出的、装箱后的事件流。provider 持有底层连接/驱动循环;
/// agent 通过 [`StreamIterator::next_event`] 拉取事件。
/// The boxed stream of events a provider produces. The provider owns the
/// underlying connection/drive loop; the agent pulls events via
/// [`StreamIterator::next_event`].
pub type StreamResult = CoreResult<Box<dyn StreamIterator + Send>>;
/// 流事件的同步迭代器。provider 驱动其完成;agent 循环拉取事件直到 `None`。
/// A sync iterator over stream events. The provider drives this to completion;
/// the agent loop pulls events until `None`.
pub trait StreamIterator {
fn next_event(&mut self) -> Option<StreamEvent>;
}
/// 每个 LLM 后端都实现的 trait。
/// The trait every LLM backend implements.
pub trait StreamProvider: Send + Sync + fmt::Debug {
/// 开始流式响应。返回的迭代器会持续产出事件,直到终止性的 `Done`/`Error`。
/// Begin streaming a response. The returned iterator yields events until a
/// terminal `Done`/`Error`.
fn stream(&self, request: &ProviderRequest) -> StreamResult;
}
/// 装箱的 provider 本身也是 provider,这样 [`Agent`](crate::agent::Agent)
/// 可以用 `Box::new(some_provider)` 构造。
/// A boxed provider is itself a provider, so the [`Agent`](crate::agent::Agent)
/// can be constructed with `Box::new(some_provider)`.
impl StreamProvider for Box<dyn StreamProvider> {

View File

@ -1,5 +1,9 @@
//! [`Tool`] trait 与 [`ToolRegistry`]。
//! The [`Tool`] trait and [`ToolRegistry`].
//!
//! 工具是 agent 的双手。一个工具接收经过校验的 JSON 参数,返回一个
//! [`ToolResult`] 以及可选的增量 [`ToolUpdate`]。工具能否与其他工具并发执行,
//! 通过 [`ToolEffects`] 声明。
//! Tools are the agent's hands. A tool receives validated JSON arguments and
//! returns a [`ToolResult`] plus optional incremental [`ToolUpdate`]s. Whether a
//! tool may run concurrently with others is declared via [`ToolEffects`].
@ -9,21 +13,28 @@ use crate::model::*;
use focus_json::JsonValue;
use std::fmt;
/// 工具执行的最终结果。
/// The final result of a tool execution.
#[derive(Debug, Clone)]
pub struct ToolResult {
/// 模型能看到的内容(文本/图片块)。
/// What the model sees (text/image blocks).
pub content: Vec<ContentBlock>,
/// UI/日志能看到的内容,对模型不可见。
/// What the UI/logs see, opaque to the model.
pub details: JsonValue,
/// 本次结果新引入的工具名(延迟加载)。
/// Names of tools newly introduced by this result (deferred loading).
pub added_tool_names: Vec<String>,
/// 在本批次后停止 agent 的提示(仅当本批次所有结果都同意时才生效)。
/// Hint to stop the agent after this batch (only honored if every result
/// in the batch agrees).
pub terminate: bool,
}
impl ToolResult {
/// 用一段文本构造一个普通结果。
/// Build a plain text result.
pub fn text(text: impl Into<String>) -> Self {
ToolResult {
content: vec![ContentBlock::text(text)],
@ -33,6 +44,8 @@ impl ToolResult {
}
}
/// 用一段文本构造一个错误结果。
/// Build an error result from a text message.
pub fn error(text: impl Into<String>) -> Self {
ToolResult {
content: vec![ContentBlock::text(text)],
@ -43,45 +56,64 @@ impl ToolResult {
}
}
/// 工具运行过程中发出的增量更新(例如流式 stdout)。
/// An incremental update emitted while a tool runs (e.g. streaming stdout).
#[derive(Debug, Clone)]
pub struct ToolUpdate {
/// 内容块。
/// Content blocks.
pub content: Vec<ContentBlock>,
/// 结构化细节。
/// Structured details.
pub details: JsonValue,
}
/// 副作用分类,用于并行安全调度。
/// Side-effect classification used for parallel-safety scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ToolEffects(pub u8);
impl ToolEffects {
// 各位含义:READ=读、WRITE=写、NETWORK=网络、PROCESS=进程。
// Bit meaning: READ=read, WRITE=write, NETWORK=network, PROCESS=process.
pub const NONE: ToolEffects = ToolEffects(0);
pub const READ: ToolEffects = ToolEffects(1 << 0);
pub const WRITE: ToolEffects = ToolEffects(1 << 1);
pub const NETWORK: ToolEffects = ToolEffects(1 << 2);
pub const PROCESS: ToolEffects = ToolEffects(1 << 3);
/// 默认 fail-closed:未声明副作用的工具被视为有副作用(不会与其他工具并发)。
/// Default to fail-closed: a tool that doesn't declare effects is treated
/// as having side effects (so it won't run concurrently with others).
pub fn write() -> Self {
ToolEffects(ToolEffects::WRITE.0)
}
/// 是否包含 `other` 的所有位。
/// Whether all bits of `other` are set.
pub fn contains(self, other: Self) -> bool {
(self.0 & other.0) == other.0
}
/// 当且仅当两者都不在对方读写时写、都不触网、都不开进程时,两次工具调用才兼容
/// (可并行执行)。
/// Two tool calls are compatible (can run in parallel) iff neither writes
/// while the other reads or writes, and neither touches the network or
/// spawns a process.
pub fn compatible_with(self, other: Self) -> bool {
// 写冲突:任一方写、且双方存在交互。
// Write conflict: either side writes and the two interact.
let writes_conflict = (self.contains(ToolEffects::WRITE)
|| other.contains(ToolEffects::WRITE))
&& (self.0 | other.0 != 0
|| self.contains(ToolEffects::WRITE) && other.contains(ToolEffects::WRITE));
// 写与读冲突:一方写、另一方存在非写操作。
// Write-vs-read conflict: one side writes, the other does something non-write.
let write_vs_read = self.contains(ToolEffects::WRITE)
&& (other.0 & !ToolEffects::WRITE.0 != 0)
|| other.contains(ToolEffects::WRITE) && (self.0 & !ToolEffects::WRITE.0 != 0);
// 副作用:任一方触网或开进程。
// Side effects: either side touches the network or spawns a process.
let side_effects = self.contains(ToolEffects::NETWORK)
|| other.contains(ToolEffects::NETWORK)
|| self.contains(ToolEffects::PROCESS)
@ -113,25 +145,37 @@ impl fmt::Display for ToolEffects {
}
}
/// agent 可调用的工具。
/// A tool the agent can call.
///
/// 实现必须廉价可克隆(通常是 `Arc` 支撑的共享状态),因为注册表在派发时会克隆工具。
/// Implementations must be cheap to clone (typically `Arc`-backed shared
/// state) because the registry clones tools when dispatching.
pub trait Tool: Send + Sync + fmt::Debug {
/// 工具名(模型调用时使用)。
/// The tool name (used when the model calls it).
fn name(&self) -> &str;
/// 显示标签,默认与 [`name`](Self::name) 相同。
/// Display label, defaults to the [`name`](Self::name).
fn label(&self) -> &str {
self.name()
}
/// 给模型看的工具描述。
/// Description shown to the model.
fn description(&self) -> &str;
/// 描述模型应提供参数的 JSON schema。
/// JSON schema describing the arguments the model should provide.
fn parameters(&self) -> JsonValue;
/// 用于调度的副作用分类。
/// Side-effect classification for scheduling.
fn effects(&self) -> ToolEffects {
// Fail-closed 默认值。
// Fail-closed default.
ToolEffects::write()
}
/// 运行工具。`on_update` 可在返回的 future 完成之前被调用以推送部分结果。
/// Run the tool. `on_update` may be invoked with partial results before the
/// returned future resolves.
fn execute(
@ -142,9 +186,11 @@ pub trait Tool: Send + Sync + fmt::Debug {
) -> CoreResult<ToolResult>;
}
/// 工具用来推送增量更新的 channel 式 sink。
/// A channel-style sink a tool uses to push incremental updates.
pub type ToolUpdateSink = dyn Fn(ToolUpdate) + Send + Sync;
/// 可用工具的注册表,按名查找。
/// A registry of available tools, looked up by name.
#[derive(Default)]
pub struct ToolRegistry {
@ -160,20 +206,28 @@ impl fmt::Debug for ToolRegistry {
}
impl ToolRegistry {
/// 创建一个空注册表。
/// Create an empty registry.
pub fn new() -> Self {
Self::default()
}
/// 用单个工具构造注册表。
/// Build a registry containing a single tool.
pub fn with(tool: Box<dyn Tool>) -> Self {
let mut r = Self::new();
r.tools.push(tool);
r
}
/// 注册一个工具。
/// Register a tool.
pub fn register(&mut self, tool: Box<dyn Tool>) {
self.tools.push(tool);
}
/// 按名查找工具。
/// Look up a tool by name.
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools
.iter()
@ -181,18 +235,25 @@ impl ToolRegistry {
.map(|t| t.as_ref())
}
/// 遍历所有工具。
/// Iterate over all tools.
pub fn iter(&self) -> impl Iterator<Item = &dyn Tool> {
self.tools.iter().map(|t| t.as_ref())
}
/// 工具数量。
/// Number of tools.
pub fn len(&self) -> usize {
self.tools.len()
}
/// 是否为空。
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
/// 构造发送给模型的工具定义 JSON 数组。
/// Build the JSON array of tool definitions sent to the model.
pub fn tool_definitions(&self) -> JsonValue {
JsonValue::Arr(
@ -265,8 +326,10 @@ mod tests {
#[test]
fn read_effects_compatible() {
assert!(ToolEffects::READ.compatible_with(ToolEffects::READ));
// 写与读不兼容。
// write vs read is not compatible
assert!(!ToolEffects::WRITE.compatible_with(ToolEffects::READ));
// 触网/开进程会阻止并行。
// network/process block parallelism
assert!(!ToolEffects::NETWORK.compatible_with(ToolEffects::NONE));
assert!(!ToolEffects::PROCESS.compatible_with(ToolEffects::NONE));

View File

@ -1,5 +1,7 @@
//! focus-harness:会话持久化、上下文压缩与系统提示词模板。
//! focus-harness: session persistence, compaction, system prompts.
//!
//! 本里程碑阶段尚未实现。
//! Not yet implemented in this milestone.
#![forbid(unsafe_code)]

View File

@ -1,11 +1,17 @@
//! JSON 错误类型。
//! JSON error type.
use std::fmt;
/// 解析或序列化 JSON 时产生的错误。
/// Errors produced while parsing or serializing JSON.
#[derive(Debug)]
pub struct JsonError {
/// 人类可读的错误描述。
/// Human-readable description of the error.
pub message: String,
/// 触发错误的字节偏移量(若可用)。
/// Byte offset where the error occurred, if known.
pub position: Option<usize>,
}
@ -21,6 +27,8 @@ impl fmt::Display for JsonError {
impl std::error::Error for JsonError {}
impl JsonError {
/// 创建一个不带位置信息的错误。
/// Create an error with no position information.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
@ -28,6 +36,8 @@ impl JsonError {
}
}
/// 创建一个带字节偏移位置的错误。
/// Create an error with a byte-offset position.
pub fn at(message: impl Into<String>, position: usize) -> Self {
Self {
message: message.into(),
@ -36,5 +46,6 @@ impl JsonError {
}
}
/// JSON 操作的 `Result` 便捷别名。
/// Convenience `Result` alias for JSON operations.
pub type JsonResult<T> = Result<T, JsonError>;

View File

@ -1,5 +1,9 @@
//! 精简的 JSON 解析器与序列化器。
//! Minimal JSON parser and serializer.
//!
//! 提供 [`JsonValue`] DOM 以及 [`parse`] / [`to_string`] 函数。
//! 对象键保留插入顺序。支持完整的 JSON 文法(对象、数组、含转义的字符串、
//! 数字、布尔值、null),但不支持任何扩展(不支持注释、尾随逗号、JSONC)。
//! Provides a [`JsonValue`] DOM and the [`parse`] / [`to_string`] functions.
//! Object keys preserve insertion order. Supports the full JSON grammar
//! (objects, arrays, strings with escapes, numbers, booleans, null) but no
@ -12,7 +16,15 @@ mod parser;
mod value;
mod writer;
/// JSON 错误类型与结果别名。
/// JSON error type and result alias.
pub use error::{JsonError, JsonResult};
/// 解析 JSON 文本的入口函数。
/// Entry function for parsing JSON text.
pub use parser::parse;
/// 表示已解析 JSON 值的动态类型。
/// Dynamic type representing a parsed JSON value.
pub use value::JsonValue;
/// 将 JSON 值序列化为字符串的入口函数。
/// Entry function for serializing a JSON value to a string.
pub use writer::to_string;

View File

@ -1,5 +1,7 @@
//! 递归下降的 JSON 解析器。
//! Recursive-descent JSON parser.
//!
//! 实现的文法(RFC 8259 子集,无扩展):
//! Grammar implemented (RFC 8259 subset, no extensions):
//! ```text
//! value = object | array | string | number | true | false | null
@ -13,6 +15,7 @@
use crate::error::{JsonError, JsonResult};
use crate::value::JsonValue;
/// 将一份 JSON 文档解析为 [`JsonValue`]。
/// Parse a JSON document into a [`JsonValue`].
pub fn parse(input: &str) -> JsonResult<JsonValue> {
let bytes = input.as_bytes();
@ -30,29 +33,47 @@ pub fn parse(input: &str) -> JsonResult<JsonValue> {
Ok(value)
}
/// 递归下降解析器的游标状态。
/// Cursor state for the recursive-descent parser.
struct Parser<'a> {
/// 待解析的字节切片。
/// The byte slice being parsed.
bytes: &'a [u8],
/// 当前字节偏移。
/// Current byte offset.
pos: usize,
/// 当前嵌套深度(用于防御性栈溢出保护)。
/// Current nesting depth (defensive stack-overflow guard).
depth: usize,
}
/// 最大嵌套深度,防止恶意输入导致栈溢出。
/// Maximum nesting depth, guarding against malicious stack overflow.
const MAX_DEPTH: usize = 512;
impl<'a> Parser<'a> {
/// 在当前位置构造一个带位置信息的错误。
/// Build an error tagged with the current position.
fn error(&self, msg: &str) -> JsonError {
JsonError::at(msg, self.pos)
}
/// 偷看当前字节(不推进)。
/// Peek at the current byte without advancing.
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
/// 读取并消费当前字节。
/// Read and consume the current byte.
fn bump(&mut self) -> Option<u8> {
let b = self.peek()?;
self.pos += 1;
Some(b)
}
/// 跳过空白字符。
/// Skip whitespace.
fn skip_ws(&mut self) {
while let Some(b) = self.peek() {
match b {
@ -62,6 +83,8 @@ impl<'a> Parser<'a> {
}
}
/// 期望并消费一个指定字节;不匹配则报错。
/// Expect and consume a specific byte, erroring on mismatch.
fn expect(&mut self, expected: u8, what: &str) -> JsonResult<()> {
match self.peek() {
Some(b) if b == expected => {
@ -73,8 +96,12 @@ impl<'a> Parser<'a> {
}
}
/// 根据首字节分派,解析任意 JSON 值。包含深度检查。
/// Dispatch on the leading byte to parse any JSON value. Includes a depth check.
fn parse_value(&mut self) -> JsonResult<JsonValue> {
self.skip_ws();
// 嵌套过深则拒绝,防止栈溢出。
// Reject nesting that is too deep, to guard the stack.
if self.depth >= MAX_DEPTH {
return Err(self.error("JSON nesting too deep"));
}
@ -96,6 +123,8 @@ impl<'a> Parser<'a> {
Ok(value)
}
/// 匹配一个字面量关键字(true/false/null)。
/// Match a literal keyword (true/false/null).
fn parse_keyword(&mut self, kw: &str, value: JsonValue) -> JsonResult<JsonValue> {
let kw_bytes = kw.as_bytes();
if self.bytes.len() < self.pos + kw_bytes.len() {
@ -108,6 +137,8 @@ impl<'a> Parser<'a> {
Ok(value)
}
/// 解析对象:`"{" [ member *( "," member ) ] ")"`。
/// Parse an object: `"{" [ member *( "," member ) ] "}"`.
fn parse_object(&mut self) -> JsonResult<JsonValue> {
self.expect(b'{', "'{'")?;
let mut entries = Vec::new();
@ -143,6 +174,8 @@ impl<'a> Parser<'a> {
Ok(JsonValue::Obj(entries))
}
/// 解析数组:`"[" [ value *( "," value ) ] "]"`。
/// Parse an array: `"[" [ value *( "," value ) ] "]"`.
fn parse_array(&mut self) -> JsonResult<JsonValue> {
self.expect(b'[', "'['")?;
let mut items = Vec::new();
@ -174,6 +207,8 @@ impl<'a> Parser<'a> {
Ok(JsonValue::Arr(items))
}
/// 解析字符串(含转义与 UTF-8 多字节处理)。
/// Parse a string (with escapes and multibyte UTF-8 handling).
fn parse_string(&mut self) -> JsonResult<String> {
self.expect(b'"', "'\"'")?;
let mut out = String::new();
@ -205,10 +240,13 @@ impl<'a> Parser<'a> {
}
}
}
// JSON 字符串中不允许出现控制字符(U+0000..U+001F)。
// Control characters (U+0000..U+001F) are invalid in JSON strings.
0x00..=0x1F => return Err(self.error("unescaped control character in string")),
// ASCII 快速路径。
// ASCII fast path.
0x20..=0x7F => out.push(b as char),
// 多字节 UTF-8:按原样拷贝该序列的剩余字节。
// Multibyte UTF-8: copy remaining bytes of the sequence verbatim.
_ => {
let extra = utf8_continuation_len(b);
@ -228,9 +266,11 @@ impl<'a> Parser<'a> {
Ok(out)
}
/// 解析 4 位十六进制的 `\uXXXX` 转义,含其后可能跟随的代理对。
/// Parse a 4-hex-digit `\uXXXX` escape, including a following surrogate pair.
fn parse_unicode_escape(&mut self) -> JsonResult<char> {
let hi = self.parse_hex4()?;
// 高位代理:其后必须紧跟一个低位代理。
// High surrogate: must be followed by a low surrogate.
if (0xD800..=0xDBFF).contains(&hi) {
if self.peek() == Some(b'\\') && self.bytes.get(self.pos + 1) == Some(&b'u') {
@ -245,6 +285,7 @@ impl<'a> Parser<'a> {
}
return Err(self.error("expected low surrogate after high surrogate"));
}
// 没有前置高位代理的低位代理是非法的。
// Low surrogate without a preceding high surrogate is invalid.
if (0xDC00..=0xDFFF).contains(&hi) {
return Err(self.error("unexpected low surrogate"));
@ -252,6 +293,8 @@ impl<'a> Parser<'a> {
char::from_u32(hi).ok_or_else(|| self.error("invalid unicode codepoint"))
}
/// 解析 4 位十六进制数字。
/// Parse four hexadecimal digits.
fn parse_hex4(&mut self) -> JsonResult<u32> {
let mut value = 0u32;
for _ in 0..4 {
@ -269,12 +312,15 @@ impl<'a> Parser<'a> {
Ok(value)
}
/// 解析数字:`[ "-" ] int [ frac ] [ exp ]`,最终转为 f64。
/// Parse a number: `[ "-" ] int [ frac ] [ exp ]`, ultimately as f64.
fn parse_number(&mut self) -> JsonResult<JsonValue> {
let start = self.pos;
if self.peek() == Some(b'-') {
self.pos += 1;
}
self.parse_int_part()?;
// 小数部分。
// Fractional part.
if self.peek() == Some(b'.') {
self.pos += 1;
@ -282,6 +328,7 @@ impl<'a> Parser<'a> {
return Err(self.error("expected digit after decimal point"));
}
}
// 指数部分。
// Exponent.
if matches!(self.peek(), Some(b'e') | Some(b'E')) {
self.pos += 1;
@ -300,10 +347,14 @@ impl<'a> Parser<'a> {
Ok(JsonValue::Num(n))
}
/// 解析整数部分,禁止前导零(如 "01")。
/// Parse the integer part, disallowing leading zeros (e.g. "01").
fn parse_int_part(&mut self) -> JsonResult<()> {
match self.peek() {
Some(b'0') => {
self.pos += 1;
// 前导零后不得紧跟另一个数字(不允许 "01" 这样的前导零);
// 下一个字符必须是 '.'、'e' 或某个值终止符。
// A leading zero must not be followed by another digit (no
// leading zeros like "01"); the next char must be '.', 'e', or
// a value terminator.
@ -320,6 +371,7 @@ impl<'a> Parser<'a> {
Ok(())
}
/// 消费一个或多个数字;至少消费一个返回 true。
/// Consume one or more digits. Returns true if at least one digit was consumed.
fn parse_digit_sequence(&mut self, _what: &str) -> Result<bool, JsonError> {
let mut any = false;
@ -331,10 +383,13 @@ impl<'a> Parser<'a> {
}
}
/// UTF-8 引导字节之后的后续字节数。
/// ASCII(< 0x80)与孤立的后续字节(< 0xC0)贡献 0。
/// Number of continuation bytes for a UTF-8 lead byte.
/// ASCII (< 0x80) and stray continuation bytes (< 0xC0) contribute zero.
fn utf8_continuation_len(lead: u8) -> usize {
match lead {
// ASCII 或孤立的后续字节。
0..=0xBF => 0, // ASCII or stray continuation byte
0xC0..=0xDF => 1,
0xE0..=0xEF => 2,
@ -420,6 +475,7 @@ mod tests {
fn parses_unicode_escapes() {
assert_eq!(ok(r#""\u0041""#), JsonValue::Str("A".into()));
assert_eq!(ok(r#""\u4e2d""#), JsonValue::Str("中".into()));
// U+1F600 😀 的代理对。
// Surrogate pair for U+1F600 😀
assert_eq!(ok(r#""\uD83D\uDE00""#), JsonValue::Str("😀".into()));
}
@ -471,6 +527,7 @@ mod tests {
#[test]
fn rejects_trailing_comma() {
// 尾随逗号会被拒绝,因为下一个字符(']' 或 '}')不是合法的值/成员起始。
// A trailing comma is rejected because the next char (']' or '}') is
// not a valid value/member start.
assert!(parse("[1,2,]").is_err());

View File

@ -1,5 +1,8 @@
//! [`JsonValue`] DOM 及其辅助方法。
//! The [`JsonValue`] DOM and its helper methods.
//!
//! 对象使用 `Vec<(String, JsonValue)>` 保留插入顺序,而不是哈希表——
//! 这样序列化结果是确定性的,也与 provider 输出字段的顺序一致。
//! Objects preserve insertion order using a `Vec<(String, JsonValue)>` rather
//! than a hash map, which keeps serialization deterministic and matches the
//! order in which providers emit fields.
@ -7,6 +10,7 @@
use crate::error::{JsonError, JsonResult};
use std::fmt;
/// [`JsonValue::entries`] 使用的私有双臂迭代器组合子。
/// Private two-arm iterator combinator used by [`JsonValue::entries`].
enum Either<L, R> {
Left(L),
@ -27,6 +31,7 @@ where
}
}
/// 一个已解析的 JSON 值。
/// A parsed JSON value.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum JsonValue {
@ -36,37 +41,52 @@ pub enum JsonValue {
Num(f64),
Str(String),
Arr(Vec<JsonValue>),
/// 按插入顺序排列的键值对。
/// Key-value pairs in insertion order.
Obj(Vec<(String, JsonValue)>),
}
impl JsonValue {
// ---- 构造函数 ----
// ---- constructors ----------------------------------------------------
/// 创建一个空对象。
/// Create an empty object.
pub fn obj() -> Self {
JsonValue::Obj(Vec::new())
}
/// 创建一个空数组。
/// Create an empty array.
pub fn arr() -> Self {
JsonValue::Arr(Vec::new())
}
// ---- 判定 ----
// ---- predicates -------------------------------------------------------
/// 是否为 null。
/// Whether this is the null value.
pub fn is_null(&self) -> bool {
matches!(self, JsonValue::Null)
}
/// 是否为对象。
/// Whether this is an object.
pub fn is_object(&self) -> bool {
matches!(self, JsonValue::Obj(_))
}
/// 是否为数组。
/// Whether this is an array.
pub fn is_array(&self) -> bool {
matches!(self, JsonValue::Arr(_))
}
// ---- 访问器 ----
// ---- accessors --------------------------------------------------------
/// 按键从对象中获取字段。非对象返回 `None`。
/// Get a field from an object by key. Returns `None` for non-objects.
pub fn get(&self, key: &str) -> Option<&JsonValue> {
match self {
@ -75,6 +95,7 @@ impl JsonValue {
}
}
/// 若字段为字符串,则以字符串切片借用。
/// Borrow a field as a string slice if it is a string.
pub fn get_str(&self, key: &str) -> Option<&str> {
match self.get(key)? {
@ -83,6 +104,7 @@ impl JsonValue {
}
}
/// 以布尔值借用字段。
/// Borrow a field as a bool.
pub fn get_bool(&self, key: &str) -> Option<bool> {
match self.get(key)? {
@ -91,6 +113,7 @@ impl JsonValue {
}
}
/// 以数字借用字段。
/// Borrow a field as a number.
pub fn get_num(&self, key: &str) -> Option<f64> {
match self.get(key)? {
@ -99,6 +122,7 @@ impl JsonValue {
}
}
/// 以数组借用字段。
/// Borrow a field as an array.
pub fn get_arr(&self, key: &str) -> Option<&Vec<JsonValue>> {
match self.get(key)? {
@ -107,6 +131,7 @@ impl JsonValue {
}
}
/// 以对象借用字段。
/// Borrow a field as an object.
pub fn get_obj(&self, key: &str) -> Option<&Vec<(String, JsonValue)>> {
match self.get(key)? {
@ -115,6 +140,7 @@ impl JsonValue {
}
}
/// 迭代对象的键值对;非对象返回空迭代器。
/// Iterate object entries. Empty for non-objects.
pub fn entries(&self) -> impl Iterator<Item = (&String, &JsonValue)> {
match self {
@ -123,8 +149,10 @@ impl JsonValue {
}
}
// ---- 修改器 ----
// ---- mutators ---------------------------------------------------------
/// 向对象插入键值对;键已存在则替换。`self` 非对象时返回错误。
/// Insert a key-value pair into an object. Replaces if key exists.
/// Returns an error if `self` is not an object.
pub fn insert(&mut self, key: impl Into<String>, value: JsonValue) -> JsonResult<()> {
@ -142,6 +170,7 @@ impl JsonValue {
}
}
/// 向数组追加一个值。`self` 非数组时返回错误。
/// Push a value into an array. Returns an error if `self` is not an array.
pub fn push(&mut self, value: JsonValue) -> JsonResult<()> {
match self {
@ -160,50 +189,67 @@ impl fmt::Display for JsonValue {
}
}
// ---- 从基本类型的转换 ----
// ---- conversions from primitives ----------------------------------------
/// 从布尔值构造 JSON 值。
/// Construct a JSON value from a bool.
impl From<bool> for JsonValue {
fn from(b: bool) -> Self {
JsonValue::Bool(b)
}
}
/// 从字符串切片构造 JSON 值。
/// Construct a JSON value from a string slice.
impl From<&str> for JsonValue {
fn from(s: &str) -> Self {
JsonValue::Str(s.to_string())
}
}
/// 从字符串构造 JSON 值。
/// Construct a JSON value from a String.
impl From<String> for JsonValue {
fn from(s: String) -> Self {
JsonValue::Str(s)
}
}
/// 从 f64 构造 JSON 值。
/// Construct a JSON value from an f64.
impl From<f64> for JsonValue {
fn from(n: f64) -> Self {
JsonValue::Num(n)
}
}
/// 从 i64 构造 JSON 值(转为 f64 存储)。
/// Construct a JSON value from an i64 (stored as f64).
impl From<i64> for JsonValue {
fn from(n: i64) -> Self {
JsonValue::Num(n as f64)
}
}
/// 从 u64 构造 JSON 值(转为 f64 存储)。
/// Construct a JSON value from a u64 (stored as f64).
impl From<u64> for JsonValue {
fn from(n: u64) -> Self {
JsonValue::Num(n as f64)
}
}
/// 从 usize 构造 JSON 值(转为 f64 存储)。
/// Construct a JSON value from a usize (stored as f64).
impl From<usize> for JsonValue {
fn from(n: usize) -> Self {
JsonValue::Num(n as f64)
}
}
/// 从向量构造 JSON 数组(逐元素转换)。
/// Construct a JSON array from a vector (element-wise conversion).
impl<T: Into<JsonValue>> From<Vec<T>> for JsonValue {
fn from(v: Vec<T>) -> Self {
JsonValue::Arr(v.into_iter().map(Into::into).collect())

View File

@ -1,8 +1,10 @@
//! JSON 序列化器——把 [`JsonValue`] 渲染回字符串。
//! JSON serializer — renders a [`JsonValue`] back to a String.
use crate::value::JsonValue;
use std::fmt::Write;
/// 将 [`JsonValue`] 序列化为紧凑的 JSON 字符串。
/// Serialize a [`JsonValue`] to a compact JSON string.
pub fn to_string(value: &JsonValue) -> String {
let mut out = String::new();
@ -10,6 +12,8 @@ pub fn to_string(value: &JsonValue) -> String {
out
}
/// 把一个值写入 `out`(递归处理数组与对象)。
/// Write a value into `out` (recursing into arrays and objects).
fn write_value(value: &JsonValue, out: &mut String) {
match value {
JsonValue::Null => out.push_str("null"),
@ -42,23 +46,31 @@ fn write_value(value: &JsonValue, out: &mut String) {
}
}
/// 以 JSON 兼容形式渲染数字。整数打印时不带小数部分,符合常见预期
/// (例如 `3` 而非 `3.0`)。
/// Render a number in a JSON-compatible form. Integers print without a
/// fractional part to match common expectations (e.g. `3` not `3.0`).
fn write_number(n: f64, out: &mut String) {
if n.is_finite() {
// 整数走 i64 分支;浮点数走最短表示分支。
// Integers take the i64 branch; fractional numbers take the shortest-repr branch.
if n.fract() == 0.0 && n.abs() < 1e16 {
let _ = write!(out, "{}", n as i64);
} else {
// 通过 format! 得到 Ryu 风格的最短表示:可往返且无需引入依赖。
// Use Ryu-style shortest representation via format!: this gives
// round-trippable floats without pulling in a dependency.
let _ = write!(out, "{}", n);
}
} else {
// JSON 无法表示无穷或 NaN;输出 null 以保持有效。
// JSON has no representation for infinity or NaN. Emit null to stay valid.
out.push_str("null");
}
}
/// 将字符串转义并加上引号后写入 `out`。
/// Escape and quote a string into `out`.
fn write_string(s: &str, out: &mut String) {
out.push('"');
for c in s.chars() {

View File

@ -1,5 +1,8 @@
//! Anthropic Messages API provider。
//! Anthropic Messages API provider.
//!
//! 将 [`ProviderRequest`] 翻译为 Anthropic 的 `/v1/messages` 流式格式,
//! 并把返回的 SSE 流解析为 [`StreamEvent`]。
//! Translates [`ProviderRequest`] into Anthropic's `/v1/messages` streaming
//! format and parses the resulting SSE stream into [`StreamEvent`]s.
@ -14,14 +17,22 @@ use focus_transport::{HttpTransport, Transport};
use std::fmt;
use std::sync::Arc;
/// Anthropic provider。`transport` 通过 trait object 泛化,以便测试注入
/// [`MockTransport`]。
/// The Anthropic provider. `transport` is generic via a trait object so tests
/// can inject a [`MockTransport`].
pub struct AnthropicProvider {
/// provider 配置(key、model、base URL 等)。
/// Provider configuration (key, model, base URL, …).
config: ProviderConfig,
/// 实际执行 HTTP 请求的 transport(生产 `HttpTransport`,测试 `MockTransport`)。
/// Transport performing the HTTP request (`HttpTransport` in prod, `MockTransport` in tests).
transport: Arc<dyn Transport>,
}
impl AnthropicProvider {
/// 使用真实 HTTP transport 构造。
/// Build with a real HTTP transport.
pub fn new(config: ProviderConfig) -> Self {
Self {
config,
@ -29,16 +40,22 @@ impl AnthropicProvider {
}
}
/// 使用自定义 transport 构造(用于测试)。
/// Construct with a custom transport (for tests).
pub fn with_transport(config: ProviderConfig, transport: Arc<dyn Transport>) -> Self {
Self { config, transport }
}
/// 测试辅助:用预置 SSE 文本的 mock transport 构造。
/// Test helper: build with a mock transport from canned SSE text.
pub fn with_mock(config: ProviderConfig, sse_body: impl Into<String>) -> Self {
Self::with_transport(config, Arc::new(MockTransport::new(200, sse_body)))
}
/// 将 [`ProviderRequest`] 组装为 Anthropic `/v1/messages` 的 JSON 请求体与
/// HTTP 请求(system 提示作为顶层字段、工具透传)。
/// Assemble the [`ProviderRequest`] into an Anthropic `/v1/messages` JSON body
/// and HTTP request (system prompt as a top-level field, tools passed through).
fn build_request(&self, request: &ProviderRequest) -> focus_transport::HttpRequest {
let mut body = JsonValue::obj();
body.insert("model", self.config.model.clone().into()).ok();
@ -77,6 +94,8 @@ impl fmt::Debug for AnthropicProvider {
impl StreamProvider for AnthropicProvider {
fn stream(&self, request: &ProviderRequest) -> StreamResult {
let http_req = self.build_request(request);
// 我们在此同步执行请求(在一个小型 runtime 上阻塞),因为 core 的
// StreamIterator 是同步拉取模型。
// We perform the request synchronously here (blocking on a mini
// runtime) because the core's StreamIterator is a sync pull model.
let response = block_on_request(&self.transport, &http_req)?;
@ -85,6 +104,8 @@ impl StreamProvider for AnthropicProvider {
}
}
/// 在一个临时单线程 tokio runtime 上同步阻塞地执行一次 HTTP 请求。
/// Run one HTTP request synchronously by blocking on a throwaway single-thread tokio runtime.
fn block_on_request(
transport: &Arc<dyn Transport>,
req: &focus_transport::HttpRequest,
@ -97,8 +118,13 @@ fn block_on_request(
.map_err(|e| CoreError::Provider(e.to_string()))
}
// ---- 消息转换 ----
// ---- message conversion ---------------------------------------------------
/// 将统一消息列表转为 Anthropic 的 messages 数组(ToolResult 映射为
/// 带 `tool_result` 块的 user 消息)。
/// Convert the unified message list into Anthropic's messages array (a
/// ToolResult maps to a user message holding a `tool_result` block).
fn convert_messages(messages: &[Message]) -> JsonValue {
let mut arr = JsonValue::arr();
for m in messages {
@ -133,6 +159,9 @@ fn convert_messages(messages: &[Message]) -> JsonValue {
arr
}
/// 将内容块转为 Anthropic 的结构化 content 数组(text/thinking/image/tool_use)。
/// Convert content blocks into Anthropic's structured content array
/// (text/thinking/image/tool_use).
fn convert_content_out(blocks: &[ContentBlock]) -> JsonValue {
let mut arr = JsonValue::arr();
for b in blocks {
@ -172,27 +201,52 @@ fn convert_content_out(blocks: &[ContentBlock]) -> JsonValue {
arr
}
/// 工具数组是否为空(缺失或空数组均视为空)。
/// Whether the tools value is empty (missing or empty array both count).
fn tools_is_empty(tools: &JsonValue) -> bool {
matches!(tools, JsonValue::Arr(a) if a.is_empty())
}
// ---- SSE 转 StreamEvent ----
// ---- SSE -> StreamEvent ---------------------------------------------------
/// 驱动 Anthropic 的 SSE 响应体并产出 [`StreamEvent`]。
/// Drives an Anthropic SSE body and emits [`StreamEvent`]s.
pub struct AnthropicStream {
/// 增量 SSE 解析器。
/// Incremental SSE parser.
parser: focus_transport::SseParser,
/// 当前模型名(用于构造 partial 消息)。
/// Current model name (used when building partial messages).
model: String,
/// 是否已收到终止事件(message_stop / error)。
/// Whether a terminal event (message_stop / error) has been seen.
done: bool,
// 累加状态。
// Accumulator state.
/// 已累积的内容块(按 Anthropic 的 index 顺序)。
/// Content blocks accumulated so far (in Anthropic's index order).
content: Vec<ContentBlock>,
/// 每个 tool_use 块尚未解析的 partial_json 片段。
/// Per-block (tool_use) not-yet-parsed partial_json fragments.
partial_json: Vec<String>,
/// 当前用量统计。
/// Current usage counters.
usage: Usage,
/// 当前停止原因。
/// Current stop reason.
stop_reason: StopReason,
/// 若收到 error 事件,记录其消息。
/// Message captured from an error event, if any.
error_message: Option<String>,
/// 已产生但尚未被 `next_event` 取走的事件缓冲。
/// Buffer of produced events not yet drained by `next_event`.
pending: std::collections::VecDeque<StreamEvent>,
}
impl AnthropicStream {
/// 用完整响应体字符串与模型名构造(SSE 一次性喂入)。
/// Build from the full response-body string and model name (whole SSE body fed at once).
fn new(body: &str, model: &str) -> Self {
let mut parser = focus_transport::SseParser::new();
parser.feed_str(body);
@ -209,6 +263,9 @@ impl AnthropicStream {
}
}
/// 用当前累加状态快照构造一条 [`AssistantMessage`](作为事件中的 partial)。
/// Build an [`AssistantMessage`] snapshot from the current accumulator state
/// (used as the `partial` field on events).
fn current_partial(&self) -> AssistantMessage {
AssistantMessage {
content: self.content.clone(),
@ -220,6 +277,9 @@ impl AnthropicStream {
}
}
/// 处理一个 Anthropic SSE 事件(按 `event_type` 分派),返回本次产生的事件列表。
/// Handle one Anthropic SSE event (dispatched by `event_type`); returns the
/// list of events it produced.
fn handle_event(&mut self, event_type: &str, data: &str) -> Vec<StreamEvent> {
let mut out = Vec::new();
let value = match parse(data) {
@ -228,6 +288,7 @@ impl AnthropicStream {
};
match event_type {
"message_start" => {
// 如存在 message.usage,则从中初始化用量统计。
// seed usage from message.usage if present
if let Some(u) = value.get("message").and_then(|m| m.get("usage")) {
self.usage.input_tokens = u.get_num("input_tokens").unwrap_or(0.0) as u64;
@ -339,6 +400,7 @@ impl AnthropicStream {
partial: self.current_partial(),
}),
Some(ContentBlock::ToolCall(tc)) => {
// 用累积的 partial json 最终确定工具参数。
// finalize arguments from accumulated partial json
let pj = self.partial_json.get(idx).cloned().unwrap_or_default();
tc.arguments = parse(&pj).unwrap_or(JsonValue::obj());
@ -390,6 +452,9 @@ impl AnthropicStream {
}
impl StreamIterator for AnthropicStream {
/// 拉取下一个事件:先排空 `pending`;缓冲空则取下一个 SSE 事件并处理。
/// Pull the next event: drain `pending` first; when empty, pull the next
/// SSE event and handle it.
fn next_event(&mut self) -> Option<StreamEvent> {
loop {
if let Some(ev) = self.pending.pop_front() {
@ -408,6 +473,8 @@ impl StreamIterator for AnthropicStream {
}
}
/// 将 Anthropic 的 stop_reason 字符串映射为统一的 [`StopReason`]。
/// Map an Anthropic stop_reason string to the unified [`StopReason`].
fn map_stop_reason(s: &str) -> StopReason {
match s {
"end_turn" | "stop_sequence" | "pause_turn" => StopReason::Stop,

View File

@ -1,3 +1,5 @@
//! provider 实现的共享辅助:请求配置、用于测试的 mock transport,
//! 以及从配置组装 model-id 的辅助函数。
//! Shared helpers for provider implementations: request config, a mock
//! transport for tests, and helpers to assemble the model-id from config.
@ -6,17 +8,30 @@ use focus_transport::{HttpRequest, HttpResponse, Transport};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// provider 配置:API key、model id、base URL。
/// Configuration for a provider: API key, model id, base URL.
#[derive(Debug, Clone)]
pub struct ProviderConfig {
/// API 密钥(或待解析的 `$VAR`/`${VAR}` 模板)。
/// API key (or a `$VAR`/`${VAR}` template to be resolved).
pub api_key: String,
/// 模型 id,例如 `claude-sonnet-4-5`、`glm-5.2`。
/// Model id, e.g. `claude-sonnet-4-5`, `glm-5.2`.
pub model: String,
/// 仅主机名(不含 scheme、不含 path),例如 `api.anthropic.com`。
/// Host only (no scheme, no path), e.g. `api.anthropic.com`.
pub base_url: String,
/// 默认最大输出 token 数。
/// Default max output tokens.
pub max_tokens: u64,
/// 请求超时(毫秒)。
/// Request timeout in milliseconds.
pub timeout_ms: u64,
}
impl ProviderConfig {
/// Anthropic 默认配置(`api.anthropic.com`,4096 tokens,120s 超时)。
/// Default Anthropic config (`api.anthropic.com`, 4096 tokens, 120s timeout).
pub fn anthropic(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
@ -27,6 +42,8 @@ impl ProviderConfig {
}
}
/// OpenAI 默认配置(`api.openai.com`,4096 tokens,120s 超时)。
/// Default OpenAI config (`api.openai.com`, 4096 tokens, 120s timeout).
pub fn openai(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
@ -37,6 +54,8 @@ impl ProviderConfig {
}
}
/// 为任意 OpenAI 兼容厂商构造一份通用配置。`base_url` 仅为主机名
/// (不含 scheme、不含 path)。
/// Build a generic config for an OpenAI-compatible vendor. `base_url` is
/// the host only (no scheme, no path).
pub fn new(
@ -53,25 +72,33 @@ impl ProviderConfig {
}
}
/// Z.ai / 智谱 BigModel 配置(国内端点)。
/// Config for Z.ai / 智谱 BigModel (国内端点).
pub fn zai_cn(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model, "open.bigmodel.cn")
}
/// Z.ai 配置(国际端点)。
/// Config for Z.ai (国际端点).
pub fn zai_intl(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(api_key, model, "api.z.ai")
}
}
/// 一个返回预置 HTTP 响应的 mock transport。用于在不触网的情况下对
/// provider 做单元测试。
/// A mock transport that returns a canned HTTP response. Used to unit-test
/// providers without touching the network.
#[derive(Debug, Clone)]
pub struct MockTransport {
/// 预置响应;每次请求取一份克隆(取空后再请求会报错)。
/// Canned response; each request clones it (requests after it's drained error).
response: Arc<Mutex<Option<HttpResponse>>>,
}
impl MockTransport {
/// 用给定状态码与 SSE 文本体构造一个预置响应。
/// Build a canned response with the given status code and SSE body.
pub fn new(status: u16, body: impl Into<String>) -> Self {
let body = body.into();
let mut headers = HashMap::new();
@ -108,5 +135,6 @@ impl Transport for MockTransport {
}
}
/// 暂时不做任何剥离——这是为未来共享转换预留的挂载点。
/// Strip and ignore nothing for now — a place to hang shared transformations.
pub fn _unused_keep_request_in_scope(_req: &ProviderRequest) {}

View File

@ -1,17 +1,25 @@
//! 解析配置值模板,对应 pi 的 `resolveConfigValue`。
//! Resolve config value templates, mirroring pi's `resolveConfigValue`.
//!
//! 存储值支持以下形式:
//! Supported forms for a stored value:
//! - 纯字面量:`"abc"` → `"abc"`
//! - Plain literal: `"abc"` → `"abc"`
//! - 环境变量引用:`"$VAR"` 或 `"${VAR}"` → 环境变量 `VAR` 的值
//! - Env var reference: `"$VAR"` or `"${VAR}"` → the value of env var `VAR`
//! - 字面美元符:`"$$"` → `"$"`、`"$!"` → `"!"`
//! - Literal dollar: `"$$"` → `"$"`, `"$!"` → `"!"`
//!
//! shell 命令形式(`"!cmd ..."`)暂时有意省略,以缩小信任边界;后续可在某个
//! feature 之后再加入。
//! The shell-command form (`"!cmd ..."`) is intentionally omitted for now to
//! keep the trust boundary small; it can be added later behind a feature.
use std::env;
/// Resolve a config value. Returns `None` if the value is `None`; returns an
/// error message in `Err` if an env-var reference names an unset variable.
/// 解析一个配置值:若环境变量引用指向未设置的变量,则在 `Err` 中返回错误信息。
/// Resolve a config value: returns an error message in `Err` if an env-var
/// reference names an unset variable.
pub fn resolve(value: &str) -> Result<String, String> {
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
@ -19,6 +27,7 @@ pub fn resolve(value: &str) -> Result<String, String> {
while i < bytes.len() {
let b = bytes[i];
if b == b'$' {
// 转义处理。
// Escapes.
if i + 1 < bytes.len() {
let next = bytes[i + 1];
@ -33,6 +42,7 @@ pub fn resolve(value: &str) -> Result<String, String> {
continue;
}
}
// ${VAR} 形式。
// ${VAR} form.
if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some(close) = bytes[i + 2..].iter().position(|&c| c == b'}') {
@ -46,6 +56,7 @@ pub fn resolve(value: &str) -> Result<String, String> {
}
return Err("unterminated '${' in config value".into());
}
// $VAR 形式:对 [A-Z_][A-Z0-9_]* 做贪心匹配。
// $VAR form: greedy match of [A-Z_][A-Z0-9_]*.
let mut j = i + 1;
while j < bytes.len() {
@ -64,10 +75,12 @@ pub fn resolve(value: &str) -> Result<String, String> {
i = j;
continue;
}
// 后面未跟名字的单独 '$':按字面输出。
// Lone '$' with no following name: emit literally.
out.push('$');
i += 1;
} else {
// 拷贝一个 UTF-8 字符。
// Copy one UTF-8 char.
let ch_len = utf8_char_len(b);
out.push_str(std::str::from_utf8(&bytes[i..i + ch_len]).unwrap_or(""));
@ -77,12 +90,15 @@ pub fn resolve(value: &str) -> Result<String, String> {
Ok(out)
}
/// 根据 UTF-8 引导字节返回该字符占用的字节数。
/// Return the byte length of a UTF-8 char given its lead byte.
fn utf8_char_len(b: u8) -> usize {
match b {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF7 => 4,
// 非法引导字节;按 1 处理以继续推进。
_ => 1, // invalid lead byte; treat as 1 to make progress
}
}

View File

@ -1,8 +1,13 @@
//! focus-providers:具体的 LLM provider 实现。
//! focus-providers: concrete LLM provider implementations.
//!
//! 每个 provider 都通过将厂商特定的 HTTP/SSE 格式翻译为统一的
//! [`StreamEvent`] 协议,从而实现 [`focus_core::StreamProvider`]。
//! Each provider implements [`focus_core::StreamProvider`] by translating its
//! vendor-specific HTTP/SSE format into the unified [`StreamEvent`] protocol.
//!
//! 主要目标是 [`ZaiProvider`](智谱 GLM 编程模型,国内端点),它构建在共享的
//! [`OpenAiCompatProvider`] 引擎之上。此外也提供 Anthropic 与原生 OpenAI provider。
//! The primary target is [`ZaiProvider`] (智谱 GLM coding models, domestic
//! endpoint), built on the shared [`OpenAiCompatProvider`] engine.
//! Anthropic and raw-OpenAI providers are also available.
@ -19,4 +24,4 @@ pub use anthropic::AnthropicProvider;
pub use common::{MockTransport, ProviderConfig};
pub use config_value::resolve as resolve_config_value;
pub use openai_compat::OpenAiCompatProvider;
pub use zai::ZaiProvider;
pub use zai::{ZaiProvider, ZAI_CODING_PATH};

View File

@ -1,5 +1,9 @@
//! 一个可复用的 OpenAI 兼容 provider。
//! A reusable OpenAI-compatible provider.
//!
//! 许多厂商(Z.ai/智谱、DeepSeek、Moonshot、Together 等)对外暴露的 API 与
//! OpenAI 的 Chat Completions 流式格式逐字节兼容,仅在 base URL 与鉴权头上不同。
//! 本模块把这部分公共逻辑抽出来,让具体 provider 只做薄封装。
//! Many vendors (Z.ai/智谱, DeepSeek, Moonshot, Together, …) expose an API
//! that is byte-for-byte compatible with OpenAI's Chat Completions streaming
//! format, differing only in the base URL and auth header. This module factors
@ -18,17 +22,28 @@ use std::collections::VecDeque;
use std::fmt;
use std::sync::Arc;
/// 一个 OpenAI 兼容 provider,由 base URL、api key、model 以及(可选的)用于
/// 测试的自定义 transport 配置而成。
/// An OpenAI-compatible provider configured with a base URL, api key, model,
/// and (optionally) a custom transport for tests.
pub struct OpenAiCompatProvider {
/// provider 配置(host、model、key、超时等)。
/// Provider configuration (host, model, key, timeouts, …).
config: ProviderConfig,
/// 实际执行 HTTP 请求的 transport(生产用 `HttpTransport`,测试用 `MockTransport`)。
/// Transport that performs the HTTP request (`HttpTransport` in production,
/// `MockTransport` in tests).
transport: Arc<dyn Transport>,
/// base host 之下的 chat-completions 路径。默认为 `/v1/chat/completions`
/// (OpenAI),但 Z.ai 使用 `/api/paas/v4/chat/completions`。
/// The chat-completions path under the base host. Defaults to
/// `/v1/chat/completions` (OpenAI) but Z.ai uses `/api/paas/v4/chat/completions`.
path: String,
}
impl OpenAiCompatProvider {
/// 使用默认路径 `/v1/chat/completions` 与真实 HTTP transport 构造。
/// Build with the default path `/v1/chat/completions` and a real HTTP transport.
pub fn new(config: ProviderConfig) -> Self {
let path = "/v1/chat/completions".to_string();
Self {
@ -38,6 +53,8 @@ impl OpenAiCompatProvider {
}
}
/// 使用自定义 chat-completions 路径构造(例如 Z.ai 的
/// `/api/paas/v4/chat/completions`)。
/// Build with a custom chat-completions path (e.g. Z.ai's
/// `/api/paas/v4/chat/completions`).
pub fn with_path(config: ProviderConfig, path: impl Into<String>) -> Self {
@ -48,6 +65,7 @@ impl OpenAiCompatProvider {
}
}
/// 使用自定义 base path 与 transport 构造。
/// Build with a custom base path and transport.
pub fn with_path_and_transport(
config: ProviderConfig,
@ -61,6 +79,7 @@ impl OpenAiCompatProvider {
}
}
/// 测试辅助:用返回预置 SSE 文本的 mock transport 构造。
/// Test helper: build with a mock transport returning canned SSE text.
pub fn with_mock(
config: ProviderConfig,
@ -70,16 +89,22 @@ impl OpenAiCompatProvider {
Self::with_path_and_transport(config, path, Arc::new(MockTransport::new(200, sse_body)))
}
/// 只读访问 provider 配置(host、model、key 等)。
/// Read-only access to the provider config (host, model, key, …).
pub fn config_ref(&self) -> &ProviderConfig {
&self.config
}
/// 只读访问请求路径。
/// Read-only access to the request path.
pub fn path(&self) -> &str {
&self.path
}
/// 将统一的 [`ProviderRequest`] 组装为 OpenAI Chat Completions 的 JSON 请求体
/// 与 HTTP 请求(含 system 消息、工具定义、采样参数)。
/// Assemble the unified [`ProviderRequest`] into an OpenAI Chat Completions
/// JSON body and HTTP request (system message, tool definitions, sampling params).
fn build_request(&self, request: &ProviderRequest) -> focus_transport::HttpRequest {
let mut body = JsonValue::obj();
body.insert("model", self.config.model.clone().into()).ok();
@ -134,6 +159,11 @@ impl StreamProvider for OpenAiCompatProvider {
}
}
/// 在一个临时的单线程 tokio runtime 上同步阻塞地执行一次 HTTP 请求。
/// core 的 [`StreamIterator`] 是同步拉取模型,因此这里把异步 transport 转为同步。
/// Run one HTTP request synchronously by blocking on a throwaway single-thread
/// tokio runtime. The core's [`StreamIterator`] is a sync pull model, so we
/// bridge the async transport into a sync call here.
fn block_on_request(
transport: &Arc<dyn Transport>,
req: &focus_transport::HttpRequest,
@ -146,8 +176,11 @@ fn block_on_request(
.map_err(|e| CoreError::Provider(e.to_string()))
}
// ---- 消息/工具转换(所有 OpenAI 兼容厂商共用)----
// ---- message / tool conversion (shared by all OpenAI-compat vendors) -------
/// 将统一 [`Message`] 转为 OpenAI 的消息对象(user/assistant/tool)。
/// Convert a unified [`Message`] into an OpenAI message object (user/assistant/tool).
fn convert_message(m: &Message) -> Option<JsonValue> {
match m {
Message::User(u) => {
@ -190,6 +223,9 @@ fn convert_message(m: &Message) -> Option<JsonValue> {
}
}
/// 将统一的工具定义 JSON 转为 OpenAI `tools` 数组(每项包裹为 `type:"function"`)。
/// Convert the unified tools JSON into the OpenAI `tools` array (each entry
/// wrapped as `type:"function"`).
fn convert_tools(tools: &JsonValue) -> JsonValue {
let arr = match tools {
JsonValue::Arr(a) => a,
@ -215,6 +251,9 @@ fn convert_tools(tools: &JsonValue) -> JsonValue {
out
}
/// 将内容块的文本部分拼接为单个字符串(OpenAI 不接受结构化 content)。
/// Flatten the text portion of content blocks into a single string (OpenAI
/// does not accept structured content here).
fn content_to_text(blocks: &[ContentBlock]) -> JsonValue {
let mut s = String::new();
for b in blocks {
@ -228,21 +267,38 @@ fn content_to_text(blocks: &[ContentBlock]) -> JsonValue {
JsonValue::Str(s)
}
/// 工具数组是否为空(缺失或空数组均视为空)。
/// Whether the tools value is empty (missing or empty array both count).
fn tools_is_empty(tools: &JsonValue) -> bool {
matches!(tools, JsonValue::Arr(a) if a.is_empty())
}
// ---- SSE 转 StreamEvent ----
// ---- SSE -> StreamEvent ---------------------------------------------------
/// Drives an OpenAI-compatible streaming body.
/// 驱动一个 OpenAI 兼容的流式响应体,按需产出 [`StreamEvent`]。
/// Drives an OpenAI-compatible streaming body, producing [`StreamEvent`]s on demand.
pub struct OpenAiCompatStream {
/// 增量 SSE 解析器,从已喂入的响应体中逐个取出 SSE 事件。
/// Incremental SSE parser; yields SSE events one at a time from the fed body.
parser: focus_transport::SseParser,
/// 把 OpenAI 增量(text delta、tool_call delta)归约为核心事件的状态机。
/// State machine that reduces OpenAI deltas (text delta, tool_call delta)
/// into core events.
reducer: ProviderEventReducer,
/// 是否已收到 `[DONE]` 或终止信号。
/// Whether `[DONE]` / a terminal signal has been seen.
done: bool,
/// 已归约但尚未被 `next_event` 取走的事件缓冲;实现"一次 SSE chunk 产出多个事件"的拉取模型。
/// Buffer of reduced events not yet drained by `next_event`; this is what lets
/// one SSE chunk yield multiple events under the pull model.
pending: VecDeque<StreamEvent>,
}
impl OpenAiCompatStream {
/// 用完整的响应体字符串和模型名构造;SSE 一次性喂入解析器(非增量网络读取)。
/// Build from the full response-body string and model name; the whole SSE body
/// is fed to the parser at once (not an incremental network read).
fn new(body: &str, model: &str) -> Self {
let mut parser = focus_transport::SseParser::new();
parser.feed_str(body);
@ -254,9 +310,16 @@ impl OpenAiCompatStream {
}
}
/// 处理单个 SSE `data:` 负载:解析 JSON、提取 `choices[0].delta`,把文本/工具
/// 调用增量交给 [`ProviderEventReducer`],并把产出的事件全部追加到 `pending`。
/// Handle a single SSE `data:` payload: parse JSON, pull `choices[0].delta`,
/// feed text/tool-call deltas to the [`ProviderEventReducer`], and push every
/// produced event onto `pending`.
fn handle_chunk(&mut self, data: &str) {
let mut out = Vec::new();
if data == "[DONE]" {
// OpenAI 的终止标记:归约出剩余事件并发出 Done。
// OpenAI's terminator: flush remaining events from the reducer and emit Done.
self.done = true;
out.extend(self.reducer.finalize_events());
if let Some(msg) = self.reducer.finish() {
@ -277,6 +340,9 @@ impl OpenAiCompatStream {
Some(c) => c,
None => return,
};
// OpenAI 的增量集中在 choices[0].delta:content 是文本片段,tool_calls 是工具增量。
// OpenAI packs increments into choices[0].delta: content is a text fragment,
// tool_calls carries tool-call deltas.
let delta = match choice.get("delta") {
Some(d) => d,
None => return,
@ -312,6 +378,11 @@ impl OpenAiCompatStream {
}
impl StreamIterator for OpenAiCompatStream {
/// 拉取下一个事件:优先排空 `pending` 缓冲;缓冲空则从 SSE 解析器再取一个
/// 事件并交给 `handle_chunk` 归约(可能一次性产生多个事件填入缓冲)。
/// Pull the next event: first drain `pending`; when empty, pull one more SSE
/// event from the parser and reduce it via `handle_chunk` (which may produce
/// several events into the buffer at once).
fn next_event(&mut self) -> Option<StreamEvent> {
loop {
if let Some(ev) = self.pending.pop_front() {

View File

@ -1,50 +1,113 @@
//! Z.ai / 智谱 BigModel provider.
//!
//! `glm-4.6` refers to 智谱 AI's coding-capable GLM models served from
//! the domestic endpoint. The API is OpenAI-compatible, so this is a thin
//! wrapper over [`OpenAiCompatProvider`] that fixes the base URL and path.
//! The API is OpenAI-compatible, so this is a thin wrapper over
//! [`OpenAiCompatProvider`] that fixes the base URL and path.
//!
//! - Domestic (国内): `https://open.bigmodel.cn/api/paas/v4/chat/completions`
//! - International: `https://api.z.ai/api/paas/v4/chat/completions`
//! GLM offers two access tiers, each with its own base path on `open.bigmodel.cn`:
//!
//! | Tier | Path | Keys |
//! |------|------|-------|
//! | **Coding Plan** (GLM Coding Plan) | `/api/coding/paas/v4/chat/completions` | `xxxxxxxx.yyyyyyyy` format, coding-only |
//! | **Standard PaaS** (通用 PaaS) | `/api/paas/v4/chat/completions` | regular keys, pay-as-you-go balance |
//!
//! Coding-plan keys report `429 余额不足` on the standard PaaS path because
//! they have no balance there — they must use the coding path. Use
//! [`ZaiProvider::coding_cn`] / [`ZaiProvider::coding_intl`] for coding-plan
//! keys and [`ZaiProvider::domestic`] / [`ZaiProvider::international`] for
//! standard PaaS keys.
//!
//! - Coding (国内): `https://open.bigmodel.cn/api/coding/paas/v4/chat/completions`
//! - Coding (国际): `https://api.z.ai/api/coding/paas/v4/chat/completions`
//! - PaaS (国内): `https://open.bigmodel.cn/api/paas/v4/chat/completions`
//! - PaaS (国际): `https://api.z.ai/api/paas/v4/chat/completions`
use crate::common::ProviderConfig;
use crate::openai_compat::OpenAiCompatProvider;
use focus_core::provider::StreamProvider;
use std::fmt;
/// The Z.ai chat-completions path (same on both domestic and international).
const ZAI_PATH: &str = "/api/paas/v4/chat/completions";
/// GLM Coding Plan 的 chat-completions 路径。`xxxxxxxx.yyyyyyyy` 格式的密钥
/// (coding 套餐密钥)必须使用此路径;在标准 PaaS 路径上它们会返回 `429 余额不足`。
/// The GLM Coding Plan chat-completions path. Keys in `xxxxxxxx.yyyyyyyy`
/// format (coding-plan keys) must use this path; on the standard PaaS path
/// they report `429 余额不足`.
pub const ZAI_CODING_PATH: &str = "/api/coding/paas/v4/chat/completions";
/// 标准 GLM PaaS chat-completions 路径(按量付费密钥)。
/// The standard GLM PaaS chat-completions path (pay-as-you-go keys).
const ZAI_PAAS_PATH: &str = "/api/paas/v4/chat/completions";
/// 一个面向 GLM 编程模型的 Z.ai / 智谱 provider。
/// A Z.ai / 智谱 provider targeting the GLM coding models.
pub struct ZaiProvider {
/// 被包装的 OpenAI 兼容 provider,承担实际的请求与流解析。
/// The wrapped OpenAI-compatible provider doing the real work.
inner: OpenAiCompatProvider,
}
impl ZaiProvider {
/// Connect to the domestic (国内) endpoint `open.bigmodel.cn`.
/// 连接国内标准 PaaS 端点 `open.bigmodel.cn`,路径为
/// `/api/paas/v4/chat/completions`。普通按量付费密钥请用此项。
/// `api_key` 可以是已解析的字面量,也可以是 `$VAR`/`${VAR}` 模板——
/// 需要时请先通过 [`crate::config_value::resolve`] 解析。
/// Connect to the domestic (国内) standard PaaS endpoint
/// `open.bigmodel.cn` with `/api/paas/v4/chat/completions`.
/// Use this for regular pay-as-you-go keys.
/// `api_key` may be a resolved literal or a `$VAR`/`${VAR}` template —
/// resolve it first via [`crate::config_value::resolve`] if needed.
pub fn domestic(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::from_config(ProviderConfig::zai_cn(api_key, model))
}
/// Connect to the international endpoint `api.z.ai`.
/// 连接国际标准 PaaS 端点 `api.z.ai`。
/// Connect to the international standard PaaS endpoint `api.z.ai`.
pub fn international(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::from_config(ProviderConfig::zai_intl(api_key, model))
}
/// Build from an explicit [`ProviderConfig`] (host + model + key).
/// 连接国内 **GLM Coding Plan** 端点 `open.bigmodel.cn`,路径为
/// `/api/coding/paas/v4/chat/completions`。coding 套餐密钥
/// (`xxxxxxxx.yyyyyyyy` 格式)请用此项。
/// Connect to the domestic (国内) **GLM Coding Plan** endpoint
/// `open.bigmodel.cn` with `/api/coding/paas/v4/chat/completions`.
/// Use this for coding-plan keys (`xxxxxxxx.yyyyyyyy` format).
pub fn coding_cn(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::from_config_with_path(ProviderConfig::zai_cn(api_key, model), ZAI_CODING_PATH)
}
/// 连接国际 **GLM Coding Plan** 端点 `api.z.ai`,路径为
/// `/api/coding/paas/v4/chat/completions`。
/// Connect to the international **GLM Coding Plan** endpoint `api.z.ai`
/// with `/api/coding/paas/v4/chat/completions`.
pub fn coding_intl(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::from_config_with_path(ProviderConfig::zai_intl(api_key, model), ZAI_CODING_PATH)
}
/// 用显式的 [`ProviderConfig`](host + model + key)构造,使用标准 PaaS 路径
/// `/api/paas/v4/chat/completions`。
/// Build from an explicit [`ProviderConfig`] (host + model + key) using the
/// standard PaaS path `/api/paas/v4/chat/completions`.
pub fn from_config(config: ProviderConfig) -> Self {
let inner = OpenAiCompatProvider::with_path(config, ZAI_PATH);
Self::from_config_with_path(config, ZAI_PAAS_PATH)
}
/// 用显式的 [`ProviderConfig`] 与自定义 chat-completions 路径构造
/// (例如 coding 套餐密钥使用 [`ZAI_CODING_PATH`])。
/// Build from an explicit [`ProviderConfig`] with a custom chat-completions
/// path (e.g. [`ZAI_CODING_PATH`] for coding-plan keys).
pub fn from_config_with_path(config: ProviderConfig, path: &str) -> Self {
let inner = OpenAiCompatProvider::with_path(config, path);
Self { inner }
}
/// Build with a custom transport (for tests).
/// 用自定义 transport 构造(用于测试)。使用标准 PaaS 路径。
/// Build with a custom transport (for tests). Uses the standard PaaS path.
pub fn with_mock(config: ProviderConfig, sse_body: impl Into<String>) -> Self {
let inner = OpenAiCompatProvider::with_mock(config, ZAI_PATH, sse_body);
let inner = OpenAiCompatProvider::with_mock(config, ZAI_PAAS_PATH, sse_body);
Self { inner }
}
/// 暴露底层的 OpenAI 兼容 provider。
/// Expose the underlying OpenAI-compatible provider.
pub fn inner(&self) -> &OpenAiCompatProvider {
&self.inner
@ -77,17 +140,34 @@ mod tests {
use focus_json::JsonValue;
#[test]
fn domestic_uses_correct_host_and_path() {
let p = ZaiProvider::domestic("key", "glm-4.6");
fn domestic_uses_correct_host_and_paas_path() {
let p = ZaiProvider::domestic("key", "glm-5.2");
assert_eq!(p.inner.config_ref().base_url, "open.bigmodel.cn");
assert_eq!(p.inner.config_ref().model, "glm-4.6");
assert_eq!(p.inner.config_ref().model, "glm-5.2");
assert_eq!(p.inner.path(), "/api/paas/v4/chat/completions");
}
#[test]
fn international_uses_correct_host() {
let p = ZaiProvider::international("key", "glm-4.6");
let p = ZaiProvider::international("key", "glm-5.2");
assert_eq!(p.inner.config_ref().base_url, "api.z.ai");
assert_eq!(p.inner.path(), "/api/paas/v4/chat/completions");
}
#[test]
fn coding_cn_uses_coding_path() {
// Coding 套餐密钥必须命中 /api/coding/paas/v4,否则会 429 余额不足。
// Coding-plan keys must hit /api/coding/paas/v4 — otherwise 429 余额不足.
let p = ZaiProvider::coding_cn("xxxxxxxx.yyyyyyyy", "glm-5.2");
assert_eq!(p.inner.config_ref().base_url, "open.bigmodel.cn");
assert_eq!(p.inner.path(), "/api/coding/paas/v4/chat/completions");
}
#[test]
fn coding_intl_uses_coding_path() {
let p = ZaiProvider::coding_intl("xxxxxxxx.yyyyyyyy", "glm-5.2");
assert_eq!(p.inner.config_ref().base_url, "api.z.ai");
assert_eq!(p.inner.path(), "/api/coding/paas/v4/chat/completions");
}
#[test]
@ -96,9 +176,9 @@ mod tests {
data: {\"choices\":[{\"delta\":{\"content\":\",世界\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\
data: [DONE]\n\n";
let provider = ZaiProvider::with_mock(ProviderConfig::zai_cn("test", "glm-4.6"), sse);
let provider = ZaiProvider::with_mock(ProviderConfig::zai_cn("test", "glm-5.2"), sse);
let req = ProviderRequest {
model: "glm-4.6".into(),
model: "glm-5.2".into(),
system_prompt: "".into(),
messages: vec![Message::user_text("你好")],
tools: JsonValue::arr(),

View File

@ -1,6 +1,10 @@
//! focus-tools:内置的文件与 shell 工具集。
//! focus-tools: built-in file and shell tools.
//!
//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`bash`。
//! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `bash`.
//!
//! 本里程碑阶段尚未实现。
//! Not yet implemented in this milestone.
#![forbid(unsafe_code)]

View File

@ -1,19 +1,28 @@
//! 传输层的错误类型。
//! Error type for the transport layer.
use std::fmt;
/// 传输层可能产生的错误。
/// Errors that can arise in the transport layer.
#[derive(Debug)]
pub enum TransportError {
/// URL 格式非法或不被支持。
/// The URL was malformed or unsupported.
BadUrl(String),
/// DNS 解析失败。
/// DNS resolution failed.
Dns(String),
/// 网络 I/O 错误(TCP、TLS 或 HTTP)。
/// A network I/O error (TCP, TLS, or HTTP).
Io(String),
/// TLS 握手失败。
/// The TLS handshake failed.
Tls(String),
/// 服务器返回非 2xx 状态码或格式错误的响应。
/// The server returned a non-2xx status or a malformed response.
Http { status: u16, body: String },
/// 请求超时。
/// A timeout elapsed.
Timeout,
}
@ -35,10 +44,14 @@ impl fmt::Display for TransportError {
impl std::error::Error for TransportError {}
/// 将 std::io::Error 转换为传输层错误(归入 Io 变体)。
/// Convert a std::io::Error into a transport error (as the Io variant).
impl From<std::io::Error> for TransportError {
fn from(e: std::io::Error) -> Self {
TransportError::Io(e.to_string())
}
}
/// 传输层操作的统一返回类型别名。
/// Standard Result alias for transport operations.
pub type TransportResult<T> = Result<T, TransportError>;

View File

@ -1,3 +1,5 @@
//! HTTP/1.1 请求/响应类型,以及最小化的请求序列化器与响应解析器。
//! 我们只实现足以 POST JSON 并读取流式(分块或 content-length)响应体的 HTTP/1.1 子集。
//! HTTP/1.1 request/response types and a minimal request serializer + response
//! parser. We implement just enough of HTTP/1.1 to POST JSON and read a
//! streamed (chunked or content-length) body.
@ -5,6 +7,8 @@
use crate::error::{TransportError, TransportResult};
use std::collections::HashMap;
/// HTTP 请求方法(仅支持 GET/POST 子集)。
/// HTTP request method (a GET/POST subset only).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
@ -12,6 +16,8 @@ pub enum Method {
}
impl Method {
/// 返回该方法在线路上使用的字符串形式。
/// Return the wire-format string for this method.
pub fn as_str(&self) -> &'static str {
match self {
Method::Get => "GET",
@ -20,17 +26,30 @@ impl Method {
}
}
/// 一个待发送到线路上的 HTTP 请求。
/// An HTTP request to be sent over the wire.
#[derive(Debug, Clone)]
pub struct HttpRequest {
/// 请求方法。
/// The request method.
pub method: Method,
/// 请求目标路径(含查询串)。
/// The request target path (including query string).
pub path: String,
/// 目标主机名。
/// The target host.
pub host: String,
/// 请求头(按插入顺序保留)。
/// Request headers, in insertion order.
pub headers: Vec<(String, String)>,
/// 请求体字节。
/// The request body bytes.
pub body: Vec<u8>,
}
impl HttpRequest {
/// 以 POST 方法构造一个到 `host` 的 `path` 请求(builder 入口)。
/// Begin a POST request to `path` on `host` (builder entry point).
pub fn post(host: &str, path: &str) -> Self {
Self {
method: Method::Post,
@ -41,11 +60,15 @@ impl HttpRequest {
}
}
/// 追加一个请求头,返回自身以支持链式调用。
/// Append a header and return self for chaining.
pub fn header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
/// 设置 JSON 请求体,自动添加 Content-Type 与 Content-Length 头。
/// Set a JSON body, adding Content-Type and Content-Length headers.
pub fn json_body(mut self, body: &str) -> Self {
self.headers
.push(("Content-Type".into(), "application/json".into()));
@ -55,6 +78,7 @@ impl HttpRequest {
self
}
/// 序列化为 HTTP/1.1 线路格式。
/// Serialize to the HTTP/1.1 wire format.
pub fn serialize(&self) -> Vec<u8> {
let mut out = Vec::new();
@ -62,6 +86,7 @@ impl HttpRequest {
out.push(b' ');
out.extend_from_slice(self.path.as_bytes());
out.extend_from_slice(b" HTTP/1.1\r\n");
// 确保 Host 头存在。
// Ensure Host is present.
let has_host = self
.headers
@ -79,31 +104,43 @@ impl HttpRequest {
}
}
/// 已解析的 HTTP 响应。
/// A parsed HTTP response.
#[derive(Debug, Clone)]
pub struct HttpResponse {
/// HTTP 状态码。
/// HTTP status code.
pub status: u16,
/// 响应头(键已小写化)。
/// Headers (keys lowercased).
pub headers: HashMap<String, String>,
/// 目前后已收到的响应体字节(非流式读取时)。
/// The body bytes received so far (for non-streaming reads).
pub body: Vec<u8>,
}
impl HttpResponse {
/// 状态码为 2xx 时返回 true。
/// True for 2xx status codes.
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
/// 以有损方式将响应体解码为字符串。
/// Decode the body to a string (lossy).
pub fn body_string(&self) -> String {
String::from_utf8_lossy(&self.body).to_string()
}
}
/// 从 `buf` 起始处解析 HTTP/1.1 状态行与响应头。返回 `(响应元数据, 已消费的头部字节数)`,
/// 其中响应元数据的 body 为空;调用方随后根据 Transfer-Encoding/Content-Length 读取响应体。
/// Parses the HTTP/1.1 status line and headers from the start of `buf`.
/// Returns `(response_meta, header_bytes_consumed)` where `response_meta` has
/// an empty body. The caller then reads the body based on
/// Transfer-Encoding/Content-Length.
pub fn parse_response_head(buf: &[u8]) -> TransportResult<(HttpResponse, usize)> {
// 查找响应头结束位置(\r\n\r\n)。
// Find the end of headers (\r\n\r\n).
let head_end = find_subsequence(buf, b"\r\n\r\n")
.ok_or_else(|| TransportError::Io("incomplete HTTP response head".into()))?;
@ -137,12 +174,15 @@ pub fn parse_response_head(buf: &[u8]) -> TransportResult<(HttpResponse, usize)>
headers,
body: Vec::new(),
};
// 已消费字节数 = head_end + len("\r\n\r\n")。
// Consumed bytes = head_end + len("\r\n\r\n").
Ok((resp, head_end + 4))
}
/// Determine how many body bytes remain to read. Returns `None` for chunked
/// encoding (caller must use the chunk decoder).
/// 判定还需读取多少响应体字节。分块编码时返回 [`BodyLength::Chunked`]
/// (调用方须使用分块解码器)。
/// Determine how many body bytes remain to read. Returns [`BodyLength::Chunked`]
/// for chunked encoding (caller must use the chunk decoder).
pub fn body_length(resp: &HttpResponse) -> BodyLength {
if resp
.headers
@ -161,13 +201,23 @@ pub fn body_length(resp: &HttpResponse) -> BodyLength {
}
}
/// 响应体长度的三种确定方式。
/// The three ways to determine the response body length.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BodyLength {
/// 固定长度(Content-Length)。
/// Fixed length (Content-Length).
Fixed(usize),
/// 分块传输编码。
/// Chunked transfer encoding.
Chunked,
/// 读至连接关闭。
/// Read until connection close.
UntilClose,
}
/// 在 `haystack` 中查找 `needle` 首次出现的字节偏移。
/// Find the first byte offset of `needle` within `haystack`.
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())

View File

@ -1,18 +1,40 @@
//! focus-transport:HTTPS 客户端与 SSE 解析器。
//! focus-transport: HTTPS client and SSE parser.
//!
//! 基于 tokio + rustls 构建。提供 [`Transport`] trait、使用 rustls 完成 TLS 握手的
//! HTTPS 客户端、手写的 HTTP/1.1 编解码器,以及用于 Server-Sent Events 流的
//! 增量解析器 [`SseParser`]。
//! Built on tokio + rustls. Provides a [`Transport`] trait, an HTTPS client
//! that performs TLS handshakes with rustls, a hand-rolled HTTP/1.1 codec, and
//! an incremental [`SseParser`] for Server-Sent Events streams.
#![forbid(unsafe_code)]
/// 传输层错误类型。
/// Error types for the transport layer.
pub mod error;
/// HTTP/1.1 请求/响应类型与编解码。
/// HTTP/1.1 request/response types and codec.
pub mod http;
/// 增量 SSE(Server-Sent Events)解析器。
/// Incremental SSE (Server-Sent Events) parser.
pub mod sse;
/// 基于 rustls + tokio 的 TLS 连接建立。
/// TLS connection establishment with rustls + tokio.
pub mod tls;
/// [`Transport`] trait 及其 tokio+rustls 实现 [`HttpTransport`]。
/// The [`Transport`] trait and its tokio+rustls impl [`HttpTransport`].
pub mod transport;
/// 传输层错误与结果类型别名。
/// Transport error type and result alias.
pub use error::{TransportError, TransportResult};
/// HTTP 请求、响应与方法。
/// HTTP request, response, and method.
pub use http::{HttpRequest, HttpResponse, Method};
/// SSE 事件与增量解析器。
/// SSE event and incremental parser.
pub use sse::{SseEvent, SseParser};
/// HTTP 传输实现与传输 trait。
/// HTTP transport implementation and the transport trait.
pub use transport::{HttpTransport, Transport};

View File

@ -1,53 +1,75 @@
//! 增量 Server-Sent Events(SSE)解析器。
//! Incremental Server-Sent Events (SSE) parser.
//!
//! SSE 流是面向行的:事件之间以空行分隔;在一个事件内部,以 `event:`、`data:`、
//! `id:`、`retry:` 开头的行用于设置字段。本解析器是增量的——它按网络到达顺序
//! 接收字节块,并输出完整的 [`SseEvent`]。
//! SSE streams are line-oriented: events are separated by blank lines, and
//! within an event, lines starting with `event:`, `data:`, `id:`, and
//! `retry:` set fields. The parser is incremental — it accepts chunks of
//! bytes as they arrive from the network and emits complete [`SseEvent`]s.
//!
//! 参见 <https://html.spec.whatwg.org/multipage/server-sent-events.html>。
//! See <https://html.spec.whatwg.org/multipage/server-sent-events.html>.
use std::collections::VecDeque;
/// 一个已解析的 SSE 事件。
/// One parsed SSE event.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SseEvent {
/// 事件类型;未设置时按规范默认为 `"message"`。
/// The event type. Defaults to `"message"` per spec if not set.
pub event: String,
/// 拼接后的 data 行(以 `\n` 连接)。
/// Concatenated data lines (joined with `\n`).
pub data: String,
/// 最近的事件 id(如有)。
/// The last event id, if any.
pub id: String,
/// 重连间隔(如已设置)。
/// The reconnection interval, if set.
pub retry: Option<u64>,
}
/// 增量 SSE 解析器。通过 [`SseParser::feed`] 喂入原始(UTF-8)字节,
/// 通过 [`SseParser::next_event`] 取出完整事件。
/// An incremental SSE parser. Feed it raw bytes (UTF-8) via [`SseParser::feed`]
/// and pull complete events out of [`SseParser::next`].
/// and pull complete events out of [`SseParser::next_event`].
#[derive(Debug, Default)]
pub struct SseParser {
/// 已解码但尚未处理的文本。
/// Decoded-but-unprocessed text.
buffer: String,
/// buffer 中已扫描行边界的位置。
/// Position up to which `buffer` has been scanned for line boundaries.
scanned: usize,
/// 已完成、待取走的事件队列。
/// Completed events waiting to be drained.
ready: VecDeque<SseEvent>,
/// 正在累积的当前事件。
/// The event currently being accumulated.
current: SseEvent,
/// 当前事件是否已设置过 data 字段(即使为空)。
/// Whether the current event has had a `data:` field set (even to empty).
current_has_data: bool,
/// 从上一字节块遗留的部分 UTF-8 序列。
/// Bytes of a partial UTF-8 sequence carried over from the last chunk.
partial_utf8: Vec<u8>,
}
impl SseParser {
/// 创建一个空的解析器。
/// Create an empty parser.
pub fn new() -> Self {
Self::default()
}
/// 喂入一块字节。边界处不完整的 UTF-8 序列会被缓冲,直到下一块将其补全。
/// Feed a chunk of bytes. Partial UTF-8 sequences at the boundary are
/// buffered until the next chunk completes them.
pub fn feed(&mut self, bytes: &[u8]) {
// 合并遗留字节与新数据块,再解码。
// Combine any leftover bytes with the new chunk, then decode.
let mut combined = std::mem::take(&mut self.partial_utf8);
combined.extend_from_slice(bytes);
@ -59,9 +81,11 @@ impl SseParser {
Err(e) => {
let valid = e.valid_up_to();
if valid == 0 {
// 整块都是不完整序列——保留它。
// The entire chunk is an incomplete sequence — keep it.
self.partial_utf8 = combined;
} else {
// 解码有效前缀,其余部分保留。
// Decode the valid prefix and keep the rest.
let prefix = std::str::from_utf8(&combined[..valid])
.unwrap_or("")
@ -74,21 +98,26 @@ impl SseParser {
}
}
/// 直接喂入已解码文本(便于测试)。
/// Feed already-decoded text directly (useful for tests).
pub fn feed_str(&mut self, text: &str) {
self.buffer.push_str(text);
self.scan();
}
/// 取出下一个完整事件(如有)。
/// Pull the next complete event, if any.
pub fn next_event(&mut self) -> Option<SseEvent> {
self.ready.pop_front()
}
/// 扫描 buffer 中的换行,将每一行交给 process_line 处理。
/// Scan buffer for newlines and dispatch each line to process_line.
fn scan(&mut self) {
while let Some(nl) = self.buffer[self.scanned..].find('\n') {
let line_start = self.scanned;
let line_end = line_start + nl;
// 去掉行尾 '\r'(CRLF 行结束符)。
// Drop a trailing '\r' (CRLF line endings).
let raw_line_end = if line_end > 0 && self.buffer.as_bytes()[line_end - 1] == b'\r' {
line_end - 1
@ -99,6 +128,7 @@ impl SseParser {
self.scanned = line_end + 1;
self.process_line(&line);
}
// 回收已消费的缓冲区空间。
// Reclaim consumed buffer space.
if self.scanned > 0 && self.scanned == self.buffer.len() {
self.buffer.clear();
@ -107,8 +137,10 @@ impl SseParser {
}
fn process_line(&mut self, line: &str) {
// 空行触发当前事件的派发。
// A blank line dispatches the current event.
if line.is_empty() {
// 按规范,只有设置了 data 字段(即使为空)或显式事件类型时才派发该事件。
// Per spec, an event is dispatched if it had a data field set
// (even to empty) or an explicit event type.
if self.current_has_data || !self.current.event.is_empty() {
@ -121,6 +153,7 @@ impl SseParser {
}
return;
}
// 以 ':' 开头的行是注释——忽略。
// Lines starting with ':' are comments — ignore.
if line.starts_with(':') {
return;
@ -128,6 +161,7 @@ impl SseParser {
let (field, value) = match line.find(':') {
Some(idx) => {
let field = &line[..idx];
// 跳过冒号后的单个前导空格。
// Skip a single leading space after the colon.
let mut value = &line[idx + 1..];
if value.starts_with(' ') {
@ -137,6 +171,8 @@ impl SseParser {
}
None => (line, ""),
};
// 根据字段名更新当前事件的对应字段。
// Update the corresponding field of the current event by field name.
match field {
"event" => self.current.event = value.to_string(),
"data" => {

View File

@ -1,5 +1,8 @@
//! 使用 rustls + tokio 建立 TLS 连接。
//! TLS connection establishment using rustls + tokio.
//!
//! 我们构建一个单一、共享的 [`rustls::ClientConfig`],使用平台原生根证书
//! (仅在测试中回退为放行式配置)。
//! We build a single shared [`rustls::ClientConfig`] with the platform's native
//! root certificates (falling back to a permissive config only in tests).
@ -10,13 +13,16 @@ use tokio::net::TcpStream;
use tokio_rustls::client::TlsStream;
use tokio_rustls::TlsConnector;
/// 到远端主机的已连接 TLS 流。
/// A connected TLS stream to a remote host.
pub type TlsConnection = TlsStream<TcpStream>;
/// 获取共享的、延迟初始化的 rustls 客户端配置。
/// Get the shared, lazily-initialized rustls client config.
pub fn client_config() -> &'static ClientConfig {
static CONFIG: OnceLock<ClientConfig> = OnceLock::new();
CONFIG.get_or_init(|| {
// 加载操作系统原生的根证书库。
// Load the OS-native root certificate store.
let mut roots = rustls::RootCertStore::empty();
match rustls_native_certs::load_native_certs() {
@ -35,6 +41,8 @@ pub fn client_config() -> &'static ClientConfig {
})
}
/// 仅用于测试、接受任意服务器证书的配置。该配置绝不用于生产构建,
/// 仅为单元测试连接桩服务器而存在。
/// A test-only config that accepts any server certificate. This is NEVER used
/// in production builds; it exists so unit tests can talk to stub servers.
#[cfg(test)]
@ -45,10 +53,14 @@ pub fn danger_no_verification_config() -> ClientConfig {
.with_no_client_auth()
}
/// 一个总是通过验证的证书校验器(仅测试用)。
/// A certificate verifier that always succeeds (test only).
#[cfg(test)]
#[derive(Debug)]
struct NoVerification;
/// 将所有证书校验视为通过。
/// Treat every certificate verification as passing.
#[cfg(test)]
impl rustls::client::danger::ServerCertVerifier for NoVerification {
fn verify_server_cert(
@ -95,6 +107,7 @@ impl rustls::client::danger::ServerCertVerifier for NoVerification {
}
}
/// 连接到 `host:port` 的 TLS 流。
/// Connect a TLS stream to `host:port`.
pub async fn connect(host: &str, port: u16) -> TransportResult<TlsConnection> {
let addr = format!("{}:{}", host, port);

View File

@ -1,5 +1,9 @@
//! [`Transport`] trait 及基于 tokio+rustls 的 [`HttpTransport`] 实现。
//! The [`Transport`] trait and a tokio+rustls-based [`HttpTransport`].
//!
//! 传输层发送一个 [`HttpRequest`] 并返回 [`HttpResponse`]。具体的 [`HttpTransport`]
//! 为每个请求建立一次 TLS 连接,并将完整响应体(分块或 content-length)读入内存。
//! 这足以支撑 `focus-core` 所采用的同步拉取式流模型。
//! A transport sends an [`HttpRequest`] and returns the [`HttpResponse`]. The
//! concrete [`HttpTransport`] performs a TLS connection per request and reads
//! the full body (chunked or content-length) into memory. This is sufficient
@ -10,10 +14,13 @@ use crate::http::{body_length, parse_response_head, BodyLength, HttpRequest, Htt
use crate::tls;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// 通过网络连接发送 HTTP 请求。
/// Sends HTTP requests over a network connection.
pub trait Transport: Send + Sync {
/// 发送请求并等待完整响应。
/// Send the request and await the full response.
///
/// 实现者在 future 生命周期内借用请求;调用方须使其存活至 future 完成。
/// Implementations borrow the request for the duration of the future;
/// callers must keep it alive until the future resolves.
fn request<'a>(
@ -24,11 +31,14 @@ pub trait Transport: Send + Sync {
>;
}
/// 基于 tokio + rustls 的真实 HTTPS 传输实现。
/// A real HTTPS transport backed by tokio + rustls.
#[derive(Debug, Default, Clone)]
pub struct HttpTransport;
impl HttpTransport {
/// 创建默认实例。
/// Create a default instance.
pub fn new() -> Self {
Self
}
@ -45,6 +55,8 @@ impl Transport for HttpTransport {
}
}
/// 执行单次 HTTP 请求:建立 TLS 连接、序列化并发送请求、读取完整响应并解析。
/// Perform a single HTTP request: open TLS, serialize & send, read the full response, parse.
async fn perform_request(req: &HttpRequest) -> TransportResult<HttpResponse> {
let mut stream = tls::connect(&req.host, 443).await?;
@ -58,14 +70,19 @@ async fn perform_request(req: &HttpRequest) -> TransportResult<HttpResponse> {
.await
.map_err(|e| TransportError::Io(format!("flush request: {}", e)))?;
// 将整个响应读入缓冲区,再解析。
// Read the entire response into a buffer, then parse.
let mut buf = Vec::with_capacity(8192);
loop {
let mut chunk = [0u8; 4096];
match stream.read(&mut chunk).await {
Ok(0) => break, // clean EOF
Ok(0) => break, // 干净的 EOF / clean EOF
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(e) => {
// rustls 会把对端在未发送 TLS close_notify 的情况下关闭连接表现为
// UnexpectedEof 错误(某些代理会直接重置连接 → ConnectionReset)。
// 若此时缓冲区已有响应数据,则这些属于良性的连接拆除——视作正常 EOF。
// 其他错误,或在收到任何数据之前就发生的错误,则向上传播。
// rustls surfaces a peer that closed the connection without a
// TLS close_notify as an UnexpectedEof error (and some proxies
// reset the connection → ConnectionReset). If we already have
@ -114,29 +131,33 @@ async fn perform_request(req: &HttpRequest) -> TransportResult<HttpResponse> {
Ok(resp)
}
/// 解码 HTTP/1.1 分块传输编码(chunked)的响应体。
/// Decode an HTTP/1.1 chunked transfer-encoded body.
fn decode_chunked(bytes: &[u8]) -> TransportResult<Vec<u8>> {
let mut out = Vec::new();
let mut pos = 0;
while pos < bytes.len() {
// 读取分块大小行。
// Read the chunk size line.
let line_end = find_line_end(&bytes[pos..])?;
let line = std::str::from_utf8(&bytes[pos..pos + line_end])
.map_err(|_| TransportError::Io("non-utf8 chunk size line".into()))?;
let size_str = line.trim_end_matches(['\r', '\n']);
// 去掉分块扩展(";...")。
// Strip any chunk extensions (";...").
let size_hex = size_str.split(';').next().unwrap_or("0").trim();
let size = usize::from_str_radix(size_hex, 16)
.map_err(|_| TransportError::Io(format!("invalid chunk size: {}", size_hex)))?;
pos += line_end + 1; // skip the line + '\n'
pos += line_end + 1; // 跳过该行及 '\n' / skip the line + '\n'
if size == 0 {
break; // last chunk
break; // 最后一个分块 / last chunk
}
if pos + size > bytes.len() {
return Err(TransportError::Io("chunk body truncated".into()));
}
out.extend_from_slice(&bytes[pos..pos + size]);
pos += size;
// 跳过分块数据后的尾随 CRLF。
// Skip the trailing CRLF after the chunk data.
if pos + 1 < bytes.len() && bytes[pos] == b'\r' && bytes[pos + 1] == b'\n' {
pos += 2;
@ -147,6 +168,8 @@ fn decode_chunked(bytes: &[u8]) -> TransportResult<Vec<u8>> {
Ok(out)
}
/// 返回 `bytes` 中第一个 '\n' 的字节偏移;不存在则报错。
/// Return the byte offset of the first '\n' in `bytes`, or an error if none.
fn find_line_end(bytes: &[u8]) -> TransportResult<usize> {
bytes
.iter()
@ -160,6 +183,7 @@ mod tests {
#[test]
fn decodes_simple_chunked_body() {
// 两个分块:"Hello" 与 " World",随后是终止分块。
// Two chunks: "Hello" and " World", then terminating chunk.
let body = b"5\r\nHello\r\n6\r\n World\r\n0\r\n\r\n";
let decoded = decode_chunked(body).unwrap();