参考

User City

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

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

City 是旧的底层用户客户端。新代码应使用 @downcity/federationEmbassy.user;本页只保留旧方法面的迁移参考。

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

  • federation_url
  • bureau_id 由 Federation 从 user_token 解析,普通 service action 不重复传入
  • user_token,调用 AI service 或用户鉴权 action 时需要

只调用登录、注册、webhook 这类免登录 action 时,可以只传 federation_url

你可以把它放在浏览器、扩展、App、桌面端,或者产品后端代用户调用 City。

先建立一个心智

User City 来说,最重要的不是方法名,而是它统一承载了三类产品调用:

  • AI service:city.ai.*
  • custom service:你自己注册到 City 的 service
  • service:服务注册到 City 的 service,例如 accountsusagepayment

所以从产品视角看,User City 不是“只用来调模型”,而是“用户态调用 City 的统一入口”。

最小示例

import { City } from "@downcity/federation/legacy";
import type { UIMessageChunk } from "ai";

const city = new City({
  federation_url: "https://base.example.com",
  user_token: "ub_xxx",
});

const catalog = await city.ai.catalog();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");

const result = await city.ai.text({
  model,
  prompt: "写一段欢迎语",
});

免登录 Action

const guest = new City({
  federation_url: "https://base.example.com",
});

const login = await guest.service("accounts").action("login/start").invoke({
  provider: "email",
  bureau_id: "product_id",
});

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 后,只需用 user_token 创建 User City。普通 service action 的 bureau_id 由 Federation 验证 Token 后解析,客户端不重复传入。

guest 调用和用户态调用的区别

可以把 User City 分成两个阶段理解:

guest 阶段

只传 federation_url,适合免登录 Action:

  • accounts.register
  • accounts.login/start
  • accounts.login/continue
  • accounts.login/result

用户态阶段

federation_url + user_token,适合:

  • AI service
  • 需要用户身份的 custom service
  • 需要用户身份的 service,例如 accounts.me

最常见的切换就是:

const guest = new City({
  federation_url,
});

const login = await guest.service("accounts").action("login/start").invoke({
  provider: "email",
  bureau_id: "product_id",
});

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({
  federation_url,
  user_token: session.user_token,
});

为什么先读 ai.catalog()

city.ai.catalog() 返回 ModelCatalog

const catalog = await city.ai.catalog();

catalog.get("deepseek-v4-flash");
catalog.all();
catalog.forModality("stream");

推荐这样用:

const catalog = await city.ai.catalog();
const model = catalog.get("deepseek-v4-flash");
if (!model) throw new Error("model not found");

这样你的业务代码不会到处散落模型 ID 字符串。

模型目录中的 model.price 是 AIChannel 提供的价格展示文案列表;它只用于展示, 不代表最终扣费结果。

目录中的 CityModel 本身就是 AI SDK LanguageModelV3,可以直接交给 Agent 或 streamText()

const result = streamText({
  model,
  prompt: "分析这个问题",
});

调用会进入 Federation 原生模型通路;Agent 不会再把它转换成 OpenAI-compatible 模型。

模型支持推理强度时,目录会同时返回可选档位:

const model = catalog.get("gpt-5.6-sol");
if (!model) throw new Error("model not found");
const efforts = model?.reasoning?.efforts ?? [];

await city.ai.text({
  model,
  prompt: "分析这个问题",
  reasoning_effort: "high",
});

reasoning_effort 必须使用目录中的档位 ID。省略时由模型的 default_effort 或上游默认行为决定。

ai.text()

const result = await city.ai.text({
  model: catalog.get("deepseek-v4-flash"),
  prompt: "写一段欢迎语",
});

ai.text() 固定返回 AI SDK UIMessage,也就是 UI 层可以直接保存和渲染的完整消息。

输入仍然是开放对象:

  • model 必填;AIService 不会自动选择默认模型
  • reasoning_effort 可选,必须来自当前模型目录声明的推理档位
  • 其他字段由 AIService 命中的 AIChannel 标准模型流决定
  • handler 返回值应该是一个 UIMessage

如果你要调用自定义 service,并且返回结构不是 UIMessage,用 city.service(...).action(...).invoke<T>()

ai.stream()

const body = await city.ai.stream({
  model: catalog.get("deepseek-v4-flash"),
  prompt: "流式输出一段文案",
});

ai.stream() 固定返回 AI SDK UIMessageChunk 流:

const stream: ReadableStream<UIMessageChunk> = await city.ai.stream({
  prompt: "流式输出一段文案",
});

它不是 HTTP 原始字节流。SDK 使用 CityModel 请求 Federation 的 LanguageModelV3 模型流,再在客户端转换成 chunk 对象。

你可以按 chunk 消费:

const reader = stream.getReader();
const first = await reader.read();

Federation 的 /v1/ai/stream 只返回标准模型事件,不负责生成 UIMessage。该转换由 city.ai.stream() 在客户端完成。

如果你想要一次性 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 city.ai.image_create({
  prompt: "一只站在雪地里的狐狸",
  model: catalog.get("image-basic"),
  ratio: "1:1",
  count: 1,
});

const current = await city.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 会按具体 AIChannel 返回的内容原样返回。Channel 可以返回 HTTPS URL、R2/resource URL,或 data:image/...;base64,... data URL。

image_create() 会在任务提交后立即返回。AIService 会调度后台 image/fetch queue 任务查询上游状态,并把最新状态写入内置 async_jobs 表。image_result() 只读取这个缓存状态。

也可以在对话或参考图场景使用 messages

const job = await city.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 侧 AIChannel 创建上游任务并实现 image_fetch() 查询上游状态,AIService 负责 Queue 调度和把 Downcity 任务状态存入 async_jobs

video() 仍然固定返回 AI SDK UIMessage。City 侧的 AIChannel video action 也应该返回 UIMessage

ai.tts() / ai.asr()

tts()asr() 仍然保留开放返回类型,因为语音输入输出在不同产品里的传输方式差异更大:

await city.ai.tts({
  text: "你好",
  voice: "alloy",
});

如果你需要更严格的返回结构,可以用自定义 service 包一层 action。

Service 列表

city.listServices() 返回当前 City 注册的 service 摘要:

const services = await city.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 city.service("accounts").get("me");
const usage = await city.service("usage").get("me");

payment

如果你希望按“支付方式”心智调用,而不是手写具体 service + action,直接用:

const methods = await city.payment.methods();

const checkout = await city.payment.method("stripe").invoke({
  topup_amount_minor: 500,
  idempotency_key: "order_123",
});

其中:

  • city.payment.methods() 对应 GET /v1/payment/methods
  • city.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",
  bureau_id: "product_id",
});

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

import type {
  UserRecentTokenUsageResponse,
  UserUsageResponse,
} from "@downcity/services";

const usage = await city.service("usage").get<UserUsageResponse>("me", {
  from: "2026-08-01",
  to: "2026-08-31",
  timezone: "Asia/Shanghai",
});

const recent = await city
  .service("usage")
  .get<UserRecentTokenUsageResponse>("me/recent", { limit: 20 });

const next_page = recent.next_cursor
  ? await city.service("usage").get<UserRecentTokenUsageResponse>("me/recent", {
      limit: 20,
      cursor: recent.next_cursor,
    })
  : null;
  • me 返回按当地自然日聚合的 Credits 和 AI 技术用量。
  • me/recent 返回当前用户最近的单次 AI Token 用量,默认 20 条,最多 50 条。
  • next_cursor 是不透明分页 Cursor;为 null 时表示没有下一页。
  • metering_statusunavailable 时,该记录的 Token 字段为 null,不能从 Credits 反推。
  • 两个入口的用户身份都只来自当前 user_token,请求参数不接受 user_idbureau_id

payment

const methods = await city.payment.methods();

const checkout = await city.payment.method("stripe").invoke({
  topup_amount_minor: 500,
  idempotency_key: "order_123",
});

什么时候应该改用 AI service

如果你要的是模型能力本身,优先用:

  • city.ai.text()
  • city.ai.stream()
  • city.ai.image_create() / city.ai.image_result()

如果你要的是某个业务动作,再用:

  • city.service(...).action(...).invoke()
  • city.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 city.service("accounts").get("login/result", {
  login_id: "login_xxx",
});

常见错误

User City 收到非 2xx HTTP 响应时会抛出 Error。这个错误会带两个额外字段:

  • status:HTTP 状态码。
  • body:City 返回的原始响应文本,通常是 {"error":"..."}
try {
  await city.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);
}

city.ai.stream() 有两个失败阶段:HTTP 状态不是 2xx 时和其他方法一样抛带 status/body 的错误;如果 HTTP 已经成功但响应体为空,或不是有效的 CityModel LanguageModelV3 stream,模型流解析阶段会抛普通错误。

401 / 403

通常是这些原因:

  • user_token 缺失
  • token 已过期
  • token 签名无效
  • token 中的 bureau_id 不属于目标产品

422

通常是这些原因:

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

什么时候不要用 User City

这些动作不应该交给 User City

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

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