Service 服务AI 模型服务

Provider

Provider 是第三方 AI 提供商的基类,通过子类化声明 action。

Provider 是抽象基类,封装第三方 AI 提供商的 action 实现和连接信息。通过 .model() 生成模型配置,传给 AIService.use() 注册。

快速开始:OpenAI-compatible 文本 Provider

继承 Provider,覆盖 createClient,即可自动获得 text / stream 能力:

import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { Provider, type OpenAICompatibleClientConfig } from "@downcity/city";

class DeepSeekProvider extends Provider {
  constructor() {
    super({
      id: "deepseek",
      envKey: "DEEPSEEK_API_KEY",
      baseURL: "https://api.deepseek.com/v1",
      passthroughModel: "deepseek-chat",
    });
  }

  protected createClient({ apiKey, baseURL }: OpenAICompatibleClientConfig) {
    return createOpenAICompatible({ apiKey, baseURL, name: "deepseek" });
  }
}

const deepseek = new DeepSeekProvider();

const ai = new AIService();
ai.use(deepseek.model({ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }));
base.use(ai);

注册后自动生成路由:

POST /v1/ai/text              → SDK 通路
POST /v1/ai/stream            → SDK 通路
POST /v1/ai/chat/completions  → 自动透传到 DeepSeek
GET  /v1/ai/models            → 模型目录

passthroughModel 控制自动透传时发往上游的 model 值。

模型上下文窗口

通过 context_window 声明模型支持的总上下文 token 容量:

ai.use(deepseek.model({
  id: "deepseek-v4-flash",
  name: "DeepSeek V4 Flash",
  context_window: 128_000,
}));

该值会通过 /v1/ai/models 返回给客户端。@downcity/agent 使用 City 模型时,会默认在上下文达到窗口的 80% 时触发 session compact,为 system、工具调用和本轮输出保留余量。未配置 context_window 的模型继续使用 Agent 的 128k 默认 compact 预算;自定义 maxInputTokensApprox 仍拥有最高优先级。

模型推理强度

支持推理强度的模型通过 reasoning 声明可选档位和默认档位:

ai.use(openai.model({
  id: "gpt-5.6-sol",
  name: "GPT-5.6 Sol",
  reasoning: {
    efforts: [
      { id: "low", name: "低" },
      { id: "medium", name: "标准" },
      { id: "high", name: "高" },
      { id: "xhigh", name: "极高" },
    ],
    default_effort: "medium",
  },
}));

这些档位会通过 /v1/ai/models 返回。调用方只传一个字段,值必须是当前模型 reasoning.efforts[].id 中的一项:

await client.ai.text({
  model: "gpt-5.6-sol",
  prompt: "分析这个问题",
  reasoning_effort: "high",
});

AIService 会先完成模型 fallback,再按照最终模型校验 reasoning_effort。请求没有指定档位时使用 default_effort;模型没有配置默认档位时使用上游默认行为。不支持的档位会返回 422

默认 OpenAI-compatible text / stream 实现会把已校验档位转换成 AI SDK providerOptionsreasoningEffort。字段结构不同的 Provider 可以覆盖 build_reasoning_provider_options(ctx, model),例如 Anthropic 映射到 anthropic.thinking / anthropic.effort,Gemini 映射到 google.thinkingConfig。自定义实现应通过 read_resolved_reasoning(ctx) 读取 AIService 已校验的结果,不要重新解析原始请求。

/v1/ai/chat/completions 已经是 OpenAI-compatible 协议,不转换成 providerOptions;校验后的 reasoning_effort 会留在请求体中交给 openai action 或自动透传。

覆盖默认 text / stream

如果默认的 OpenAI-compatible 实现不满足需求,可以直接覆盖 text / stream 方法:

import { Provider, type Context, type AIProviderChargedOutput } from "@downcity/city";
import type { UIMessage } from "ai";

class CustomTextProvider extends Provider {
  constructor() {
    super({ id: "custom", envKey: "CUSTOM_API_KEY" });
  }

  async text(ctx: Context): Promise<AIProviderChargedOutput<UIMessage>> {
    // 自定义实现
  }

  async stream(ctx: Context) {
    // 自定义实现
  }
}

多模态转文本

覆盖 extractPrompt 可以把多模态 messages 规整为纯文本:

class DeepSeekProvider extends Provider {
  // ...
  protected extractPrompt(input: { prompt?: string; messages?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> }): string {
    if (typeof input.prompt === "string") return input.prompt;
    return input.messages
      ?.find((m) => m.role === "user")
      ?.parts?.map((part) => (part.type === "text" ? part.text : ""))
      .filter(Boolean)
      .join("\n") ?? "";
  }
}

模型 fallback

可以直接在 model 配置上挂 fallback:

const kimi = kimiProvider.model({
  id: "kimi-k2.6",
  name: "Kimi K2.6",
});

const deepseek = deepseekProvider.model({
  id: "deepseek-v4-flash",
  name: "DeepSeek V4 Flash",
  fallback: [
    {
      match: (media) => media.media_type.startsWith("image/"),
      model: kimi,
    },
  ],
});

当请求里出现 file 媒体输入,而当前模型不支持时,AIService 会在 textstreamchat/completions 三条通路上按 fallback 数组顺序执行 match(media),第一条命中且目标模型可用的规则会成为实际执行模型。

UIMessage 通路只会从 parts 里的 { type: "file" } 提取 media.media_typemedia.filenamemedia.url;图片、音频、视频、PDF 等分流逻辑都应该写在 match 里。OpenAI-compatible 通路的 image_url / input_image 会被归一成 media.media_type === "image/*" 后参与同一套规则。

如果没有配置 fallback,或没有任何规则命中,AIService 会保持原模型不变,继续交给 provider 自己处理。

图片 Provider

@downcity/city 不内置 OpenAI、DeepSeek 或其他具体图片 Provider 构造器。City 包提供 Provider 抽象、AIService 注册机制,以及 client.ai.image_create() / client.ai.image_result() 的统一任务协议。

图片任务存储由 AIService 负责:它内置声明 async_jobs 表,Federation 初始化 Service 时会自动建表。Provider 只负责创建上游任务、查询上游状态,并把上游响应归一化成固定协议返回。

归一化的意思是:Provider 不能把上游原始响应直接交给 AIService 猜字段。不同上游可能把状态放在 statusdata.statusresult.status,也可能用 status_code + err + data 表示 pending 或成功。Provider 必须在 image_fetch() 内把这些结构翻译成 Downcity 唯一认可的结果:

type AIImageProviderFetchResult = {
  job_id: string;
  status: "queued" | "running" | "succeeded" | "failed";
  result?: UIMessage;
  error?: string;
  message?: string;
  poll_after_ms?: number;
  metadata?: Record<string, unknown>;
};

例如,上游返回 result pending 时,Provider 应返回 status: "running"、明确的 message、下一次 poll_after_ms,并在 metadata.raw 或类似字段保留原始响应摘要;上游成功时,Provider 必须返回 status: "succeeded" 和可落盘的 AI SDK UIMessage file parts;上游失败时,Provider 必须返回 status: "failed" 和可展示的 error

一句话:AIService 管 job 存储、Queue 调度、状态缓存和计费;Provider 管上游协议解析、pending/succeeded/failed 映射、usage 提取和图片结果转换。

AIService 还会为图片任务提供平台级 pending 兜底:任务保持 queuedrunning 超过 image_max_pending_duration_ms 后,会被标记为 failederrormessage 都是 upstream timeout。默认值是 2 小时。Provider 不需要自己判断 Downcity 任务是否超时,只需要在上游仍未完成时持续返回 queuedrunning

完整示例

import {
  AIService,
  Provider,
  type AIImageProviderCreateResult,
  type AIImageProviderFetchResult,
  type Context,
} from "@downcity/city";

class MyImageProvider extends Provider {
  constructor() {
    super({ id: "my-image", envKey: "MY_IMAGE_API_KEY" });
  }

  async image_create(ctx: Context): Promise<AIImageProviderCreateResult> {
    const input = ctx.input as { prompt?: string; size?: string };
    const api_key = ctx.env("MY_IMAGE_API_KEY");
    const response = await fetch("https://image.example.com/jobs", {
      method: "POST",
      headers: {
        authorization: `Bearer ${api_key}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        prompt: input.prompt,
        size: input.size,
      }),
    });
    const data = await response.json() as { id?: string; error?: string };
    if (!response.ok || !data.id) {
      return {
        job_id: `img_${crypto.randomUUID()}`,
        status: "failed",
        error: data.error ?? response.statusText,
      };
    }
    return {
      job_id: `img_${crypto.randomUUID()}`,
      status: "running",
      message: "submitted",
      poll_after_ms: 3000,
      metadata: {
        upstream_job_id: data.id,
        upstream_model: "image-basic",
      },
    };
  }

  async image_fetch(ctx: Context): Promise<AIImageProviderFetchResult> {
    const image_job = ctx.locals.ai_image_job as {
      record: { job_id: string; user_id?: string | null; city_id?: string | null };
      state?: { upstream_job_id?: string; upstream_model?: string };
    };
    const upstream_job_id = image_job.state?.upstream_job_id;
    if (!upstream_job_id) {
      return {
        job_id: image_job.record.job_id,
        status: "failed",
        error: "Missing upstream image job id",
      };
    }

    const api_key = ctx.env("MY_IMAGE_API_KEY");
    const response = await fetch(`https://image.example.com/jobs/${upstream_job_id}`, {
      headers: {
        authorization: `Bearer ${api_key}`,
      },
    });
    const data = await response.json() as {
      status?: "queued" | "running" | "succeeded" | "failed";
      image_url?: string;
      error?: string;
      usage?: unknown;
    };

    if (!response.ok || data.status === "failed") {
      return {
        job_id: image_job.record.job_id,
        status: "failed",
        error: data.error ?? response.statusText,
        metadata: image_job.state,
      };
    }

    if (data.status === "queued" || data.status === "running" || !data.image_url) {
      return {
        job_id: image_job.record.job_id,
        status: data.status ?? "running",
        message: "generating",
        poll_after_ms: 3000,
        metadata: image_job.state,
      };
    }

    return {
      job_id: image_job.record.job_id,
      status: "succeeded",
      result: {
        id: `msg_${crypto.randomUUID()}`,
        role: "assistant",
        parts: [{
          type: "file",
          mediaType: "image/png",
          url: data.image_url,
        }],
      },
      metadata: {
        ...image_job.state,
        user_id: image_job.record.user_id,
        city_id: image_job.record.city_id,
        usage: data.usage,
      },
    };
  }
}

const ai = new AIService({ balance });
const image_provider = new MyImageProvider();

ai.use(image_provider.model({
  id: "image-basic",
  name: "Image Basic",
  bill(ctx, output) {
    return {
      user_id: output.metadata?.user_id,
      credits: 50_000,
      ref: `image:${output.job_id}`,
      note: "image generation",
      metadata: { usage: output.metadata?.usage },
    };
  },
}));

balance 传给 AIService 后,AIService 也会在真实消费模型的 action 前执行默认余额检查:如果用户钱包已经是负数,请求会在调用 Provider 前被拦截;Provider 成功后再按实际 usage 扣费。

Queue 是 Federation 级能力,不需要传给 AIService

fed.queue.use(cloudflareQueue(env.DOWNCITY_QUEUE));
fed.use(ai);

Cloudflare Queue consumer 收到消息后,把消息交回 Federation:

for (const message of batch.messages) {
  await fed.queue.call(message.body);
  message.ack();
}

调用端仍然保持统一:

const job = await client.ai.image_create({
  model: "image-basic",
  prompt: "A clean product photo of a ceramic mug",
  ratio: "1:1",
  quality: "standard",
  count: 1,
});

const current = await client.ai.image_result({ job_id: job.job_id });

如果任务还在 queuedrunning,前端按 poll_after_ms 继续调用 image_result()image_result() 只读取 async_jobs 缓存;后台 Queue 会调用 ai.image/fetch,由 AIService 调 Provider 的 image_fetch() 抓取上游状态并写回 async_jobs

Provider 任务协议

调用方只使用 client.ai.image_create()client.ai.image_result()。Provider 实现侧通常只需要两个方法:

class ImageProvider extends Provider {
  async image_create(ctx: Context): Promise<AIImageProviderCreateResult> {
    // 创建/启动上游任务。AIService 会把 ctx.input 和 metadata 保存到 async_jobs。
    return { job_id: "upstream_or_local_job_id", status: "running" };
  }

  async image_fetch(ctx: Context): Promise<AIImageProviderFetchResult> {
    // 读取 ctx.locals.ai_image_job,查询上游,并返回最新状态或最终结果。
    return { job_id: String(ctx.input.job_id), status: "running", poll_after_ms: 2000 };
  }
}

image_fetch 是图片模型的必需方法。模型只有同时提供 image_createimage_fetch 时,才会在模型目录里暴露 image capability。

AIService 负责 job 存储、Queue 调度、状态缓存、最终结果落库和幂等计费。Provider 只负责上游调用、上游轮询、usage 解析,以及把结果转换成 AI SDK UIMessage file parts。

自动透传

baseURL + envKey 但没有 openai 方法时,AIService 自动生成透传 action:

  • 第三方工具的 OpenAI body 原样转发到上游
  • 上游 Response 原样返回
  • passthroughModel 替换 body.model(有则替换)
// 这行不用写 — AIService 自动做
// async openai(ctx) { return fetch(`${baseURL}/chat/completions`, ...) }

自定义 OpenAI 兼容 Handler

当 OpenAI 兼容上游需要自定义请求或响应处理时,可以写 openai 方法:

import {
  Provider,
  type AIProviderChargedResponse,
  type Context,
  normalizeOpenAICompatibleBody,
  readRequiredEnv,
  trimTrailingSlash,
} from "@downcity/city";

class OpenAICustomProvider extends Provider {
  constructor() {
    super({ id: "openai-custom", envKey: "OPENAI_API_KEY", baseURL: "https://api.openai.com/v1" });
  }

  async openai(ctx: Context): Promise<AIProviderChargedResponse> {
    const apiKey = readRequiredEnv(ctx, this.envKey ?? "");
    const body = normalizeOpenAICompatibleBody(
      ctx.input as Record<string, unknown>,
      this.passthroughModel ?? "",
    );
    const response = await fetch(`${trimTrailingSlash(this.baseURL ?? "")}/chat/completions`, {
      method: "POST",
      headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
      body: JSON.stringify(body),
    });
    return { response };
  }
}

Provider 出账

当 Provider 或某个具体 model 需要把一次完成的 AI 调用转换成账单草稿时,使用 bill(ctx, output)bill 不直接扣余额;AIService 会读取它返回的 charge line,并通过配置的 BalanceService bridge 调用 balance.charge()。这次成功调用可以把钱包扣成负数;下一次真实消费模型的 AI 请求会被 AIService 的前置检查拦截。

图片任务的后台 image/fetch 会先原子领取任务,同一时间只有一个 worker 调用 Provider。Provider 返回成功后,AIService 先用稳定的任务幂等键完成扣费,再写入 status: "succeeded"result_json;扣费失败时任务保持可重试,重复执行不会重复扣款。image_create() 不扣费;用户 image_result() 只读 async_jobs 缓存。Queue 消费没有当前用户上下文时,AIService 会自动使用任务创建时保存的 owner;charge line 仍可用 user_id 显式覆盖。

流式 action 返回异步 charge 时,Worker 使用 waitUntil 完成结算;Node 等没有 ExecutionContext 的运行时会把结算绑定到 Response 流收尾,扣费完成后才关闭响应流。普通异步 charge 则会在请求结束前直接完成。

通常优先级是:

  1. action 显式返回 charge
  2. model 提供 bill(ctx, output)
  3. provider 提供 bill(ctx, output)
  4. 不产生扣费
import { normalizeAIUsage } from "@downcity/city";

class DeepSeekProvider extends Provider {
  // ...
  protected bill(ctx: Context, output: unknown) {
    const usage = output?.metadata?.usage;
    const normalized = normalizeAIUsage(usage);
    // 根据 usage 计算金额
    return {
      credits: 10_000,
      note: `DeepSeek ${ctx.variant?.id}`,
      metadata: { raw_usage: usage },
    };
  }
}

单个 model 也可以覆盖 provider 的出账逻辑:

ai.use(deepseek.model({
  id: "deepseek-v4-pro",
  name: "DeepSeek V4 Pro",
  bill(ctx, output) {
    return {
      credits: 20_000,
      note: "DeepSeek V4 Pro request",
      metadata: {
        model_id: ctx.variant?.id,
        usage: output?.metadata?.usage,
      },
    };
  },
}));

也可以在 action 方法里直接返回 charge:

async text(ctx: Context) {
  const result = await callUpstream(ctx.input);
  return {
    output: toUIMessage(result),
    charge: {
      credits: priceUpstreamUsage(result.usage),
      note: "custom text",
    },
  };
}

Provider 字段与方法

字段必填说明
idProvider 唯一 ID
env环境变量声明
baseURL透传时必填上游 API 地址
envKey透传时必填API Key 环境变量名
passthroughModel透传时替换 body.model
方法说明
createClient需要 text/stream 时覆盖,返回 OpenAI-compatible client
textSDK 文本生成 action(基类默认提供)
streamSDK 流式生成 action(基类默认提供)
image_create创建并启动图片 provider 任务
image_fetch后台查询图片 provider 任务状态并返回最终结果
videoSDK 视频生成 action
openai/chat/completions action(未提供则自动透传)
bill为本次完成的调用返回账单草稿
extractPrompt从输入提取 prompt(可覆盖)

工具函数

@downcity/city 提供一些通用工具函数,方便在 Provider 子类里复用:

  • readRequiredEnv(ctx, key)
  • resolveUpstreamModel(ctx, fallback)
  • trimTrailingSlash(url)
  • buildAssistantMessage(text, ctx, result, charge?)
  • buildImageMessage(ctx, images, metadata)
  • normalizeOpenAICompatibleBody(input, model)
  • readOpenAICompatibleSseUsage(body)
  • normalizeAIUsage(usage)
  • buildToolSet(items)