参考

User City

产品侧如何读取模型目录,并调用 AIService 或自定义 Service。

User City 是给终端产品用的 SDK。

它绑定的是一个用户在某个产品下的调用上下文:

  • federation_url
  • city_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,例如 accountsusagepayment

所以从产品视角看,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.register
  • accounts.login/start
  • accounts.login/continue
  • accounts.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 命中的 Provider text action 决定
  • 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/methods
  • client.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_iduser_token 绑定的 city 不一致

422

通常是这些原因:

  • query.model 最终为空
  • 请求里传了不存在的 model
  • 当前 model 没有对应 modality 的 action

什么时候不要用 User City

这些动作不应该交给 User City

  • 创建 city
  • 签发 user_token
  • 修改 Runtime env
  • 维护生产环境 provider key
  • 暂停或恢复 city

这些都属于可信环境动作,应该交给 Admin City 或你自己的后端。