Ctrl K
ComunidadGuías…

utils/credentialPool.ts

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

Archivo original ↗Preguntar a KiBot
import * as yes from "yes";
import { autoSelectModel } from "../agents/opencodeShared.ts";
import {
  CLAUDE_CODE_ONLY_CREDENTIALS,
  getModelEnvVars,
  getModelProvider,
  getProviderGatewayUrl,
  modelAliases,
  resolveCliModel,
  stripProviderPrefix,
} from "../models.ts";
import { resolveAgent } from "./agent.ts";
import { apiFetch } from "./apiFetch.ts";
import {
  buildCredentialPoolRefusedError,
  buildSecretsUnavailableError,
  type RefusedCredential,
} from "./apiKeys.ts";
import { log } from "./cli.ts";
import {
  canInstallSubscription,
  clearInstalledSubscription,
  installCodexAuth,
  installXaiAuth,
} from "./codexHome.ts";
import { sanitizeSecret } from "./normalizeEnv.ts";
import { authorizeModel } from "./openCodeModels.ts";
import type { RunContextData } from "./runContextData.ts";
import { maskSecret, saveSecretState } from "./secretCommands.ts";
import {
  type CredentialAccess,
  type CredentialCandidate,
  selectedCredentialSchema,
  subscriptionNameSchema,
} from "./subscriptionCredentials.ts";
import { type ProbeVerdict, probeInference, probeSubscription } from "./subscriptionProbe.ts";

const receipts: Record<string, string> = {};
const workflowCredentials: Record<string, string> = {};

function saveReceipts() {
  for (const receipt of Object.values(receipts)) maskSecret(receipt);
  saveSecretState("credential_receipts", JSON.stringify(receipts));
}

const select = yes.mutation({
  run: async (
    input: { access: CredentialAccess; candidate: CredentialCandidate },
    ctx: yes.Context
  ) => {
    const response = await apiFetch({
      path: "/api/runtime/credentials",
      method: "POST",
      headers: {
        authorization: `Bearer ${input.access.token}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({ id: input.candidate.id }),
      signal: ctx.signal,
    });
    if (response.status === 404) return null;
    if (!response.ok) throw new Error(buildSecretsUnavailableError(), { cause: response.status });
    return selectedCredentialSchema.parse(await response.json());
  },
  retry: (error, attempt) =>
    error instanceof Error &&
    typeof error.cause === "number" &&
    error.cause >= 400 &&
    error.cause < 500
      ? -1
      : yes.backoff([250, 1000], attempt),
  timeout: 35_000,
});

function subscriptionForModel(model: string) {
  if (!model.includes("/")) return null;
  const provider = getModelProvider(model);
  return provider === "anthropic"
    ? "CLAUDE_CODE_OAUTH_TOKEN"
    : provider === "openai"
      ? "CODEX_AUTH_JSON"
      : provider === "xai"
        ? "GROK_AUTH_JSON"
        : null;
}

/**
 * load one subscription per provider for model discovery; keep the pool unflattened. runs
 * after the account secrets are injected: `injected` names the ones that came from storage,
 * so everything else set here is a workflow credential.
 */
export async function initializeCredentialPool(input: {
  ctx: RunContextData;
  slug: string | undefined;
  injected: string[];
  /** subscriptions keyless auth displaced: loading one back would outrank it. */
  exclude: string[];
}) {
  const access = input.ctx.credentialAccess;
  if (!access) return;
  const model = discoveryModel(input.slug);
  maskSecret(access.token);
  const names = new Set([
    ...access.candidates.map((candidate) => candidate.name),
    ...subscriptionNameSchema.options,
    "ANTHROPIC_API_KEY",
    "OPENAI_API_KEY",
    "XAI_API_KEY",
  ]);
  for (const name of names) {
    const value = process.env[name];
    if (value && !input.injected.includes(name)) workflowCredentials[name] = value;
  }
  for (const name of subscriptionNameSchema.options) {
    if (model && subscriptionForModel(model) !== name) continue;
    if (workflowCredentials[name] || input.exclude.includes(name)) continue;
    const candidate = access.candidates.find((item) => item.name === name);
    if (!candidate) continue;
    const selected = await select({ access, candidate }).catch(() => {
      log.warning(`» could not load ${name} for model discovery; selection will retry if needed`);
      return null;
    });
    if (!selected) continue;
    const value = sanitizeSecret(name, selected.value);
    if (!value) continue;
    process.env[name] = value;
    receipts[name] = selected.receipt;
  }
  saveReceipts();
}

/**
 * the configured model, for choosing which subscription to load. unlike `resolveModel` it
 * never reads a routing slug's env var, so a missing one throws later inside main's error
 * rendering rather than here: a routing slug resolves to its bare sentinel and loads nothing.
 */
function discoveryModel(slug: string | undefined) {
  const value = process.env.PULLFROG_MODEL?.trim() || slug?.trim();
  if (!value) return undefined;
  return resolveCliModel(value) ?? (value.includes("/") ? value : undefined);
}

export function resolvePoolModel(input: { codexAgent: boolean }) {
  const agent = resolveAgent({
    model: undefined,
    proxyModel: undefined,
    codexAgent: input.codexAgent,
  });
  if (agent.name === "opencode") return autoSelectModel();
  const provider = agent.name === "claude" ? "anthropic" : "openai";
  return modelAliases.find(
    (alias) =>
      alias.provider === provider &&
      alias.preferred &&
      !alias.hidden &&
      !alias.fallback &&
      !alias.routing
  )?.resolve;
}

export async function selectConfiguredCredential(input: {
  ctx: RunContextData;
  model: string | undefined;
}) {
  const access = input.ctx.credentialAccess;
  const model = input.model;
  if (!access || !model?.includes("/") || getProviderGatewayUrl(model)) return false;
  const subscription = subscriptionForModel(model);
  const names = getModelEnvVars(model).filter((name) => name !== subscription);
  if (subscription) names.unshift(subscription);
  // a subscription this runner cannot install would displace a working API key, then fail as no key.
  // so would a Claude one under PULLFROG_AGENT=opencode, which reads only ANTHROPIC_API_KEY
  const opencodePinned = process.env.PULLFROG_AGENT?.trim() === "opencode";
  const presentable = (name: string) =>
    canInstallSubscription(name) &&
    !(opencodePinned && CLAUDE_CODE_ONLY_CREDENTIALS.includes(name));
  const candidates = access.candidates.filter(
    (candidate) => names.includes(candidate.name) && presentable(candidate.name)
  );
  // nothing stored to choose between: the workflow credential runs exactly as it did before pools
  if (!candidates.length) return false;
  // a subscription pays before any API key, whatever its scope: claude.ts has always stripped
  // the key once the subscription passes its preflight. the sort is stable, so the workflow
  // still leads within each group.
  const options = [
    ...names
      .filter((name) => workflowCredentials[name] && presentable(name))
      .map((name) => ({ name, candidate: undefined })),
    ...candidates.map((candidate) => ({ name: candidate.name, candidate })),
  ].sort((a, b) => Number(b.name === subscription) - Number(a.name === subscription));
  const refused: RefusedCredential[] = [];
  for (const option of options) {
    const selected = option.candidate
      ? await select({ access, candidate: option.candidate })
      : { name: option.name, value: workflowCredentials[option.name], receipt: undefined };
    if (!selected) continue;
    const from = option.candidate ? `${option.candidate.source} scope` : "the workflow";
    const verdict = await probe({ ...selected, model });
    if (selected.receipt) await reportVerdict({ receipt: selected.receipt, verdict });
    if (usable(verdict)) {
      activate({ names, ...selected, model });
      if (option.candidate) log.info(`» selected ${option.name} from ${from}`);
      return true;
    }
    refused.push({ name: option.name, source: option.candidate?.source ?? "workflow", verdict });
    log.info(`» ${option.name} from ${from} unavailable; trying the next credential`);
  }
  throw new Error(
    buildCredentialPoolRefusedError({
      model,
      refused,
      owner: input.ctx.repo.owner,
      name: input.ctx.repo.name,
    })
  );
}

/**
 * a stored credential's value for a side call (`ask_model`), loaded without activating it: the
 * first candidate of that name, in the order startup selection tries them. never call it for a
 * name the run already holds — selecting a subscription again can rotate the chain it is using.
 */
export async function loadStoredCredential(input: { access: CredentialAccess; name: string }) {
  const candidate = input.access.candidates.find((item) => item.name === input.name);
  if (!candidate) return null;
  const selected = await select({ access: input.access, candidate });
  return selected && sanitizeSecret(input.name, selected.value);
}

async function probe(input: { name: string; value: string; model: string }) {
  const subscription = subscriptionNameSchema.safeParse(input.name);
  const model = stripProviderPrefix(input.model);
  return subscription.success
    ? probeSubscription({ name: subscription.data, value: input.value, model })
    : probeInference({ ...input, model });
}

function usable(
  verdict: ProbeVerdict
): verdict is Extract<ProbeVerdict, { status: "usable" | "unknown" }> {
  return verdict.status !== "rejected" && verdict.status !== "exhausted";
}

/** best-effort and bounded: a slow or failed report never fails the run. */
async function reportVerdict(input: { receipt: string; verdict: ProbeVerdict }) {
  if (input.verdict.status === "unknown") return;
  await apiFetch({
    path: "/api/runtime/credentials/status",
    method: "POST",
    headers: { authorization: `Bearer ${input.receipt}`, "content-type": "application/json" },
    body: JSON.stringify(input.verdict),
    signal: AbortSignal.timeout(5_000),
  }).catch(() => log.debug("» could not record a credential verdict"));
}

function activate(input: {
  names: string[];
  name: string;
  value: string;
  receipt: string | undefined;
  model: string;
}) {
  for (const name of input.names) {
    delete process.env[name];
    delete receipts[name];
    if (name === "CODEX_AUTH_JSON" || name === "GROK_AUTH_JSON") clearInstalledSubscription(name);
  }
  const value = sanitizeSecret(input.name, input.value);
  if (!value) throw new Error("configured credential was empty");
  process.env[input.name] = value;
  if (input.receipt) receipts[input.name] = input.receipt;
  installCodexAuth();
  installXaiAuth();
  saveReceipts();
  authorizeModel(input.model);
}