统一模型接口让业务更容易复用,实际兼容性仍取决于协议、鉴权、模型能力和输出验证。ChatCrystal 的固定实现使用 AI SDK 7.0.97;本文核对当前工厂与调用路径,再通过完全替换 fetch 的样本观察协议选择,避免把“OpenAI 兼容”当作所有端点都兼容。
当前注册表有七项 Provider
providers.ts使用 Map 注册工厂,接口还包含 supportsModelDiscovery。以下是这份实现的能力表,不是对服务商全部未来产品能力的断言。
| 注册名 | 语言模型路径 | Embedding 工厂 | 发现模型 / Base URL 要求 |
|---|---|---|---|
| ollama | createOpenAI(…).chat(model) | 有 | 支持 / 要求,工厂有本地默认值 |
| openai | createOpenAI(…)(model) | 有 | 支持 / 可选 |
| orcarouter | 固定网关上的 createOpenAI(…)(model) | 无 | 支持 / 不要求,忽略传入 baseURL |
| anthropic | createAnthropic(…)(model) | 无 | 支持 / 不要求 |
| createGoogle(…)(model) | 有 | 支持 / 不要求 | |
| azure | createAzure(…)(model) | 有 | 不支持 / 要求 |
| custom | createOpenAI(…)(model) | 有 | 支持 / 要求 |
除 ollama 外,注册元数据都要求 API Key。Ollama 工厂传占位 key,去除 URL 末尾斜杠后追加 /v1;这并不能证明任意代理、鉴权设置或已带 /v1 的地址都正确。Anthropic 与 OrcaRouter 在本项目没有 Embedding 工厂,不应概括为服务商永远不可能提供相应能力。
同一个 SDK 工厂也可能选择不同协议
getLanguageModel读取 appConfig.llm;embedding.ts的私有 getEmbeddingModel 读取另一份配置,缺少工厂时抛错。语言和 Embedding Provider 可以分别设置,仍需检查模型对应能力与索引兼容性。
固定的 @ai-sdk/openai 4.0.65 中,直接 provider(model) 使用 Responses 路径,显式 .chat(model) 使用 Chat Completions。当前 Ollama 选后者,OpenAI、OrcaRouter 与 Custom 选前者。一个只实现 /chat/completions 的兼容服务,并未因此证明能接受 Custom 的 /responses 请求。
这些能力标志和工厂类型不代表已经完成端点可达、真实鉴权、结构化输出或指定模型能力验证。未知 Provider 会由 getProvider 抛错;返回模型对象也还没有证明一次真实请求能成功。
当前结构化摘要调用与原稿接口不同
summarize.ts使用 generateText、Output.object 和 SummarizeSchema,传 instructions、prompt、maxOutputTokens=4096、显式 maxRetries=3,随后把 tags 转小写。这里没有原稿示例中的 generateObject 调用。
schema 的 describe 提供自然语言说明,不等于实际的 max、min 或数量约束;例如“标题20字以内”没有对应 z.string().max(20)。结构化输出通过也不证明总结事实正确。结构化 JSON 提示进一步区分 schema 与语义验收。
AI SDK 的generateText 文档说明 maxRetries 的默认值是 2。项目显式设置的 3 不能写成 SDK 默认三次重试,更不能概括为任何 schema 错误都会重新生成。SDK 请求重试和外层任务队列重试还可能叠加,分别核对可重试错误和次数。
无网络验证三种路径与一次 schema 失败
独立目录固定安装 npm install --ignore-scripts --save-exact ai@7.0.97 @ai-sdk/openai@4.0.65 zod@4.6.2,保存 lesson.mjs,Node 24+ 运行。fetch 完全替换为本地 Response 构造器,API Key 与模型名都是 fixture,不访问任何真实模型或网络端点。
import assert from 'node:assert/strict';
import { createOpenAI } from '@ai-sdk/openai';
import { embed, generateText, Output } from 'ai';
import { z } from 'zod';
const paths = [];
let content = JSON.stringify({ title: 'fixture' });
const provider = createOpenAI({
baseURL: 'https://fixture.invalid/v1',
apiKey: 'fixture-only',
fetch: async (input) => {
const path = new URL(String(input)).pathname;
paths.push(path);
let body;
if (path.endsWith('/embeddings')) {
body = {
object: 'list',
data: [{ object: 'embedding', index: 0, embedding: [1, 0] }],
model: 'fixture',
usage: { prompt_tokens: 1, total_tokens: 1 },
};
} else if (path.endsWith('/chat/completions')) {
body = {
id: 'chat_fixture',
created: 1,
model: 'fixture',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
} else {
body = {
id: 'resp_fixture',
created_at: 1,
model: 'fixture',
output: [
{
type: 'message',
id: 'msg_fixture',
role: 'assistant',
content: [{ type: 'output_text', text: content, annotations: [] }],
},
],
usage: { input_tokens: 1, output_tokens: 1 },
};
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
});
const output = Output.object({ schema: z.object({ title: z.string() }) });
const responses = await generateText({
model: provider('fixture'),
output,
prompt: 'fixture',
maxRetries: 0,
});
const chat = await generateText({
model: provider.chat('fixture'),
output,
prompt: 'fixture',
maxRetries: 0,
});
const vector = await embed({
model: provider.embeddingModel('fixture'),
value: 'fixture',
maxRetries: 0,
});
assert.deepEqual(responses.output, { title: 'fixture' });
assert.deepEqual(chat.output, { title: 'fixture' });
assert.deepEqual(vector.embedding, [1, 0]);
assert.deepEqual(paths, ['/v1/responses', '/v1/chat/completions', '/v1/embeddings']);
content = JSON.stringify({ title: 42 });
const before = paths.length;
await assert.rejects(
async () => {
const invalid = await generateText({
model: provider('fixture'),
output,
prompt: 'fixture',
maxRetries: 3,
});
return invalid.output;
},
(error) => error.name === 'AI_NoObjectGeneratedError'
);
assert.equal(paths.length - before, 1);
console.log('protocol selection and schema rejection pass without network');
三次正常调用观察到 /responses、/chat/completions、/embeddings;结构化正文和固定向量与期望相同。后续 HTTP 200 返回 title 数字,schema 要求字符串,即使 maxRetries=3,也在一次 mock 请求后拒绝为 AI_NoObjectGeneratedError。该反例验证这个指定错误,不代表所有错误都不可重试。
把 .chat 换成直接 provider 调用,路径断言失败;把 title schema 改成接受任意值,拒绝断言失败。恢复后应通过。这个 mock 只证明客户端的协议和验证行为,没有证明外部服务真的支持它们。
更换 Provider 的验收清单要落到能力
检查最终请求 URL、请求体和鉴权头;分别试普通文本、所需结构化 schema、Embedding、异常状态、无效输出、限流和取消。只有真实端点测试才能得出服务兼容结论;本篇没有成本、排名、质量或耗时比较。
maxInputChars 在当前配置默认 32000,是字符预算而非模型 token 上限。prepareTranscript按字符预算选取回合并标记省略:不超过三回合的路径会截断;多回合路径保留首回合和末两回合,最终拼接没有再次硬截断,因此不保证严格字符上限,也不能证明适配任意模型的上下文窗口。更换 Embedding 模型即使维度相同,也可能换了向量空间,应验收索引重建与持久化状态,不能用一次模型对象创建替代迁移完成。