用 discriminated union 将各提供商 wire format 映射为统一领域类型,消除 optional 三重嵌套的 domain 污染。
给模型客户端加类型不是为了自动补全。真正价值在于: discriminated union 让编译器穷举出 provider 可能返回给你的每一种情况——text、tool call、refusal、filtered、truncated、error——一旦你漏掉其中任何一种,编译就会失败。
Provider 的 SDK 都有类型,通常还做得不错。问题在于这些类型描述的是该 provider 的** wire format**(线路格式),一旦你的应用要对接两个 provider,或者在一个 gateway 前面挂多个 provider,你的领域逻辑就变成了和最初那个 provider 绑定的样子。之后每加一个 provider,都是从外部硬生生拧上去的一层翻译代码。
第二个问题更隐蔽。Wire type 出于必要性必须是宽松的——几乎每个字段都是可选的,因为同一个对象 shape 要承载流式增量、最终消息和工具调用。所以 choices[0].message.content 类型是 string | null | undefined,而每个调用点都要处理三种情况,但领域模型其实只有一种含义。这种宽松对 wire type 来说是正确的,对 domain type 来说就是错误的。
解决办法是建立一个小型的内部模型来描述你的应用处理什么,在边缘处放适配器。大概两百行代码,但这正是"一下午加一个 provider"和"一个 sprint 才能加一个 provider"的差距。
// types.ts
/* A message is one of four things, and the discriminant is `role`.
Writing it this way rather than as one optional-heavy object means the
compiler knows a tool message has a tool_call_id and a user message does
not, instead of both having "maybe". */
export type SystemMessage = { role: "system"; content: string };
export type UserMessage = {
role: "user";
content: string | ContentPart[]; // multimodal is an array, text is not
};
export type AssistantMessage = {
role: "assistant";
content: string | null; // null when the turn is only tool calls
toolCalls?: ToolCall[];
};
export type ToolMessage = {
role: "tool";
toolCallId: string; // required here, absent elsewhere
content: string;
};
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolMessage;
export type ContentPart =
| { type: "text"; text: string }
| { type: "image"; url: string; detail?: "low" | "high" | "auto" };
export type ToolCall = {
id: string;
name: string;
/* Deliberately `unknown`, not `any` and not a typed shape. The provider
sends a JSON string the model produced; nothing has validated it against
your tool's schema yet. Forcing the caller to parse before use is the
entire point, and it is where a Zod schema goes. */
arguments: unknown;
};
arguments: unknown 防止了一整类生产事故。在那里放一个 typed shape 是撒谎——模型生成了那段 JSON,但还没有任何东西根据你的 tool schema 验证过它。而 any 则会让调用方直接读 args.userId 然后传给数据库查询。unknown 强制你先解析,而你已有的 Zod schema 正是做这件事的天然选择。安全相关的论述详见 secure tool calls。
抛出的异常不携带类型信息。catch (err) 给你的是 unknown,每个调用方都要重新构造一套 instanceof 加字符串匹配的逻辑来判断是重试、降级还是向用户展示失败。改用返回 union,决策就变成一个编译器会检查的 switch。
export type Failure =
| { kind: "auth"; status: 401 | 403; message: string }
| { kind: "rate_limit"; retryAfterMs: number | null }
| { kind: "insufficient_credit"; message: string }
| { kind: "context_length"; limit: number | null; sent: number | null }
| { kind: "content_filter"; stage: "input" | "output" }
| { kind: "timeout"; afterMs: number }
| { kind: "network"; cause: string }
| { kind: "provider"; status: number; body: string }
| { kind: "aborted" };
export type Completion = {
text: string;
toolCalls: ToolCall[];
finish: "stop" | "length" | "tool_calls" | "content_filter";
usage: { promptTokens: number; completionTokens: number } | null;
model: string; // what actually served it, after any fallback
};
export type Result =
| { ok: true; completion: Completion }
| { ok: false; failure: Failure };
这个 union 是重试策略的所在,只需写一次,而不是在每个调用点重复。rate_limit 和 network 是可重试的;timeout 可以重试一次但 budget 要更长;auth、insufficient_credit 和 context_length 完全不可重试,重试只会消耗剩余额度。把 rate_limit 和 insufficient_credit 分开尤为重要,因为 provider 把这两种情况都发成 HTTP 429——光看状态码分不出来,而其中一种无论怎么重试都不会成功。
// exhaustive.ts
export function assertNever(x: never): never {
throw new Error("unhandled variant: " + JSON.stringify(x));
}
// ui.ts
import type { Failure } from "./types";
import { assertNever } from "./exhaustive";
export function messageFor(failure: Failure): string {
switch (failure.kind) {
case "auth":
return "The API key was rejected. Check the key and its permissions.";
case "rate_limit":
return failure.retryAfterMs
? "Rate limited. Try again in " + Math.ceil(failure.retryAfterMs / 1000) + "s."
: "Rate limited. Try again shortly.";
case "insufficient_credit":
return "The account is out of credit.";
case "context_length":
return failure.limit
? "Too long: " + failure.sent + " tokens sent, limit " + failure.limit + "."
: "The conversation is too long for this model.";
case "content_filter":
return failure.stage === "input"
? "That request was blocked before it reached the model."
: "The answer was blocked by a safety filter.";
case "timeout":
return "No response after " + Math.round(failure.afterMs / 1000) + "s.";
case "network":
return "Could not reach the provider.";
case "provider":
return "The provider returned an error (" + failure.status + ").";
case "aborted":
return "Cancelled.";
default:
return assertNever(failure); // <- compile error if a variant is added
}
}
往 Failure 加第十个变体,这整个文件就会停止编译,错误直接指向那一行。这和测试提供的保证不在一个级别:测试只有在有人写了测试的情况下才能捕获它,而编译器是在代码库里每一个 switch 语句处捕获它,而且不需要任何人记住这件事。把错误建模成 union 而不是字符串,是这套做法最强的单一理由。
// client.ts
import type { Message, Result, Failure } from "./types";
export type ClientOptions = {
baseUrl?: string;
apiKey: string;
defaultModel: string;
timeoutMs?: number;
};
export type CompleteOptions = {
model?: string;
maxTokens?: number;
temperature?: number;
signal?: AbortSignal;
};
export function createClient(opts: ClientOptions) {
const baseUrl = opts.baseUrl ?? "https://api.multigrid.ai/v1";
const timeoutMs = opts.timeoutMs ?? 60_000;
async function complete(
messages: Message[],
o: CompleteOptions = {},
): Promise<Result> {
const signal = o.signal
? AbortSignal.any([o.signal, AbortSignal.timeout(timeoutMs)])
: AbortSignal.timeout(timeoutMs);
let res: Response;
try {
res = await fetch(baseUrl + "/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + opts.apiKey,
},
body: JSON.stringify({
model: o.model ?? opts.defaultModel,
messages: messages.map(toWire),
max_tokens: o.maxTokens ?? 1024,
temperature: o.temperature,
}),
signal,
});
} catch (err) {
return { ok: false, failure: classifyThrown(err, timeoutMs) };
}
if (!res.ok) return { ok: false, failure: await classifyResponse(res) };
const data = await res.json();
const choice = data.choices?.[0];
return {
ok: true,
completion: {
text: choice?.message?.content ?? "",
toolCalls: (choice?.message?.tool_calls ?? []).map((t: any) => ({
id: t.id,
name: t.function?.name ?? "",
arguments: safeJson(t.function?.arguments),
})),
finish: choice?.finish_reason ?? "stop",
usage: data.usage
? {
promptTokens: data.usage.prompt_tokens,
completionTokens: data.usage.completion_tokens,
}
: null,
model: data.model ?? o.model ?? opts.defaultModel,
},
};
}
return { complete };
}
function safeJson(raw: unknown): unknown {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
return { __unparsed: raw }; // never throw on the model's own output
}
}
function classifyThrown(err: unknown, timeoutMs: number): Failure {
const e = err as { name?: string; message?: string };
if (e?.name === "AbortError") return { kind: "aborted" };
if (e?.name === "TimeoutError") return { kind: "timeout", afterMs: timeoutMs };
return { kind: "network", cause: e?.message ?? String(err) };
}
async function classifyResponse(res: Response): Promise<Failure> {
const body = await res.text().catch(() => "");
const lower = body.toLowerCase();
if (res.status === 401 || res.status === 403) {
return { kind: "auth", status: res.status, message: body.slice(0, 300) };
}
if (res.status === 429) {
// Two different conditions wear this status. Read the body, not the code.
if (lower.includes("credit") || lower.includes("balance") || lower.includes("quota")) {
return { kind: "insufficient_credit", message: body.slice(0, 300) };
}
const header = res.headers.get("retry-after");
return { kind: "rate_limit", retryAfterMs: header ? Number(header) * 1000 : null };
}
if (lower.includes("context length") || lower.includes("maximum context")) {
return { kind: "context_length", limit: null, sent: null };
}
if (lower.includes("content_filter") || lower.includes("content policy")) {
return { kind: "content_filter", stage: "input" };
}
return { kind: "provider", status: res.status, body: body.slice(0, 500) };
}
function toWire(m: Message): Record<string, unknown> {
switch (m.role) {
case "tool":
return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
case "assistant":
return {
role: "assistant",
content: m.content,
...(m.toolCalls?.
在 classifyResponse 里用字符串匹配错误体不够优雅,但这是诚实的:provider 们对错误码并没有达成一致,所以必须有人做这个翻译工作。把翻译集中在一个返回 typed value 的函数里,比在十二个调用点各自隐式地做这件事要好得多。标准化 API 错误是这类问题的通用版本。
对多个 provider 建立统一抽象是值得的,但并不是免费的。有四样东西在这个抽象层里存留不住,装作它们能存住正是这些客户端最终变得比它们所替代的 SDK 更差的原因。
Sampling parameters. temperature 和 top_p 几乎是通用的。Frequency penalty、presence penalty、repetition penalty、min-p、logit bias 和 seeds 则不是,而且它们存在的范围内取值范围也各不相同。把前两个作为一等公民选项暴露出去,其余的都通过一个指名 provider 的 passthrough 传过去。
Reasoning controls. Reasoning 模型需要 effort 或 budget 参数,不同 provider 之间名称不同、语义也不同,而且它们的 token 收费方式也不一样。不要把这些压平成一个选项;它们的含义确实不同。
Structured output. Schema 约束解码在不同名称下存在,支持的 JSON Schema 子集也不同。做特性检测,降解到 prompt-plus-validate,而不是直接假设它能用。
Caching. 一些 provider 自动缓存前缀,一些需要在请求里显式加标记。这个区别在统一类型里是看不见的,但在账单里非常显眼——参见各 provider 之间 prompt caching 的差异。
上面四个泄漏是论证"标准化应该放在你的应用之外"的理由:一个已经用一种请求 shape 对接多个 provider 的 gateway 已经吸收了这些问题,而且 Multigrid 会报告实际是哪个模型服务了请求,所以 Completion 类型上的 model 字段是事实而不是你所请求内容的回声。
保持诚实的逃生舱是 providerOptions?: Record<string, unknown>,由适配器合并到请求体中。它故意不加类型:它标记了你已经离开了公共模型的边界,而标记它比假装差异不存在、或者把共享类型膨胀成每个 provider 特性总和的 union 要好。
Your First LLM Call in TypeScript
Node Streams, Web Streams and SSE in One Model