参考

User City

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

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

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

  • city_url
  • town_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,例如 accountsusagepayment

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

422

通常是这些原因:

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

什么时候不要用 User City

这些动作不应该交给 User City

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

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