User City
产品侧如何读取模型目录,并调用 AIService 或自定义 Service。
User City 是给终端产品用的 SDK。
它绑定的是一个用户在某个产品下的调用上下文:
city_urltown_id,调用 AI service 或自定义 user action 时会自动注入user_token,调用 AI service 或用户鉴权 action 时需要
只调用登录、注册、webhook 这类免登录 action 时,可以只传 city_url。
你可以把它放在浏览器、扩展、App、桌面端,或者产品后端代用户调用 City。
先建立一个心智
对 User City 来说,最重要的不是方法名,而是它统一承载了三类产品调用:
- AI service:
client.ai.* - custom service:你自己注册到 City 的 service
- service:服务注册到 City 的 service,例如
accounts、usage、payment
所以从产品视角看,User City 不是“只用来调模型”,而是“用户态调用 City 的统一入口”。
最小示例
import { City } from "@downcity/city";
import type { UIMessageChunk } from "ai";
const client = new City({
role: "user",
city_url: "https://base.example.com",
town_id: "town_xxx",
user_token: "ub_xxx",
});
const catalog = await client.ai.listModels();
const result = await client.ai.text({
model: catalog.default(),
prompt: "写一段欢迎语",
});免登录 Action
const guest = new City({
role: "user",
city_url: "https://base.example.com",
});
const session = await guest.service("accounts").action("login").invoke<{
user_token: string;
user_id?: string;
email?: string;
}>({
email: "user@example.com",
password: "password123",
town_id: "town_xxx",
});拿到 session.user_token 后,再创建带 town_id + user_token 的 User City 调 AI service。
guest 调用和用户态调用的区别
可以把 User City 分成两个阶段理解:
guest 阶段
只传 city_url,适合免登录 Action:
accounts.registeraccounts.loginaccounts.oauth/startaccounts.oauth/result
用户态阶段
传 city_url + town_id + user_token,适合:
- AI service
- 需要用户身份的 custom service
- 需要用户身份的 service,例如
accounts.me
最常见的切换就是:
const guest = new City({
role: "user",
city_url,
});
const session = await guest.service("accounts").action("login").invoke<{
user_token: string;
}>({
email: "user@example.com",
password: "password123",
town_id: "town_xxx",
});
const user = new City({
role: "user",
city_url,
town_id: "town_xxx",
user_token: session.user_token,
});为什么先读 ai.listModels()
client.ai.listModels() 返回 ModelCatalog:
const catalog = await client.ai.listModels();
catalog.get("deepseek-v4-flash");
catalog.default();
catalog.all();
catalog.forModality("stream");推荐这样用:
const catalog = await client.ai.listModels();
const model = catalog.get("deepseek-v4-flash") ?? catalog.default();这样你的业务代码不会到处散落模型 ID 字符串。
ai.text()
const result = await client.ai.text({
model: catalog.default(),
prompt: "写一段欢迎语",
});ai.text() 固定返回 AI SDK UIMessage,也就是 UI 层可以直接保存和渲染的完整消息。
输入仍然是开放对象:
model是可选- 其他字段由
AIService命中的 Providertextaction 决定 - handler 返回值应该是一个
UIMessage
如果没有传 model,AIService 会按注册模型的默认顺序选择对应通路的默认模型。
如果你要调用自定义 service,并且返回结构不是 UIMessage,用 client.service(...).action(...).invoke<T>()。
ai.stream()
const body = await client.ai.stream({
model: catalog.get("deepseek-v4-flash"),
prompt: "流式输出一段文案",
});ai.stream() 固定返回 AI SDK UIMessageChunk 流:
const stream: ReadableStream<UIMessageChunk> = await client.ai.stream({
prompt: "流式输出一段文案",
});它不是 HTTP 原始字节流。SDK 会把 City 返回的 AI SDK UIMessage SSE body 解析成 chunk 对象。
你可以按 chunk 消费:
const reader = stream.getReader();
const first = await reader.read();City 侧的 stream handler 应该返回 AI SDK 的 createUIMessageStreamResponse() 或 streamText().toUIMessageStreamResponse() 结果。
如果你想要一次性 JSON 结果,应该用 text(),不是 stream()。
ai.image() / ai.video()
image() 和 video() 固定返回 AI SDK UIMessage。建议在 message 的 parts 里用 file part 表达图片或视频文件:
const imageMessage = await client.ai.image({
prompt: "一只站在雪地里的狐狸",
model: catalog.get("image-basic"),
ratio: "1:1",
count: 1,
});
const image = imageMessage.parts.find((part) => part.type === "file");
console.log(image?.mediaType, image?.url);也可以在对话或参考图场景使用 messages:
await client.ai.image({
model: "luchi-gpt-image-2",
messages: [
{
role: "user",
content: [
{ type: "text", text: "保持主体不变,换成白色影棚背景" },
{ type: "image", data_url: "data:image/png;base64,..." },
],
},
],
});image() 的底层约定很简单:client 只发送 prompt / messages / model / size / ratio / quality / count / provider_options 这些输入;City 侧 Provider 负责把 Luchi 异步 Job、OpenAI/302.ai images API、Gemini generateContent 等上游格式归一成 AI SDK UIMessage file parts。
City 侧的 Provider image / video action 也应该返回 UIMessage。
ai.tts() / ai.asr()
tts() 和 asr() 仍然保留开放返回类型,因为语音输入输出在不同产品里的传输方式差异更大:
await client.ai.tts({
text: "你好",
voice: "alloy",
});如果你需要更严格的返回结构,可以用自定义 service 包一层 action。
Service 列表
client.listServices() 返回当前 City 注册的 service 摘要:
const services = await client.listServices();
services[0];
// {
// id: "ai",
// name: "AI",
// env: []
// }这适合做动态菜单、调试面板,或者让产品侧按配置发现当前可调用的 service。
custom service 和 service
对 User City 来说,这两类 service 的调用方式完全一样。
custom service
这是你自己在 City 里注册的 service:
const rewritten = await client
.service("rewrite")
.action("formal")
.invoke<{ text: string }>({
prompt: "把这段话改得更专业",
});service
这是 service 带进 City 的 service:
const me = await client.service("accounts").get("me");
const usage = await client.service("usage").get("me");payment
如果你希望按“支付方式”心智调用,而不是手写具体 service + action,直接用:
const methods = await client.payment.methods();
const checkout = await client.payment.method("stripe").invoke({
topup_id: "topup_demo",
});其中:
client.payment.methods()对应GET /v1/payment/methodsclient.payment.method("stripe").invoke(...)会先读取 method 定义,再自动转发到具体 service,例如payment.stripe/checkout/create
你不需要为 service 学一套新协议。只需要记住:
- 来源不同
- 调法相同
- 最终都走 City 里的统一
/v1/*路由空间
常见 service 例子
accounts
const session = await guest.service("accounts").action("login").invoke({
email: "user@example.com",
password: "password123",
town_id: "town_xxx",
});usage
const usage = await client.service("usage").get("me");payment
const methods = await client.payment.methods();
const checkout = await client.payment.method("stripe").invoke({
topup_id: "topup_demo",
});什么时候应该改用 AI service
如果你要的是模型能力本身,优先用:
client.ai.text()client.ai.stream()client.ai.image()
如果你要的是某个业务动作,再用:
client.service(...).action(...).invoke()client.service(...).get(...)
自定义 Service
如果你要调用自己注册的 Service,先拿到 service 调用器,再指定 action:
const result = await client
.service("rewrite")
.action("formal")
.invoke<{ text: string }>({
prompt: "把这段话改得更专业",
});这适合:
- 前端按配置动态切换 service
- 你定义了自定义 service,不想为它单独再包一层方法
GET Action
对于 method: "GET" 的 action,用 get() 传 query:
const result = await client.service("accounts").get("oauth/result", {
state: "oauth_state_xxx",
});常见错误
User City 收到非 2xx HTTP 响应时会抛出 Error。这个错误会带两个额外字段:
status:HTTP 状态码。body:City 返回的原始响应文本,通常是{"error":"..."}。
try {
await client.ai.text({
model: "gpt-5.4",
prompt: "你好",
});
} catch (error) {
const status = error instanceof Error && "status" in error ? error.status : undefined;
const body = error instanceof Error && "body" in error ? error.body : undefined;
console.log(status, body);
}client.ai.stream() 有两个失败阶段:HTTP 状态不是 2xx 时和其他方法一样抛带 status/body 的错误;如果 HTTP 已经成功但响应体为空,或不是 AI SDK UIMessage stream,解析阶段会抛普通解析错误。
401 / 403
通常是这些原因:
user_token缺失- token 已过期
- token 签名无效
- 请求里的
town_id与user_token绑定的 town 不一致
422
通常是这些原因:
query.model最终为空- 请求里传了不存在的 model
- 当前 model 没有对应 modality 的 action
什么时候不要用 User City
这些动作不应该交给 User City:
- 创建 town
- 签发
user_token - 修改 Runtime env
- 维护生产环境 provider key
- 暂停或恢复 town
这些都属于可信环境动作,应该交给 Admin City 或你自己的后端。