Z ZPI 文档 v0.3.8
介绍页

源码

packages/ai/src/providers/discovery.ts

typescript259 行查看原文
import type { ProviderApi } from "../types.ts";
import { isJsonObject } from "../utils/transcript.ts";
import type { DiscoveredModel, ProviderPresetId } from "./registry.ts";
import {
  getProviderPreset,
  isLegacyOpenAIModel,
  presetModels,
  providerApis,
  usesChatGPTAuth,
} from "./registry.ts";

export interface ModelDiscoveryInput {
  preset?: ProviderPresetId;
  api?: ProviderApi;
  baseUrl?: string;
  apiKey: string;
}
export interface ModelDiscoveryResult {
  models: DiscoveredModel[];
  source: "remote" | "catalog";
  warning?: string;
}
const positive = (value: unknown) =>
  typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
function normalizeModel(
  value: unknown,
  input: ModelDiscoveryInput,
  catalog: ReadonlyMap<string, DiscoveredModel>,
): DiscoveredModel | undefined {
  const item = typeof value === "string" ? { id: value } : value;
  if (!isJsonObject(item)) return;
  if (usesChatGPTAuth(input.preset) && item.visibility !== "list") return;
  const chatgpt = usesChatGPTAuth(input.preset);
  const openai = chatgpt || input.preset === "openai-api";
  const id = chatgpt ? (item.slug ?? item.id) : (item.id ?? item.slug);
  if (typeof id !== "string" || !id.trim() || id.length > 200) return;
  if (openai && isLegacyOpenAIModel(id)) return;
  const known = catalog.get(id.toLowerCase());
  // The API catalog also includes embeddings, audio and other non-agent models.
  if (input.preset === "openai-api" && !known) return;
  const model: DiscoveredModel = known
    ? structuredClone(known)
    : {
        id,
        input: ["text"],
        reasoning: false,
        contextWindow: 32768,
        maxTokens: 4096,
        compat: { supportsDeveloperRole: false, supportsReasoningEffort: false },
      };
  model.id = id;
  const name = item.display_name ?? item.name;
  if (!(openai && known) && typeof name === "string" && name.trim()) model.name = name.slice(0, 200);
  const contextWindow = positive(item.context_window ?? item.contextWindow ?? item.context_length);
  const maxTokens = positive(item.max_output_tokens ?? item.maxTokens ?? item.max_tokens);
  // OpenAI presets use the client's chosen default, not the account's advertised maximum.
  if (contextWindow && !(openai && known)) model.contextWindow = contextWindow;
  if (maxTokens && maxTokens <= (model.contextWindow ?? 32768)) model.maxTokens = maxTokens;
  if ((model.maxTokens ?? 0) > (model.contextWindow ?? 0))
    model.maxTokens = Math.min(4096, model.contextWindow ?? 32768);
  const modalities = item.input_modalities ?? item.input;
  if (Array.isArray(modalities)) {
    model.input = [
      "text",
      ...modalities.filter((type): type is "image" | "video" | "pdf" =>
        ["image", "video", "pdf"].includes(String(type)),
      ),
    ];
  }
  if (input.preset === "openai-api") model.availability = "listed";
  if (usesChatGPTAuth(input.preset)) {
    model.availability = "listed";
    if (item.supports_image_inputs === true) model.input = ["text", "image"];
    const levels = item.supported_reasoning_levels ?? item.supported_reasoning_efforts;
    const supported = Array.isArray(levels)
      ? levels.flatMap((value) =>
          typeof value === "string"
            ? [value]
            : isJsonObject(value) && typeof value.effort === "string"
              ? [value.effort]
              : [],
        )
      : [];
    if (supported.length) {
      model.reasoning = supported.some((level) => level !== "none");
      model.compat = { supportsReasoningEffort: true };
      model.thinkingLevelMap = Object.fromEntries(
        ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [
          level,
          level === "off"
            ? supported.includes("none")
              ? "none"
              : null
            : supported.includes(level)
              ? level
              : null,
        ]),
      );
      const defaultLevel = item.default_reasoning_level ?? item.default_reasoning_effort;
      const defaultThinkingLevel = (
        typeof defaultLevel === "string" && supported.includes(defaultLevel)
          ? defaultLevel === "none"
            ? "off"
            : defaultLevel
          : supported.includes("medium")
            ? "medium"
            : undefined
      ) as DiscoveredModel["defaultThinkingLevel"];
      if (defaultThinkingLevel) model.defaultThinkingLevel = defaultThinkingLevel;
      else delete model.defaultThinkingLevel;
    }
  }
  const effort = isJsonObject(item.effort) ? item.effort.supported_levels : undefined;
  if (Array.isArray(effort) && input.preset === "deepseek" && input.api !== "anthropic-messages") {
    model.reasoning = true;
    model.compat = { ...model.compat, thinkingFormat: "deepseek", supportsReasoningEffort: true };
    model.thinkingLevelMap = Object.fromEntries(
      ["minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [
        level,
        effort.includes(level) ? level : null,
      ]),
    );
  }
  model.metadataSource =
    openai && known
      ? "catalog"
      : contextWindow && maxTokens && Array.isArray(modalities)
        ? "remote"
        : known
          ? "catalog"
          : "defaults";
  return model;
}
export async function fetchProviderModels(
  input: ModelDiscoveryInput,
  fetcher: typeof fetch = fetch,
): Promise<ModelDiscoveryResult> {
  if (input.api !== undefined && !providerApis(input.preset).includes(input.api))
    throw new Error("configuration: 不支持的 API 格式");
  const preset = input.preset ? getProviderPreset(input.preset) : undefined;
  if (input.preset && !preset) throw new Error("configuration: 未知预置提供商");
  const baseUrl = preset?.baseUrl ?? input.baseUrl;
  if (!baseUrl) throw new Error("configuration: 缺少 API 地址");
  // Presets expose their catalog on the OpenAI endpoint even when inference uses Anthropic.
  const anthropic = !preset && input.api === "anthropic-messages";
  const url = new URL(`${baseUrl.replace(/\/$/, "")}${anthropic ? "/v1/models" : "/models"}`);
  if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
    throw new Error("configuration: 无效 API 地址");
  const catalog = presetModels(input.preset ?? "", input.api);
  const modelsById = new Map(catalog.map((model) => [model.id.toLowerCase(), model]));
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 8000);
  try {
    const response = await fetcher(url, {
      headers: {
        Accept: "application/json",
        ...(anthropic ? { "anthropic-version": "2023-06-01" } : {}),
        ...(input.apiKey
          ? anthropic
            ? input.apiKey.includes("sk-ant-oat")
              ? { Authorization: `Bearer ${input.apiKey}` }
              : { "x-api-key": input.apiKey }
            : preset?.family === "mimo"
              ? { "api-key": input.apiKey }
              : { Authorization: `Bearer ${input.apiKey}` }
          : {}),
      },
      signal: controller.signal,
      cache: "no-store",
      redirect: "error",
    });
    if (!response.ok) {
      await response.body?.cancel();
      throw new Error(`HTTP ${response.status}`);
    }
    const reader = response.body?.getReader();
    if (!reader) throw new Error("空响应");
    const parts: Uint8Array[] = [];
    let size = 0;
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        size += value.length;
        if (size > 2 * 1024 * 1024) {
          await reader.cancel();
          throw new Error("模型目录过大");
        }
        parts.push(value);
      }
    } finally {
      reader.releaseLock();
    }
    const bytes = new Uint8Array(size);
    let offset = 0;
    for (const part of parts) {
      bytes.set(part, offset);
      offset += part.length;
    }
    const data: unknown = JSON.parse(new TextDecoder().decode(bytes));
    const container = isJsonObject(data)
      ? usesChatGPTAuth(input.preset)
        ? (data.models ?? data.data)
        : (data.data ?? data.models)
      : data;
    const entries = Array.isArray(container)
      ? container
      : isJsonObject(container) && Array.isArray(container.models)
        ? container.models
        : [];
    const models = [
      ...new Map(
        entries.slice(0, 500).flatMap((value) => {
          const model = normalizeModel(value, input, modelsById);
          return model ? [[model.id, model] as const] : [];
        }),
      ).values(),
    ];
    if (!entries.length || (!models.length && input.preset !== "openai-api"))
      throw new Error("未返回可用模型");
    if (usesChatGPTAuth(input.preset) || input.preset === "openai-api") {
      // ChatGPT may explicitly hide models; API entries have no visibility field.
      const returned = new Set(
        usesChatGPTAuth(input.preset)
          ? entries.flatMap((item) =>
              isJsonObject(item) && typeof (item.slug ?? item.id) === "string" ? [item.slug ?? item.id] : [],
            )
          : models.map((model) => model.id),
      );
      const supplemental = catalog.filter((model) => !returned.has(model.id));
      const order = new Map(catalog.map((model, index) => [model.id, index]));
      return {
        models: [...models, ...supplemental].sort(
          (a, b) => (order.get(a.id) ?? catalog.length) - (order.get(b.id) ?? catalog.length),
        ),
        source: "remote",
        ...(supplemental.length
          ? {
              warning: `已获取${input.preset === "openai-api" ? "API" : "账号"}模型目录;${supplemental.map((model) => model.name).join("、")} 为预置补充,账号可用性待验证。`,
            }
          : {}),
      };
    }
    return { models, source: "remote" };
  } catch (error) {
    const models = catalog.map((model) => ({
      ...model,
      metadataSource: "catalog" as const,
    }));
    if (!models.length || usesChatGPTAuth(input.preset))
      throw new Error("provider: 无法获取模型列表,请检查地址、凭据和网络");
    // Never include response bodies, headers or credentials in diagnostics.
    const status = error instanceof Error && /^HTTP \d{3}$/.test(error.message) ? `(${error.message})` : "";
    return { models, source: "catalog", warning: `在线模型列表暂不可用${status},保留 Pi 预置目录。` };
  } finally {
    clearTimeout(timer);
  }
}