跳到主内容
创建时间:最后更新:作者:白川

接入自定义接口与本地模型

看清 Models 与 Provider 的抽象边界,为兼容接口和本地服务构建自己的 Provider,并复用同一段调用代码。

接入自定义接口与本地模型

有些服务给你的是一个兼容接口地址,而不是当前模型层已内置的 Provider。还有些模型运行在自己的机器上。它们都需要明确的地址、模型 ID 和协议配置,不能只修改模型名称。

本篇用兼容 Chat Completions 的接口作为主线。项目沿用模型配置中的依赖和 connection.ts,但单独创建一个 Provider,不修改内置服务的行为。

三层抽象各管什么

模型层的接口分三层,边界很清楚:

层接口拥有什么谁实现
集合层
Models
Provider 注册表、认证解析、请求路由
Pi 提供,你只读
集合层(可变)
MutableModels
同上,外加注册/删除/清空
你通过 createModels() 获得
Provider 层
Provider<TApi>
目录、凭据、连接实现
Pi 内置,或你自己构建

Models 与 MutableModels 的关系是继承,不是并列:

typescript
export interface MutableModels extends Models {
	setProvider(provider: Provider): void;
	deleteProvider(id: string): void;
	clearProviders(): void;
}

MutableModels 拥有 Models 的全部读能力。如果一个函数签名写的是 Models,它照样能接收 MutableModels——这正是 createModels() 的返回值可以直接传给 Agent 的 streamFn 的原因。

Models 接口上的读方法分三组,源码注释写得很明确:

typescript
/**
 * Read accessors come in three flavors: the unqualified ones (`getModels`,
 * `getModel`, `getAvailable`) return chat models, the `*OfType` accessors
 * return one model type, and `getAllModels`/`getAllAvailable` return every type.
 */

带 *OfType 的方法返回一个指定类型的模型集合,getAllModels 和 getAllAvailable 返回全部类型。这三组方法都是同步的,返回的是上一次已知的结果。

Provider 接口的字段

packages/ai/src/models.ts 第 158 行开始的 Provider 接口是构建自定义 Provider 时唯一需要读懂的东西:

typescript
export interface Provider<TApi extends Api = Api> {
	readonly id: string;
	readonly name: string;
	readonly baseUrl?: string;
	readonly headers?: ProviderHeaders;
	readonly auth: ProviderAuth;
	getModels(): readonly Model<TApi>[];
	getAllModels?(): readonly ProviderModel<TApi>[];
	refreshModels?(context: RefreshModelsContext): Promise<void>;
	filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
	filterAllModels?(models: readonly ProviderModel<TApi>[], credential: Credential | undefined): readonly ProviderModel<TApi>[];
	stream<T extends TApi>(model: Model<T>, context: TranscriptContext, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
	streamSimple(model: Model<TApi>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream;
	fetchDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredFetchOptions): AssistantMessageEventStream;
	cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;
	generateImages?(model: ImageModel<ImageApi>, context: ImagesContext, options?: ImagesOptions): Promise<AssistantImages>;
	classify?(model: ClassifierModel<ClassifierApi>, context: ClassifierContext, options?: ClassifierOptions): Promise<ClassifierResult>;
}

必填字段只有四个:id、name、auth、getModels(),加上两个流方法 stream 和 streamSimple。其余都是可选的扩展点。

泛型参数 TApi 的作用在源码注释里:

typescript
/**
 * `TApi` lets concrete provider factories declare which chat APIs their models
 * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
 * giving typed chat model lists to direct factory users. Other model types use
 * their operation-specific API unions. Inside a `Models` collection providers
 * are held as `Provider<Api>`.
 */

翻译过来:Provider 工厂可以用泛型声明自己支持哪几种 API,让直接调用工厂的用户拿到带类型的模型列表;而放进 Models 集合后,Provider 一律被当作 Provider<Api> 持有。

getModels() 的契约在注释里被强调了:

typescript
/**
 * Current known chat models, sync. Static providers return their catalog;
 * dynamic providers return the list as of the last `refreshModels()` (empty
 * before the first). Must not throw; `Models` treats a throwing
 * implementation as having no models.
 */

三个要点:同步返回、动态 Provider 首次刷新前返回空数组、不许抛异常(Models 会把抛异常的 Provider 当成没有模型)。

用 createProvider 构建 Provider

不需要手写整个接口。createProvider() 从零件拼装一个 Provider:

typescript
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi>

CreateProviderOptions 的字段:

字段必填作用
id
是
注册键,与内置 Provider 的 id 不冲突
name
否
显示名,默认取 id
baseUrl
否
请求根地址
headers
否
默认请求头
auth
是
认证方法
models
是
静态基线模型列表
fetchModels
否
动态模型抓取函数
api
否
聊天实现,可以是单个也可以是按 model.api 分发的映射
images / classifiers
否
图片与分类器实现,按 model.api 索引

api 字段支持两种形态。单个实现接管全部聊天模型:

typescript
api: openAICompletionsApi()

映射形式按 model.api 分发:

typescript
const mixed = createProvider({
  id: "mixed-gateway",
  auth: { apiKey: envApiKeyAuth("练习服务", ["GATEWAY_API_KEY"]) },
  models: [model],
  api: {
    "openai-responses": openAIResponsesApi(),
    "openai-completions": openAICompletionsApi(),
  },
});

映射形式下,如果某个模型的 api 没有对应实现,调用会产生一个流错误。源码注释:

typescript
/**
 * Builds a provider from parts. Built-in provider factories and models.json
 * custom providers both go through this. A single `api` streams all chat
 * models; an `api` map dispatches on `model.api`, and a model whose api has
 * no entry produces a stream error. One-shot operation maps dispatch on
 * `model.api` the same way. At least one concrete implementation across
 * `api`/`images`/`classifiers` is required; empty maps are rejected.
 */

最后一句是硬约束:api、images、classifiers 三者至少要有一个非空实现,否则 createProvider() 直接抛错:

typescript
throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);

先从服务方确认三项信息

确认完整 API 基础地址、实际模型 ID,以及使用的协议。地址是否包含 /v1 以服务说明为准,不要在客户端和网关各追加一次。

Chat Completions 与 Responses 是不同协议。"兼容 OpenAI"不说明所有接口都可用。本篇明确选择 openai-completions;如果服务只实现 Responses,需要换成对应适配器,不能只更换 URL。

填写网关配置

建立 .env,以下内容中的地址、模型和凭据需要替换:

dotenv
GATEWAY_BASE_URL=https://your-gateway.example/v1
GATEWAY_MODEL=replace-with-model-id
GATEWAY_API_KEY=replace-with-your-key
CONTEXT_WINDOW=8192
MAX_OUTPUT_TOKENS=512

上下文窗口和输出限制是填写格式示意,必须按服务及实际部署配置调整。填得更大不会增加模型能力,只会让客户端记录与实际服务不一致。

创建兼容服务入口

新建 src/gateway.ts:

typescript
import { createModels, createProvider, envApiKeyAuth, type Model } from "@earendil-works/pi-ai"
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"
import { responseText } from "./connection.js"

const baseUrl = process.env.GATEWAY_BASE_URL
const id = process.env.GATEWAY_MODEL
if (!baseUrl || !id) {
  throw new Error("请设置 GATEWAY_BASE_URL 和 GATEWAY_MODEL")
}
const url = new URL(baseUrl)
if (!["http:", "https:"].includes(url.protocol)) {
  throw new Error("模型服务地址必须是 HTTP 或 HTTPS")
}
const contextWindow = Number(process.env.CONTEXT_WINDOW)
const maxTokens = Number(process.env.MAX_OUTPUT_TOKENS)
if (
  !Number.isInteger(contextWindow) ||
  !Number.isInteger(maxTokens) ||
  maxTokens <= 0 ||
  contextWindow < maxTokens
) {
  throw new Error("请按服务配置填写 CONTEXT_WINDOW 和 MAX_OUTPUT_TOKENS")
}
const model: Model<"openai-completions"> = {
  id,
  name: id,
  provider: "lesson-gateway",
  api: "openai-completions",
  baseUrl,
  reasoning: false,
  input: ["text"],
  contextWindow,
  maxTokens,
  // 示例不提供价格数据,零值不能用作真实账单。
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}
const gateway = createProvider({
  id: model.provider,
  auth: { apiKey: envApiKeyAuth("练习服务", ["GATEWAY_API_KEY"]) },
  models: [model],
  api: openAICompletionsApi(),
})
const models = createModels()
models.setProvider(gateway)
const result = await models.completeSimple(
  model,
  {
    messages: [{ role: "user", content: "用一句话解释函数。", timestamp: Date.now() }],
  },
  { signal: AbortSignal.timeout(30_000), maxTokens },
)
console.log(responseText(result))

运行:

bash
node --env-file=.env --import tsx src/gateway.ts

envApiKeyAuth() 是 Pi 提供的标准 API Key 认证实现。它的 resolve() 逻辑很简单:

typescript
resolve: async ({ ctx, credential, signal }) => {
	if (credential?.key) {
		return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
	}
	for (const envVar of envVars) {
		const value = await ctx.env(envVar);
		if (value) return { auth: { apiKey: value }, source: envVar };
	}
	return undefined;
}

已存储的凭据优先,其次按 envVars 顺序查环境变量,都拿不到就返回 undefined——未配置。返回 undefined 时,models.completeSimple() 会抛出 ModelsError,code 为 "auth",消息是 Provider is not configured: lesson-gateway。

这里的 cost 是零值占位,示例没有配置价格。输出费用若为零,不代表远程服务免费。客户端也没有宣称该模型支持图片或额外推理参数,先把能力限定在已经准备验证的文本路径。

认证通过 envApiKeyAuth() 接入。需要网关专用请求头时,可以在调用选项的 headers 中添加真实需要的字段;不要把缺少认证头的问题当成模型参数错误。

动态 Provider:让目录跟着服务走

有些服务的模型列表会变——自建的 llama.cpp 服务、OpenRouter 的实时列表。这类 Provider 用 fetchModels 声明动态抓取:

typescript
const dynamic = createProvider({
  id: "llamacpp",
  auth: { apiKey: envApiKeyAuth("本地服务", ["LLAMACPP_API_KEY"]) },
  models: [],
  fetchModels: async (context) => {
    if (!context.allowNetwork) return [];
    const response = await fetch(`${context.baseUrl}/v1/models`, { signal: context.signal });
    const payload = await response.json();
    return payload.data.map((entry: { id: string }) => ({
      id: entry.id,
      name: entry.id,
      provider: "llamacpp",
      api: "openai-completions",
      baseUrl: context.baseUrl ?? "",
      reasoning: false,
      input: ["text"],
      contextWindow: 32768,
      maxTokens: 4096,
      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    }));
  },
  api: openAICompletionsApi(),
});

动态 Provider 的读方法始终是同步的,抓取是一个显式的异步动作:

typescript
await models.refresh({ providers: ["llamacpp"] }); // 刷新指定 Provider
await models.refresh();                            // 并发刷新全部,最佳努力
const fresh = models.getModel("llamacpp", "qwen3-30b");

RefreshModelsContext 提供给 fetchModels 的字段:

字段类型含义
credential
Credential | undefined
解析后的凭据,OAuth 已刷新
stored
Readonly<ModelsStoreEntry>
本次刷新前的已存目录快照
publish
(pub) => Promise<boolean>
事务式发布目录变更
allowNetwork
boolean
离线初始化阶段为 false
force
boolean | undefined
跳过新鲜度检查,立即抓取
signal
AbortSignal
取消信号,始终存在
baseUrl
string | undefined
Provider 的基础地址

publish() 的返回值是"是否真的生效了"。它是生成数检查过的(generation-checked)发布:

typescript
export interface ModelsPublication {
	/** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */
	persist?: ModelsStoreEntry | null;
	/** Optional synchronous update of provider-private in-memory catalog state. */
	update?: () => void;
}

persist 省略表示不改存储,null 表示删除存储条目,update 是同步更新 Provider 私有内存状态的回调。publish() 内部会串行化同一个 Provider 的多次发布,并检查取消信号和生成数——被更新的刷新会被判定为过期,返回 false。

静态 Provider 调用 refresh() 是空操作。

连接本地 Ollama

先在本机准备并启动 Ollama,确认目标模型已经下载且可运行。本篇不展开模型安装和显存选型,模型 ID 使用你实际安装的名称。

本地服务配置可改为:

dotenv
GATEWAY_BASE_URL=http://localhost:11434/v1
GATEWAY_MODEL=replace-with-installed-model
GATEWAY_API_KEY=ollama
CONTEXT_WINDOW=8192
MAX_OUTPUT_TOKENS=512

这里同样需要按部署校准窗口与输出值。Ollama 官方的本地兼容示例使用占位 Key,客户端需要这个值而本地服务忽略它;这不能推广为其他网关也能忽略认证。

程序代码无需变化,再运行相同命令。文本路径通过后,用工具篇验证目标模型的工具能力;不能仅凭目录中声明了能力就省略实际检查。

兼容选项在哪里

Model 接口上有一个 compat 字段,专门用于 OpenAI 兼容 API 的行为覆盖:

typescript
export interface Model<TApi extends Api> extends BaseModel<TApi> {
	/**
	 * Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl.
	 */
	compat?: TApi extends "openai-completions"
		? OpenAICompletionsCompat
		: TApi extends "openai-responses" | "azure-openai-responses" | "openai-codex-responses"
			? OpenAIResponsesCompat
			: TApi extends "anthropic-messages"
				? AnthropicMessagesCompat
				: TApi extends "bedrock-converse-stream"
					? BedrockCompat
					: never;
}

不设置时由 baseUrl 自动推断。设置了就覆盖自动推断的结果。compat-schema.ts 里定义了每个 API 支持哪些兼容项——哪些字段服务接受、哪些要改名、哪些不支持工具调用。

如何判断兼容问题

找不到模型先查模型 ID;404 先核对地址和路径;认证错误先查网关要求;请求字段不支持则核对协议与兼容选项。不要一次关闭所有兼容字段,应该根据服务明确拒绝的字段缩小调整范围。

排查时有一份错误码表可用。ModelsError 的 code 有六个取值:

typescript
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
code含义常见触发
provider
Provider 层的问题
未知 Provider、能力未实现
auth
认证解析或凭据存储失败
Key 缺失、存储读写失败
oauth
OAuth 刷新失败
令牌过期且刷新被拒
stream
流建立阶段失败
协议不匹配、响应无法解析
model_source
模型目录来源失败
远端目录抓取异常
model_validation
模型数据本身不合法
目录条目字段缺失

自定义 Provider 最常遇到的是 provider 和 auth 两个码。provider 码的典型消息是 Provider lesson-gateway has no API implementation for "openai-completions"——这说明映射形式下漏配了对应 API 的实现。

本篇没有启动真实网关或本地推理服务。示例已做类型检查和模拟协议请求验证,实际服务仍需分别检查;本轮未与真实网关联调。

参考资料