Ctrl K
ComunidadGuías…

lib/ai-chat.ts

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

Archivo original ↗Preguntar a KiBot
import {
  getAgentProvider,
  type AgentProviderId,
} from "./agent-providers";
import {
  nearestReasoningLevel,
  reasoningRequestFields,
  reasoningTokenBudget,
  type ReasoningLevel,
} from "./agent-reasoning";
import {
  anthropicModelReasoning,
  geminiModelReasoning,
  isAdaptiveClaude,
  isAlwaysThinkingClaude,
  isClaude46,
  isManualThinkingClaude,
  openAiModelReasoning,
} from "./model-reasoning-rules";
import { SITE_URL } from "./site";

/**
 * Shared chat contract for Numo, dictation, feedback, and integrations.
 * Callers use provider-independent fields, translated below. Add common options
 * here; reserve `extensions` for intentionally provider-specific capabilities,
 * such as the OpenRouter web plugin.
 */
export interface AiChatRequest {
  model: string;
  messages: unknown[];
  stream?: boolean;
  maxOutputTokens?: number;
  reasoning?: {
    /** Explicitly disable optional reasoning on OpenRouter; omission keeps model defaults. */
    enabled?: false;
    effort?: ReasoningLevel;
    /** Fixed budget when the provider knows how to express it. */
    maxTokens?: number;
  };
  tools?: unknown[];
  toolChoice?: unknown;
  temperature?: number;
  stop?: string | string[];
  responseFormat?: unknown;
  parallelToolCalls?: boolean;
  /** Options deliberately specific to the chosen endpoint. Never for a common field. */
  extensions?: Record<string, unknown>;
}

function positiveInt(value: unknown): number | undefined {
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
  return Math.trunc(value);
}

/**
 * Claude 4.7 and later return a 400 for any `temperature` below 1
 * ("`temperature` is deprecated for this model"), so the sampling field is
 * dropped on those families.
 */
function rejectsSamplingTemperature(model: string): boolean {
  return isAdaptiveClaude(model) && !isClaude46(model);
}

/**
 * Thinking takes its share of the output ceiling. A ceiling sized for the
 * visible answer would let a high effort consume it and end the round on
 * `max_tokens`; only generated tokens are billed, so the floor is free on
 * short answers.
 */
const ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS = 16_000;

/** A level folded to what the family accepts (`minimal` → `low`, `xhigh` → `high` on 4.6). */
function anthropicEffort(model: string, level: ReasoningLevel): ReasoningLevel {
  const allowed = anthropicModelReasoning(model)?.efforts ?? [];
  return nearestReasoningLevel(level === "off" ? "low" : level, allowed);
}

/**
 * Families whose Chat Completions function calling only works with
 * `reasoning_effort: "none"`: GPT-5.6, and GPT-6 Sol and GPT-6 Luna (OpenAI
 * guide "Using GPT-6"). GPT-6 Astra, GPT-6.1 Sol and GPT-6.1 Terra are left
 * out on purpose: they reject `none` and need Responses for tool calling, so
 * no effort value makes them usable on this transport.
 */
function needsReasoningOffWithFunctionTools(model: string): boolean {
  return (
    /^gpt-5\.6(?:-|$)/i.test(model) ||
    /^gpt-6-(?:sol|luna)(?:-|$)/i.test(model)
  );
}

/**
 * Families that reject forced tool choice with a 400 on every request:
 * Claude Opus 5.5, Sonnet 5.5, Fable 5.1, and Mythos 5.1. Downgrade to `auto`
 * at the compatibility boundary to keep these requests usable.
 */
function rejectsForcedToolChoice(model: string): boolean {
  return (
    /^claude-(?:opus|sonnet)-5-5(?:-|$)/i.test(model) ||
    /^claude-(?:fable|mythos)-5-1(?:-|$)/i.test(model)
  );
}

function isForcedOpenAiToolChoice(value: unknown): boolean {
  if (value === "required") return true;
  if (typeof value !== "object" || value === null) return false;
  const choice = value as { type?: unknown; allowed_tools?: { mode?: unknown } };
  return choice.type === "function" ||
    (choice.type === "allowed_tools" && choice.allowed_tools?.mode === "required");
}

function normalizeProviderToolChoice(
  body: Record<string, unknown>,
  provider: AgentProviderId,
): void {
  if (
    provider === "anthropic" &&
    typeof body.model === "string" &&
    rejectsForcedToolChoice(body.model) &&
    isForcedOpenAiToolChoice(body.tool_choice)
  ) {
    body.tool_choice = "auto";
  }
}

/**
 * Final Anthropic adjustments once the body is assembled: drops the sampling
 * temperature recent families reject, drops a manual budget when the tool
 * choice is forced ("Thinking may not be enabled when tool_choice forces tool
 * use", 400), and lifts the ceiling when adaptive thinking runs.
 */
function normalizeAnthropicBody(
  body: Record<string, unknown>,
  provider: AgentProviderId,
): void {
  if (provider !== "anthropic" || typeof body.model !== "string") return;
  if (rejectsSamplingTemperature(body.model)) delete body.temperature;
  const thinking = (body.thinking as { type?: unknown } | undefined)?.type;
  if (thinking === "enabled" && isForcedOpenAiToolChoice(body.tool_choice)) {
    delete body.thinking;
  }
  if (thinking === "adaptive") {
    const ceiling = body.max_tokens;
    if (typeof ceiling !== "number" || ceiling < ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS) {
      body.max_tokens = ADAPTIVE_CLAUDE_MIN_OUTPUT_TOKENS;
    }
  }
}

/**
 * Native Messages API reasoning (`lib/server/anthropic-messages.ts` carries
 * these fields as they are). Adaptive families get `thinking: adaptive` with a
 * readable summary for the reasoning feed, and the level as
 * `output_config.effort`; 4.5 families get a manual budget.
 */
function anthropicReasoningFields(params: {
  model: string;
  effort?: ReasoningLevel;
  fixedTokens?: number;
  maxOutputTokens?: number;
}): Record<string, unknown> {
  const { model } = params;
  if (params.effort === "off") {
    // Fable 5 / Mythos 5 / Mythos Preview and the Claude 5.5 families refuse
    // `thinking: {type: "disabled"}` (400): the cheapest effort is the
    // closest request they accept.
    if (isAlwaysThinkingClaude(model)) {
      return { output_config: { effort: anthropicEffort(model, "off") } };
    }
    return isAdaptiveClaude(model) || isManualThinkingClaude(model)
      ? { thinking: { type: "disabled" } }
      : {};
  }

  // A fixed budget keeps manual mode where the family still accepts one.
  const manual = isManualThinkingClaude(model) &&
    (params.fixedTokens !== undefined || !isAdaptiveClaude(model));
  if (!manual) {
    if (!isAdaptiveClaude(model) || !params.effort) return {};
    return {
      thinking: { type: "adaptive", display: "summarized" },
      output_config: { effort: anthropicEffort(model, params.effort) },
    };
  }

  const requested =
    params.fixedTokens ??
    (params.effort ? reasoningTokenBudget(params.effort) : undefined);
  if (requested === undefined) return {};
  const maxOutput = params.maxOutputTokens;
  if (maxOutput !== undefined && maxOutput <= 1024) return {};
  const budget = Math.max(
    1024,
    maxOutput !== undefined ? Math.min(requested, maxOutput - 1) : requested,
  );
  return { thinking: { type: "enabled", budget_tokens: budget } };
}

/** `reasoning_effort` for OpenAI and Gemini, folded to what the model takes. */
function compatEffortFields(
  provider: "openai" | "google",
  model: string,
  level: ReasoningLevel,
): Record<string, unknown> {
  const reasoning = provider === "openai" ? openAiModelReasoning(model) : geminiModelReasoning(model);
  if (!reasoning) return {};
  if (level === "off") {
    // A mandatory model has no "off": the server already lowers it, and the
    // model's own default is the safe fallback for any other caller.
    return reasoning.mandatory ? {} : { reasoning_effort: "none" };
  }
  return { reasoning_effort: nearestReasoningLevel(level, reasoning.efforts) };
}

function providerReasoningFields(
  provider: AgentProviderId,
  model: string,
  reasoning: AiChatRequest["reasoning"] | undefined,
  maxOutputTokens: number | undefined,
  hasFunctionTools: boolean,
): Record<string, unknown> {
  // GPT-5.6 and GPT-6 Sol/Luna call functions via Chat Completions only
  // without an active reasoning effort. Responses is the full path; as long as
  // this transport remains Chat Completions, `none` is the documented contract.
  if (provider === "openai" && hasFunctionTools && needsReasoningOffWithFunctionTools(model)) {
    return { reasoning_effort: "none" };
  }
  if (provider === "openrouter" && reasoning?.enabled === false) {
    return { reasoning: { enabled: false } };
  }
  if (
    reasoning === undefined ||
    (reasoning.effort === undefined && reasoning.maxTokens === undefined)
  ) {
    return {};
  }
  const fixedTokens = positiveInt(reasoning?.maxTokens);
  if ((provider === "openai" || provider === "google") && reasoning.effort) {
    return compatEffortFields(provider, model, reasoning.effort);
  }
  // "Off" really disables optional reasoning: omitting the field ran the
  // model at its default effort (MIN-826). A mandatory model answers 400,
  // which `repairRejectedAiChatBody` replays without the field.
  if (provider === "openrouter" && reasoning.effort === "off") {
    return { reasoning: { enabled: false } };
  }
  if (provider === "anthropic") {
    return anthropicReasoningFields({
      model,
      effort: reasoning.effort,
      fixedTokens,
      maxOutputTokens,
    });
  }
  if (fixedTokens !== undefined && provider === "openrouter") {
    return { reasoning: { max_tokens: fixedTokens, exclude: false } };
  }
  return reasoning.effort
    ? reasoningRequestFields(reasoning.effort, provider)
    : {};
}

/**
 * Gemini 3 returns a `thought_signature` in each tool call's `extra_content`,
 * and requires it back on the next round. The loop keeps it in history; any
 * other provider receives the tool calls without that Gemini-only field.
 */
function withoutGeminiToolExtras(messages: unknown[]): unknown[] {
  return messages.map((message) => {
    const calls = (message as { tool_calls?: unknown } | null)?.tool_calls;
    if (!Array.isArray(calls) || !calls.some((call) => call && typeof call === "object" && "extra_content" in call)) {
      return message;
    }
    return {
      ...(message as Record<string, unknown>),
      tool_calls: calls.map((call) => {
        if (!call || typeof call !== "object" || !("extra_content" in call)) return call;
        const { extra_content: _extra, ...rest } = call as Record<string, unknown>;
        return rest;
      }),
    };
  });
}

/** Translates the minddy contract to the exact JSON of the provider. Pure function. */
export function translateAiChatRequest(
  request: AiChatRequest,
  provider: AgentProviderId,
): Record<string, unknown> {
  const profile = getAgentProvider(provider)?.requestProfile;
  if (!profile) throw new Error(`Unknown AI provider: ${provider}`);

  const body: Record<string, unknown> = {
    model: request.model,
    messages: provider === "google" ? request.messages : withoutGeminiToolExtras(request.messages),
    ...(request.stream !== undefined ? { stream: request.stream } : {}),
    ...(request.tools ? { tools: request.tools } : {}),
    ...(request.toolChoice !== undefined ? { tool_choice: request.toolChoice } : {}),
    ...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
    ...(request.stop !== undefined ? { stop: request.stop } : {}),
    ...(request.responseFormat !== undefined ? { response_format: request.responseFormat } : {}),
    ...(request.parallelToolCalls !== undefined
      ? { parallel_tool_calls: request.parallelToolCalls }
      : {}),
    ...request.extensions,
  };

  const maxOutputTokens =
    positiveInt(request.maxOutputTokens) ?? profile.defaultMaxOutputTokens;
  if (maxOutputTokens !== undefined) {
    body[profile.outputTokenField] = maxOutputTokens;
  }

  Object.assign(
    body,
    providerReasoningFields(
      provider,
      request.model,
      request.reasoning,
      maxOutputTokens,
      Boolean(request.tools?.length),
    ),
  );

  normalizeProviderToolChoice(body, provider);
  normalizeAnthropicBody(body, provider);

  if (profile.usageAccounting) body.usage = { include: true };
  if (request.stream && profile.streamUsage) {
    body.stream_options = { include_usage: true };
  }
  return body;
}

/** Provider-specific headers; authentication remains in transport. */
export function aiChatProviderHeaders(
  provider: AgentProviderId,
  title: string,
): Record<string, string> {
  const profile = getAgentProvider(provider)?.requestProfile;
  if (!profile) throw new Error(`Unknown AI provider: ${provider}`);
  return profile.attribution
    ? { "HTTP-Referer": SITE_URL, "X-Title": title }
    : {};
}

/**
 * Produces the same body with the other ceiling alias, only when the 400
 * explicitly designates the one that was sent as unsupported.
 */
export function alternateOutputTokenBody(
  bodyText: string,
  errorText: string,
): string | null {
  let body: Record<string, unknown>;
  try {
    body = JSON.parse(bodyText) as Record<string, unknown>;
  } catch {
    return null;
  }
  const current = Object.hasOwn(body, "max_completion_tokens")
    ? "max_completion_tokens"
    : Object.hasOwn(body, "max_tokens")
      ? "max_tokens"
      : null;
  if (
    current === null ||
    !new RegExp(
      `(?:unsupported|not supported)[^\\n]{0,160}${current}|${current}[^\\n]{0,160}(?:unsupported|not supported)`,
      "i",
    ).test(errorText)
  ) {
    return null;
  }

  const alternate = current === "max_tokens" ? "max_completion_tokens" : "max_tokens";
  body[alternate] = body[current];
  delete body[current];
  return JSON.stringify(body);
}

/**
 * Repairs the only compatibility 400s that we know how to replay without risk:
 * a denied cap alias, a reasoning disable refused by a model that must reason,
 * or GPT-5.6 / GPT-6 Sol-Luna Chat Completions that denies the couple function tools +
 * reasoning. Any other error keeps the initial body.
 */
export function repairRejectedAiChatBody(
  bodyText: string,
  errorText: string,
): string | null {
  const outputAliasRepair = alternateOutputTokenBody(bodyText, errorText);
  if (outputAliasRepair !== null) return outputAliasRepair;

  // "Off" sent to a model whose catalog entry did not say it must reason:
  // replay at the model's own default rather than failing the turn.
  if (/reasoning is mandatory|(?:reasoning|thinking)[^\n]{0,80}cannot be (?:turned off|disabled)/i.test(errorText)) {
    try {
      const body = JSON.parse(bodyText) as Record<string, unknown>;
      const reasoning = body.reasoning as { enabled?: unknown } | undefined;
      if (reasoning?.enabled === false || body.reasoning_effort === "none") {
        delete body.reasoning;
        delete body.reasoning_effort;
        return JSON.stringify(body);
      }
    } catch {
      return null;
    }
  }

  if (
    !/function tools[^\n]{0,160}reasoning_effort[^\n]{0,160}(?:not supported|unsupported)/i.test(
      errorText,
    )
  ) {
    return null;
  }
  let body: Record<string, unknown>;
  try {
    body = JSON.parse(bodyText) as Record<string, unknown>;
  } catch {
    return null;
  }
  if (!Array.isArray(body.tools) || body.tools.length === 0) return null;
  body.reasoning_effort = "none";
  return JSON.stringify(body);
}

/**
 * Compatibility boundary for a third-party client (opencode) that already manufactures
 * of OpenAI JSON. The provider aliases are absorbed then reissued by the same
 * translator that all surfaces.
 */
export function translateLegacyAiChatBody(
  input: Record<string, unknown>,
  provider: AgentProviderId,
  reasoningEffort?: ReasoningLevel,
): Record<string, unknown> {
  const profile = getAgentProvider(provider)?.requestProfile;
  if (!profile) throw new Error(`Unknown AI provider: ${provider}`);
  const body = { ...input };

  const maxOutputTokens =
    positiveInt(body.max_completion_tokens) ??
    positiveInt(body.max_tokens) ??
    profile.defaultMaxOutputTokens;
  delete body.max_tokens;
  delete body.max_completion_tokens;
  if (maxOutputTokens !== undefined) body[profile.outputTokenField] = maxOutputTokens;

  delete body.reasoning;
  delete body.reasoning_effort;
  delete body.thinking;
  if (reasoningEffort) {
    Object.assign(
      body,
      providerReasoningFields(
        provider,
        typeof body.model === "string" ? body.model : "",
        { effort: reasoningEffort },
        maxOutputTokens,
        Array.isArray(body.tools) && body.tools.length > 0,
      ),
    );
  }

  normalizeProviderToolChoice(body, provider);
  normalizeAnthropicBody(body, provider);

  if (profile.usageAccounting && body.usage === undefined) body.usage = { include: true };
  // This border is only used by the opencode client, which streams all its rounds.
  // However, some versions omit `stream: true` from the intermediate body.
  if (profile.streamUsage && body.stream_options === undefined) {
    body.stream_options = { include_usage: true };
  }
  if (!profile.usageAccounting) delete body.usage;
  if (!profile.streamUsage) delete body.stream_options;
  return body;
}