Ctrl K
ComunidadGuías…

lib/agent-providers.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Code Agent BYOK provider register (MIN-46). Shared client +
 * server (NO import server-only): config wizard (UI), route
 * ai-keys, key resolution (`lib/server/agent/model.ts`), and loop
 * (`agent-loop.ts`) all rely on this.
 *
 * Principle: providers are addressed via the OpenAI-compatible
 * `<baseUrl>/chat/completions` endpoint with `Authorization: Bearer <apiKey>`,
 * except Anthropic, whose compatibility layer cannot carry Claude's reasoning:
 * its calls go to the native `<baseUrl>/messages` and are converted back to
 * the chat shape (`lib/server/anthropic-messages.ts`). OpenAI and Gemini expose
 * such a layer; the “generic” provider
 * covers any OpenAI-compatible server via a URL base entered by
 * the user. The particularities per provider (attribution headers,
 * usage count, API version) are carried by `requestProfile` and
 * applied in the loop.
 */

export type AgentProviderId =
  | "openrouter"
  | "openai"
  | "anthropic"
  | "google"
  /** OpenCode Go subscription, served by the OpenCode Zen gateway (MIN-544). */
  | "opencode-go"
  /** OpenCode Zen pay-as-you-go gateway — same API as Go, other base path (MIN-544). */
  | "opencode-zen"
  | "generic"
  /** Endpoint OpenAI-compatible atteint seulement depuis l'app de bureau. */
  | "local_openai"
  /** Ollama, via sa couche OpenAI-compatible `/v1`. */
  | "ollama";

/** Adjustments to the chat request by provider (OpenAI compat variable). */
export interface ProviderRequestProfile {
  /** Sends `usage: { include: true }` (OpenRouter cost count). */
  usageAccounting?: boolean;
  /** Sends `stream_options: { include_usage: true }` (tokens in the stream). */
  streamUsage?: boolean;
  /** Wire name of the output ceiling. Surfaces speak `maxOutputTokens`. */
  outputTokenField: "max_tokens" | "max_completion_tokens";
  /** Default ceiling when a surface does not set one. */
  defaultMaxOutputTokens?: number;
  /** Adds OpenRouter attribution headers (HTTP-Referer / X-Title). */
  attribution?: boolean;
  /**
 * Marks the system prompt with a cache breakpoint `cache_control:{ephemeral}`.
 * Reserved for providers who accept it: OpenRouter transmits it to models which
 * support the caching prompt (big gain on Claude) and ignores it without error
 * elsewhere. NOT to be activated for direct OpenAI/Anthropic/Gemini
 * compat layers (risk of field rejection).
 */
  promptCaching?: boolean;
  /**
 * Form of the reasoning field accepted by this endpoint (MIN-122): the ONLY
 * gate of the feature — a provider without this field never sends any.
 * `reasoning` → `reasoning: { effort }` (OpenRouter).
 * `reasoning_effort` → `reasoning_effort` flat (OpenAI and Gemini).
 * `thinking` → adaptive/manual model-aware form (Anthropic).
 * The generic remains silent: its base URL is an unknown server, and a field
 * refused returns to 400. The levels and their translation: lib/agent-reasoning.ts.
 */
  reasoningField?: "reasoning" | "reasoning_effort" | "thinking";
}

export interface AgentProviderDef {
  id: AgentProviderId;
  label: string;
  /** Base URL OpenAI-compatible (sans `/chat/completions`). Absent = base saisie (generic). */
  baseUrl?: string;
  /** true → the user MUST provide a base URL (generic). */
  requiresBaseUrl?: boolean;
  /**
 * URL offered when installing a local endpoint. This is a draft UI,
 * never a server-side fallback: an explicitly registered URL remains
 * required to launch a run.
 */
  localDefaultBaseUrl?: string;
  /**
 * Default model (border) of this provider in BYOK — pre-filled in the
 * selector and used as a resolution fallback (see `resolveAgentModel`).
 * Absent for OpenRouter (takes the root default = quota minddy) and for the
 * generic (namespace unknown → the user must choose).
 *
 * FALLBACK only: an admin changes it from /admin without redeployment (key
 * `byok_default_model_<provider>`, cf. `resolveProviderDefaultModel`). What
 * really works is read there, not here.
 */
  defaultModel?: string;
  /**
 * “Sample” template id to derive the logo via `providerFromModel`
 * (reuses @lobehub/icons mapping). "" = no logo (generic → Cpu).
 */
  logoModel: string;
  /** Model listing strategy (see /api/agent/models route). */
  listStrategy: "openrouter" | "openai" | "anthropic" | "generic" | "none";
  /**
 * The address designates the user's machine or network. It should
 * never be probed by the server nor be chosen for a cloud microVM.
 */
  localOnly?: boolean;
  /** OpenAI-compatible segment added when the local server is waiting for a root. */
  localBaseUrlSuffix?: string;
  requestProfile: ProviderRequestProfile;
  /** Link to the key generation page (wizard help). */
  keysUrl?: string;
}

export const AGENT_PROVIDERS: AgentProviderDef[] = [
  {
    id: "openrouter",
    label: "OpenRouter",
    baseUrl: "https://openrouter.ai/api/v1",
    // No own default: takes over the platform settings of each call type,
    // exactly like the minddy quota (same OpenRouter endpoint).
    logoModel: "openrouter/x",
    listStrategy: "openrouter",
    requestProfile: {
      usageAccounting: true,
      streamUsage: true,
      outputTokenField: "max_completion_tokens",
      defaultMaxOutputTokens: 8192,
      attribution: true,
      promptCaching: true,
      reasoningField: "reasoning",
    },
    keysUrl: "https://openrouter.ai/keys",
  },
  {
    id: "openai",
    label: "OpenAI",
    baseUrl: "https://api.openai.com/v1",
    defaultModel: "gpt-5.6-sol",
    logoModel: "openai/x",
    listStrategy: "openai",
    requestProfile: {
      streamUsage: true,
      outputTokenField: "max_completion_tokens",
      reasoningField: "reasoning_effort",
    },
    keysUrl: "https://platform.openai.com/api-keys",
  },
  {
    id: "anthropic",
    label: "Anthropic",
    baseUrl: "https://api.anthropic.com/v1",
    defaultModel: "claude-sonnet-5",
    logoModel: "anthropic/x",
    listStrategy: "anthropic",
    // Native Messages API (MIN-826): the ceiling is `max_tokens`, usage is
    // always reported in the stream, and reasoning keeps the Anthropic form.
    requestProfile: {
      outputTokenField: "max_tokens",
      defaultMaxOutputTokens: 8192,
      reasoningField: "thinking",
    },
    keysUrl: "https://console.anthropic.com/settings/keys",
  },
  {
    id: "google",
    label: "Google (Gemini)",
    baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
    defaultModel: "gemini-3.5-flash",
    logoModel: "google/x",
    listStrategy: "openai",
    requestProfile: {
      streamUsage: true,
      outputTokenField: "max_completion_tokens",
      reasoningField: "reasoning_effort",
    },
    keysUrl: "https://aistudio.google.com/apikey",
  },
  {
    // OpenCode Go is a $10/month subscription of the OpenCode Zen gateway:
    // same console, same API, another base path (`/zen/go/v1`). Every model
    // the gateway lists is served through OpenAI-compatible
    // `<baseUrl>/chat/completions` (models.dev declares both providers on
    // @ai-sdk/openai-compatible), so the uniform loop/VM transport works as-is.
    // The `/models` listing is PUBLIC: it feeds the catalog but cannot probe
    // a key — see `probeRequestFor` (byok-validate.ts).
    id: "opencode-go",
    label: "OpenCode Go",
    baseUrl: "https://opencode.ai/zen/go/v1",
    defaultModel: "glm-5.3-flash",
    logoModel: "opencode/x",
    listStrategy: "openai",
    // Conservative profile: the gateway tolerance for reasoning fields is not
    // verified, so direct calls send none (the agent harness is unaffected:
    // the opencode VM path nests `reasoning: {effort}` for every provider).
    requestProfile: {
      streamUsage: true,
      outputTokenField: "max_tokens",
    },
    keysUrl: "https://opencode.ai/auth",
  },
  {
    // OpenCode Zen is the pay-as-you-go twin of Go: same console, same API
    // shape, base path `/zen/v1`.
    id: "opencode-zen",
    label: "OpenCode Zen",
    baseUrl: "https://opencode.ai/zen/v1",
    defaultModel: "glm-5.3-flash",
    logoModel: "opencode/x",
    listStrategy: "openai",
    requestProfile: {
      streamUsage: true,
      outputTokenField: "max_tokens",
    },
    keysUrl: "https://opencode.ai/auth",
  },
  {
    id: "generic",
    label: "Générique (OpenAI-compatible)",
    requiresBaseUrl: true,
    logoModel: "",
    listStrategy: "generic",
    // A generic server can only implement the old Chat contract
    // Completions. No provider extensions are sent there.
    requestProfile: { outputTokenField: "max_tokens" },
  },
  {
    id: "local_openai",
    label: "Endpoint local (OpenAI-compatible)",
    requiresBaseUrl: true,
    // Default port of LM Studio, the local OpenAI-compatible implementation
    // more widespread. The user can of course replace it.
    localDefaultBaseUrl: "http://127.0.0.1:1234/v1",
    logoModel: "",
    listStrategy: "none",
    localOnly: true,
    // No optional field: an unknown local endpoint is the most
    // conservatrice du contrat Chat Completions.
    requestProfile: { outputTokenField: "max_tokens" },
  },
  {
    id: "ollama",
    label: "Ollama (local)",
    requiresBaseUrl: true,
    localDefaultBaseUrl: "http://127.0.0.1:11434",
    logoModel: "",
    listStrategy: "none",
    localOnly: true,
    // Ollama exposes its Chat Completions under `/v1`, while its usual URL
    // is the root `http://127.0.0.1:11434`.
    localBaseUrlSuffix: "/v1",
    requestProfile: { outputTokenField: "max_tokens" },
  },
];

/** Default Provider when no BYOK: OpenRouter platform key. */
export const DEFAULT_AGENT_PROVIDER: AgentProviderId = "openrouter";

export function getAgentProvider(id: string | null | undefined): AgentProviderDef | undefined {
  return AGENT_PROVIDERS.find((p) => p.id === id);
}

export function isKnownAgentProvider(id: string): id is AgentProviderId {
  return AGENT_PROVIDERS.some((p) => p.id === id);
}

/** A local endpoint can only be reached by the desktop app harness. */
export function isLocalAgentProvider(id: string | null | undefined): boolean {
  return getAgentProvider(id)?.localOnly === true;
}

/** Normalizes a URL base: trim, removes the final `/` and a pasted `/chat/completions`. */
export function normalizeBaseUrl(raw: string): string {
  return raw
    .trim()
    .replace(/\/+$/, "")
    .replace(/\/chat\/completions$/i, "");
}

/**
 * Effective URL base of a provider: that of the registry, or the custom (generic).
 * Returns null if generic without URL base, or unknown provider.
 */
export function resolveProviderBaseUrl(
  providerId: string,
  customBaseUrl?: string | null,
): string | null {
  const def = getAgentProvider(providerId);
  if (!def) return null;
  const raw = def.requiresBaseUrl ? (customBaseUrl ?? "").trim() : (def.baseUrl ?? "");
  if (!raw) return null;
  const normalized = normalizeBaseUrl(raw);
  if (!def.localBaseUrlSuffix) return normalized;
  const suffix = def.localBaseUrlSuffix.replace(/^\/+/, "");
  return normalized.toLowerCase().endsWith(`/${suffix.toLowerCase()}`)
    ? normalized
    : `${normalized}/${suffix}`;
}

/** OpenAI-compatible chat completion URL for a given database. */
export function chatCompletionsUrl(baseUrl: string): string {
  return `${normalizeBaseUrl(baseUrl)}/chat/completions`;
}