场景指南
模型与通路
Provider + AIService 两条独立通路的架构与工作原理。
Downcity 的 AI 服务有 两条独立通路,共用同一个模型注册:
Provider.model({ id: "deepseek-v4-flash", ... })
│
ai.use(model)
│
┌─────────────────────┴─────────────────────┐
│ │
SDK 通路 OpenAI 兼容通路
给 User City / Agent CityModel 用 给 curl / OpenAI SDK / 兼容客户端用
│ │
POST /v1/ai/text POST /v1/ai/chat/completions
POST /v1/ai/stream ↑
│ OpenAI 格式 body
▼ { model, messages, stream }
Provider text action │
Provider stream action ▼
(ai-sdk 封装) Provider openai action
│ (透传 或 格式转换)
▼ │
UIMessage ▼
UIMessageStream Response
(上游原始响应)自动透传
当 Provider 不提供 openai action 但提供了 baseURL + envKey 时:
POST /v1/ai/chat/completions
body: { model: "deepseek-v4-flash", messages: [...], stream: true }
│
▼
resolve → model.actions.openai 不存在 → baseURL+envKey 存在
→ 自动透传
│
▼
fetch("https://api.deepseek.com/v1/chat/completions", {
body: JSON.stringify(originalBody), // 原样转发
})
│
▼
上游 Response 原样返回(SSE 或 JSON)格式转换
当 OpenAI 兼容上游需要自定义请求或响应处理时,可以写 openai action:
import { Provider, type Context, type AIProviderChargedResponse } from "@downcity/city";
class OpenAICustomProvider extends Provider {
constructor() {
super({
id: "openai-custom",
baseURL: "https://api.openai.com/v1",
envKey: "OPENAI_API_KEY",
});
}
async openai(ctx: Context): Promise<AIProviderChargedResponse> {
const body = customizeOpenAIBody(ctx.input);
const apiKey = ctx.env("OPENAI_API_KEY");
const response = await fetch(`${this.baseURL}/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
return ctx.input.stream ? { response: convertStream(response) } : { response: convertJSON(await response.json()) };
}
}
const openaiCustom = new OpenAICustomProvider();Provider 出账
Provider action 可以在正常输出旁边返回一条 charge,但更推荐把共享计费逻辑放到 provider 或 model 的 bill(ctx, output) 中。这样 provider 自己解析上游 usage 和价格,AIService / BalanceService 不需要理解 token、cache、reasoning 等 provider 私有字段。
当 AIService 传入 balance bridge 后,它会在真实消费模型的请求调用 Provider 前做默认余额检查。默认检查只拦截已经负余额的用户;实际扣费仍然在 Provider 成功后由返回的 charge line 完成。
import { Provider, buildAssistantMessage, type Context, type AIProviderChargedOutput } from "@downcity/city";
import type { UIMessage } from "ai";
const ai = new AIService({
balance,
});
class PricedProvider extends Provider {
constructor() {
super({ id: "priced-provider" });
}
protected bill(ctx: Context, output: unknown) {
return {
credits: priceUpstreamUsage(output?.metadata?.usage),
note: "priced-provider text",
metadata: {
provider_id: "priced-provider",
raw_usage: output?.metadata?.usage,
},
};
}
async text(ctx: Context): Promise<AIProviderChargedOutput<UIMessage>> {
const result = await callUpstream(ctx.input);
return buildAssistantMessage(result.text, ctx, {
finishReason: "stop",
usage: result.usage,
});
}
}
const provider = new PricedProvider();流式调用仍然可以从 action 返回一个 charge Promise,用于在上游 stream 结束后再 resolve 最终扣费:
return {
response: stream.toUIMessageStreamResponse(),
charge: stream.totalUsage.then((usage) => ({
credits: priceUpstreamUsage(usage),
note: "priced-provider stream",
})),
};SDK 调用
const client = new City({
role: "user",
federation_url,
city_id,
user_token,
});
const result = await client.ai.text({ prompt: "hello", model: "deepseek-v4-flash" });OpenAI 兼容调用
curl http://127.0.0.1:43127/v1/ai/chat/completions \
-H "Authorization: Bearer ub_xxx" \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hello"}],"stream":true}'