OpenHands 前端 API 服务层实现指南:Service 模式、类型化客户端与 TanStack Query 集成
OpenHands(Agent Canvas)前端将「组件 → 后端 API」之间的网络访问统一收敛到 src/api/ 下的服务层:本地 agent-server 访问必须使用 @openhands/typescript-client 类型化客户端并复用共享连接选项,云端访问则走 cloud 代理模块。读完本篇,你将掌握该服务层的目录结构、命名规范、连接选项解析机制、强制架构守卫测试,以及如何用 TanStack Query hooks 正确消费这些服务。
1. 服务层的定位:组件与后端之间的唯一抽象层
根据 API Services Guide 的定义,服务(Services)是前端组件与后端 API 之间的抽象层。当前仓库中它遵循两条明确的通道划分:
- 本地 agent-server 通道:直接使用
@openhands/typescript-client导出的客户端类(如LLMMetadataClient、VSCodeClient、ServerClient),连接参数(host、session API key、workspace 默认值)统一来自 agent-server-client-options.ts; - 云端通道:使用
src/api/cloud/下的 cloud service 模块或代理 helper,而不是本地 agent-server 客户端。
每条服务以「纯对象 + 异步方法」的形式组织(Each service is a plain object with async methods),不引入类实例、单例或全局状态。从源码结构看,src/api/ 下还包含一批不属于「feature-service 目录」形态的横切模块,例如 agent-server-adapter.ts(负责把 agent-server 的 wire 数据适配为前端 AppConversation 模型)、with-retry.ts、main-app-auth.ts 等,它们与目录化服务共同构成完整的 API 层。
当前实际落地的服务目录包括:config-service/、conversation-service/、event-service/、git-service/、mcp-service/、secrets-service.ts、settings-service/、workspaces-service/、bash-service/、automation-service/、option-service/、profiles-service/、provider-connections-service/、runtime-service/、agent-profiles-service/ 等,全部位于 src/api/ 目录下。
2. 目录结构与命名规范
官方约定每个服务拥有独立目录,且目录内固定两个文件:
src/api/
└── feature-service/
├── feature-service.api.ts # Service methods
└── feature.types.ts # Types and interfaces
对应的命名规范如下(完整继承自原文档):
| 项目 | 规范 | 示例 |
|---|---|---|
| 目录 | feature-service/ |
secrets-service/ |
| 服务文件 | feature-service.api.ts |
secrets-service.api.ts |
| 类型文件 | feature.types.ts |
secrets.types.ts |
| 导出名 | featureService |
secretsService |
以仓库中真实存在的 config-service 为例,其目录内即为 config-service.api.ts + config-service.types.ts 的双文件结构,与规范完全一致。需要注意一个实现细节:部分真实服务(如 config-service.api.ts、conversation-service.api.ts)采用了带静态方法的类 + export default 的组织方式,而规范推荐的是对象字面量 + 命名导出;从源码结构看,这是历史形态与现行约定的并存,新代码应遵循 README 中的对象字面量写法。
3. 连接选项解析:getAgentServerClientOptions 的完整机制
规范示例中出现的 getAgentServerClientOptions() 是整个服务层的地基,其实现位于 agent-server-client-options.ts。完整参数与行为如下:
// src/api/agent-server-client-options.ts
export interface AgentServerClientOverrides {
host?: string; // 显式覆盖 host
apiKey?: string | null; // 覆盖 API key
sessionApiKey?: string | null; // 会话级 API key(优先级最高)
workingDir?: string; // 覆盖工作目录
conversationUrl?: string | null; // 由会话 URL 反推 host
timeout?: number; // 请求超时(毫秒)
}
export interface AgentServerClientOptions {
host: string;
apiKey?: string;
workingDir: string;
timeout?: number;
}
getAgentServerClientOptions(overrides) 的解析规则:
- host 解析优先级:
overrides.host>overrides.conversationUrl(经buildHttpBaseUrl从 WebSocket URL 推导 HTTP base)> 当前激活本地后端的backend.host(来自 backend-registry/active-store.ts)。所有路径都会经normalizeHost去除尾部斜杠; - 无后端时的失败语义:若既没有激活的本地 Backend,也没有任何 host/conversationUrl 覆盖,抛出类型化的
NoBackendAvailableError(附带isNoBackendAvailableError类型守卫),供上层区分「未配置后端」与网络错误; - API key 优先级:
sessionApiKey>apiKey>backend.apiKey; - workingDir 默认值:取
getAgentServerWorkingDir(),其默认常量DEFAULT_WORKING_DIR = "workspace/project"定义在 agent-server-config.ts 中。
此外还有一个 HTTP 变体 getAgentServerHttpClientOptions(),它把上述选项转换为 SDK 的 baseUrl/apiKey/timeout 形态,且 timeout 默认 60000ms。例如 conversation-service.api.ts 中拉取轨迹数据时就用了这个变体:
const page = await new RemoteEventsList(
getAgentServerHttpClientOptions(this.getClientOverrides()),
conversationId,
).search({ limit: 10000 });
其中 getClientOverrides() 会注入当前会话的 session_api_key,体现了「会话级密钥优先」的设计——这与 backend-registry/types.ts 中 Backend 接口携带 apiKey/connectionRevision 字段的注册表机制相互衔接。
4. 创建一个新服务:规范写法与关键约束
原文档给出的创建范式值得逐条拆解:
使用对象字面量 + 命名导出;参数用对象解构使调用自文档化;优先使用类型化的
@openhands/typescript-client类而非通用 HTTP 调用;如果缺少所需端点,先给@openhands/typescript-client补齐,而不是在应用内手写 fetch。
// feature-service/feature-service.api.ts
import { FeatureClient } from "@openhands/typescript-client/clients";
import { getAgentServerClientOptions } from "../agent-server-client-options";
import { Feature, CreateFeatureParams } from "./feature.types";
export const featureService = {
getFeature: async ({ id }: { id: string }): Promise<Feature> => {
return new FeatureClient(getAgentServerClientOptions()).getFeature(id);
},
createFeature: async (params: CreateFeatureParams): Promise<Feature> => {
return new FeatureClient(getAgentServerClientOptions()).createFeature(params);
},
};
// feature-service/feature.types.ts
// 仅当 SDK 模型不足时,才在独立类型文件中定义应用级类型
export interface Feature {
id: string;
name: string;
description: string;
}
export interface CreateFeatureParams {
name: string;
description: string;
}
要点:
- 每个方法内部
new XxxClient(getAgentServerClientOptions())即时构造客户端,避免长生命周期实例; - 参数对象解构(
{ id }: { id: string })让调用点形如featureService.getFeature({ id: "abc" }),语义自明; - 应用特有类型放在同目录
feature.types.ts中,与 SDK 模型解耦。
类型化访问的强制守卫
这条「不许手写 HTTP」的约定不是口号,而是被测试强制执行的。no-direct-agent-server-calls.test.ts 会递归扫描 src/ 下所有非测试的 TS/TSX 文件,拦截以下行为:
- 直接使用共享 axios 实例(
openHands.xxx); - 直接调用
createHttpClient(...)或直接 import SDK 低层HttpClient; - 直接
new HttpClient(...); - 直接使用
axios发起请求; - 直接用
fetch请求/api/路径。
仅三个文件被显式加入白名单:api/automation-service/automation-service.api.ts、api/cloud/proxy.ts、api/main-app-auth.ts。这也解释了为什么云端通道必须走 cloud/proxy.ts 的 callCloudProxy——它是全仓库唯一合法的通用 HTTP 出口之一。
5. 云端通道:callCloudProxy 与本地路径的分流
cloud/proxy.ts 中的 callCloudProxy<TResponse>(req) 接收 CloudProxyRequest:backend(必须为 cloud 类型的 Backend)、method、path、可选 body/headers/timeoutSeconds/hostOverride,以及认证模式(bearer 默认 / session-api-key / none)。内部通过 createCloudClient / createCloudClientForRuntime 构造云客户端再转发请求。
真实服务如何二选一分流,可以见 config-service.api.ts 的 searchModels:
const active = getActiveBackend();
if (active.backend.kind === "cloud") {
// 云端直接暴露 /api/v1/config/models/search,返回 LLMModelPage
const qs = buildCloudQueryString({
page_id: params.page_id,
limit: params.limit,
query: params.query,
verified__eq: params.verified__eq,
provider__eq: params.provider__eq,
});
return callCloudProxy<LLMModelPage>({
backend: active.backend,
method: "GET",
path: `/api/v1/config/models/search${qs}`,
});
}
// 本地:走类型化 LLMMetadataClient,并在此做 verified 状态重建与分页/过滤
const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
const [models, verifiedMap] = await Promise.all([
llmClient.getModels(),
llmClient.getVerifiedModels(),
]);
// ...过滤 query / verified__eq / limit 后返回 { items, next_page_id: null }
该实现同时展示了本地路径的一个实用模式:Promise.all 并行拉取模型列表与 verified 模型映射,支持外部传入预取的 verifiedByProvider 以避免重复请求,并在客户端侧完成 query/verified__eq/limit 三个过滤参数的完整落地——即文档所说的「参数说明可执行」。
6. 消费方式:必须包在 TanStack Query hooks 中
原文档用 IMPORTANT 级别强调:不要在组件里直接调用 service,一律包成 TanStack Query hooks:
- Caching —— 避免冗余网络请求
- Deduplication —— 多个组件请求同一数据时共享一次请求
- Loading/error states —— 内置
isLoading、isError、data状态- Background refetching —— 数据自动保持新鲜
Hooks 的位置约定:
src/hooks/query/:数据获取(useQuery)src/hooks/mutation/:写入/更新(useMutation)
标准示例(原文档示例,路径前缀 #/ 为仓库内 alias):
// src/hooks/query/use-feature.ts
import { useQuery } from "@tanstack/react-query";
import { featureService } from "#/api/feature-service/feature-service.api";
export const useFeature = (id: string) => {
return useQuery({
queryKey: ["feature", id],
queryFn: () => featureService.getFeature({ id }),
});
};
这与仓库实际结构吻合:src/hooks/query/ 下约 70 余个 query hooks 与 src/hooks/mutation/ 下约 40 余个 mutation hooks,一一对应 src/api/ 中的服务模块。组件层因此只依赖 hook 的 data/isLoading/error,永远不感知 host、API key 或请求重试细节。
7. 实践清单
新增一个前端 API 能力时,按以下顺序操作即可与仓库规范保持一致:
- 确认端点是否存在于
@openhands/typescript-client;若缺失,先给该 SDK 补端点,而不是在本仓库手写 HTTP(守卫测试 no-direct-agent-server-calls.test.ts 会拦截后者); - 在
src/api/<feature>-service/下创建<feature>-service.api.ts与<feature>.types.ts,导出命名对象<feature>Service,方法内通过getAgentServerClientOptions()构造类型化客户端; - 若该能力仅存在于云端,改经
callCloudProxy(参考 config-service.api.ts 的kind === "cloud"分流写法); - 在
src/hooks/query/或src/hooks/mutation/中为它包一层 TanStack Query hook,组件只消费 hook; - 需要特殊行为(会话级密钥、超时、workingDir 覆盖)时,通过
AgentServerClientOverrides传入覆盖项,而不是绕过共享连接选项自行拼 URL。
这套「目录即模块、SDK 即客户端、hook 即消费口」的分层,是 OpenHands 前端在本地与云端双后端架构下保持 API 访问可测试、可缓存、可审计的核心工程约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00