ADD: 注释
This commit is contained in:
parent
a7ddba035a
commit
5364ca004c
47
AGENTS.md
47
AGENTS.md
|
|
@ -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 原版)和
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! focus-harness:会话持久化、上下文压缩与系统提示词模板。
|
||||
//! focus-harness: session persistence, compaction, system prompts.
|
||||
//!
|
||||
//! 本里程碑阶段尚未实现。
|
||||
//! Not yet implemented in this milestone.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue