接入自定义接口与本地模型
看清 Models 与 Provider 的抽象边界,为兼容接口和本地服务构建自己的 Provider,并复用同一段调用代码。
接入自定义接口与本地模型
有些服务给你的是一个兼容接口地址,而不是当前模型层已内置的 Provider。还有些模型运行在自己的机器上。它们都需要明确的地址、模型 ID 和协议配置,不能只修改模型名称。
本篇用兼容 Chat Completions 的接口作为主线。项目沿用模型配置中的依赖和 connection.ts,但单独创建一个 Provider,不修改内置服务的行为。
三层抽象各管什么
模型层的接口分三层,边界很清楚:
Models 与 MutableModels 的关系是继承,不是并列:
export interface MutableModels extends Models {
setProvider(provider: Provider): void;
deleteProvider(id: string): void;
clearProviders(): void;
}MutableModels 拥有 Models 的全部读能力。如果一个函数签名写的是 Models,它照样能接收 MutableModels——这正是 createModels() 的返回值可以直接传给 Agent 的 streamFn 的原因。
Models 接口上的读方法分三组,源码注释写得很明确:
/**
* 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 时唯一需要读懂的东西:
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 的作用在源码注释里:
/**
* `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() 的契约在注释里被强调了:
/**
* 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:
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi>CreateProviderOptions 的字段:
api 字段支持两种形态。单个实现接管全部聊天模型:
api: openAICompletionsApi()映射形式按 model.api 分发:
const mixed = createProvider({
id: "mixed-gateway",
auth: { apiKey: envApiKeyAuth("练习服务", ["GATEWAY_API_KEY"]) },
models: [model],
api: {
"openai-responses": openAIResponsesApi(),
"openai-completions": openAICompletionsApi(),
},
});映射形式下,如果某个模型的 api 没有对应实现,调用会产生一个流错误。源码注释:
/**
* 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() 直接抛错:
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,以下内容中的地址、模型和凭据需要替换:
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:
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))运行:
node --env-file=.env --import tsx src/gateway.tsenvApiKeyAuth() 是 Pi 提供的标准 API Key 认证实现。它的 resolve() 逻辑很简单:
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 声明动态抓取:
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 的读方法始终是同步的,抓取是一个显式的异步动作:
await models.refresh({ providers: ["llamacpp"] }); // 刷新指定 Provider
await models.refresh(); // 并发刷新全部,最佳努力
const fresh = models.getModel("llamacpp", "qwen3-30b");RefreshModelsContext 提供给 fetchModels 的字段:
publish() 的返回值是"是否真的生效了"。它是生成数检查过的(generation-checked)发布:
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 使用你实际安装的名称。
本地服务配置可改为:
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 的行为覆盖:
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 有六个取值:
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";自定义 Provider 最常遇到的是 provider 和 auth 两个码。provider 码的典型消息是 Provider lesson-gateway has no API implementation for "openai-completions"——这说明映射形式下漏配了对应 API 的实现。
本篇没有启动真实网关或本地推理服务。示例已做类型检查和模拟协议请求验证,实际服务仍需分别检查;本轮未与真实网关联调。