User City
产品侧如何读取模型目录,并调用 AIService 或自定义 Service。
User City 是给终端产品用的 SDK。
它绑定的是一个用户在某个产品下的调用上下文:
federation_urlcity_id,调用普通 user service action 时会自动注入user_token,调用 AI service 或用户鉴权 action 时需要
只调用登录、注册、webhook 这类免登录 action 时,可以只传 federation_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",
federation_url: "https://base.example.com",
city_id: "city_xxx",
user_token: "ub_xxx",
});
const catalog = await client.ai.listModels();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");
const result = await client.ai.text({
model,
prompt: "写一段欢迎语",
});免登录 Action
const guest = new City({
role: "user",
federation_url: "https://base.example.com",
});
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
city_id: "city_xxx",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});拿到 session.user_token 后,AI service 可以直接用 user_token 调用。需要让 User City 自动给普通 service action 注入 city_id 时,再同时传入 city_id。
guest 调用和用户态调用的区别
可以把 User City 分成两个阶段理解:
guest 阶段
只传 federation_url,适合免登录 Action:
accounts.registeraccounts.login/startaccounts.login/continueaccounts.login/result
用户态阶段
传 federation_url + city_id + user_token,适合:
- AI service
- 需要用户身份的 custom service
- 需要用户身份的 service,例如
accounts.me
最常见的切换就是:
const guest = new City({
role: "user",
federation_url,
});
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
city_id: "city_xxx",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});
const user = new City({
role: "user",
federation_url,
city_id: "city_xxx",
user_token: session.user_token,
});为什么先读 ai.listModels()
client.ai.listModels() 返回 ModelCatalog:
const catalog = await client.ai.listModels();
catalog.get("deepseek-v4-flash");
catalog.all();
catalog.forModality("stream");推荐这样用:
const catalog = await client.ai.listModels();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");这样你的业务代码不会到处散落模型 ID 字符串。
模型支持推理强度时,目录会同时返回可选档位:
const model = catalog.get("gpt-5.6-sol");
if (!model) throw new Error("model not found");
const efforts = model?.reasoning?.efforts ?? [];
await client.ai.text({
model,
prompt: "分析这个问题",
reasoning_effort: "high",
});reasoning_effort 必须使用目录中的档位 ID。省略时由模型的 default_effort 或上游默认行为决定。
ai.text()
const result = await client.ai.text({
model: catalog.get("deepseek-v4-flash"),
prompt: "写一段欢迎语",
});ai.text() 固定返回 AI SDK UIMessage,也就是 UI 层可以直接保存和渲染的完整消息。
输入仍然是开放对象:
model必填;AIService 不会自动选择默认模型reasoning_effort可选,必须来自当前模型目录声明的推理档位- 其他字段由
AIService命中的 Providertextaction 决定 - handler 返回值应该是一个
UIMessage
如果你要调用自定义 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_create() / ai.image_result() / ai.video()
图片生成是任务 API:先用 image_create() 创建任务,再用 image_result() 查询状态。成功后 result 是 AI SDK UIMessage,建议在 message 的 parts 里用 file part 表达图片文件:
const job = await client.ai.image_create({
prompt: "一只站在雪地里的狐狸",
model: catalog.get("image-basic"),
ratio: "1:1",
count: 1,
});
const current = await client.ai.image_result({ job_id: job.job_id });
if (current.status === "succeeded") {
const image = current.result?.parts.find((part) => part.type === "file");
console.log(image?.mediaType, image?.url);
}生成图片的 file part url 会按具体 Provider 返回的内容原样返回。Provider 可以返回 HTTPS URL、R2/resource URL,或 data:image/...;base64,... data URL。
image_create() 会在任务提交后立即返回。AIService 会调度后台 image/fetch queue 任务查询上游状态,并把最新状态写入内置 async_jobs 表。image_result() 只读取这个缓存状态。
也可以在对话或参考图场景使用 messages:
const job = await client.ai.image_create({
model: "openai-image-basic",
messages: [
{
role: "user",
content: [
{ type: "text", text: "保持主体不变,换成白色影棚背景" },
{ type: "image", data_url: "data:image/png;base64,..." },
],
},
],
});图片任务的底层约定很简单:client 只发送 prompt / messages / model / size / ratio / quality / count / provider_options 这些输入;City 侧 Provider 创建上游任务并实现 image_fetch() 查询上游状态,AIService 负责 Queue 调度和把 Downcity 任务状态存入 async_jobs。
video() 仍然固定返回 AI SDK UIMessage。City 侧的 Provider 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 定义,再自动转发到payment/checkout/create,并带上method_id: "stripe"
你不需要为 service 学一套新协议。只需要记住:
- 来源不同
- 调法相同
- 最终都走 City 里的统一
/v1/*路由空间
常见 service 例子
accounts
const login = await guest.service("accounts").action("login/start").invoke({
provider: "email",
city_id: "city_xxx",
});
await guest.service("accounts").action("login/continue").invoke({
login_id: login.login_id,
input: {
email: "[email protected]",
password: "password123",
},
});
const session = await guest.service("accounts").get("login/result", {
login_id: login.login_id,
});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_create()/client.ai.image_result()
如果你要的是某个业务动作,再用:
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("login/result", {
login_id: "login_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 签名无效
- 请求里的
city_id与user_token绑定的 city 不一致
422
通常是这些原因:
query.model最终为空- 请求里传了不存在的 model
- 当前 model 没有对应 modality 的 action
什么时候不要用 User City
这些动作不应该交给 User City:
- 创建 city
- 签发
user_token - 修改 Runtime env
- 维护生产环境 provider key
- 暂停或恢复 city
这些都属于可信环境动作,应该交给 Admin City 或你自己的后端。