Ctrl K
ComunidadGuías…

utils/subscriptionProbe.ts

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

Archivo original ↗Preguntar a KiBot
import { z } from "zod";
import { preflightClaudeSubscription } from "./claudeSubscription.ts";
import { parseCodexAuthBody } from "./codexOAuth.ts";
import { verifyCredential } from "./credentialCheck.ts";
import type { SubscriptionName } from "./subscriptionCredentials.ts";
import { parseXaiAuthBody } from "./xaiOAuth.ts";

const identitySchema = z.object({
  email: z.string().email().optional(),
  sub: z.string().optional(),
  user_id: z.string().optional(),
  account_id: z.string().optional(),
  email_verified: z.boolean().optional(),
  plan_type: z.string().optional(),
});
const codexWindowSchema = z
  .object({ used_percent: z.number().optional(), reset_at: z.number().optional() })
  .nullish();
const codexUsageSchema = z.object({
  plan_type: z.string().optional(),
  rate_limit: z
    .object({
      allowed: z.boolean(),
      limit_reached: z.boolean(),
      primary_window: codexWindowSchema,
      secondary_window: codexWindowSchema,
    })
    .optional(),
  credits: z
    .object({
      has_credits: z.boolean().optional(),
      unlimited: z.boolean().optional(),
      overage_limit_reached: z.boolean().optional(),
    })
    .nullish(),
  spend_control: z.object({ reached: z.boolean().optional() }).nullish(),
});

/**
 * the provider's answer about one credential. only `rejected` and `exhausted`
 * advance the chain; `unknown` never changes who pays. `plan` is the ChatGPT plan
 * `wham/usage` reported, which an upgrade changes long before the token's claim.
 */
export type ProbeVerdict =
  | { status: "usable"; plan?: string | undefined }
  | { status: "unknown" }
  | { status: "rejected"; detail: string }
  | { status: "exhausted"; detail: string; resetAt: Date | undefined; plan?: string | undefined };

const unknown = { status: "unknown" } as const;

export class SubscriptionCredentialError extends Error {}

export async function subscriptionIdentity(input: { name: SubscriptionName; value: string }) {
  const empty = { email: null, subject: null, plan: null };
  if (input.name === "CLAUDE_CODE_OAUTH_TOKEN") return empty;
  const codex = input.name === "CODEX_AUTH_JSON" ? parseCodexAuthBody(input.value) : null;
  const grok = input.name === "GROK_AUTH_JSON" ? parseXaiAuthBody(input.value) : null;
  const token = codex?.tokens.access_token ?? grok?.tokens.access_token;
  if (!token) throw new SubscriptionCredentialError("invalid subscription credential");
  const response = await readSubscriptionEndpoint({
    url: codex ? "https://chatgpt.com/backend-api/wham/usage" : "https://auth.x.ai/oauth2/userinfo",
    token,
    accountId: codex?.tokens.account_id,
  });
  if (response?.status === 401)
    throw new SubscriptionCredentialError("subscription credential was rejected");
  if (!response?.ok) return empty;
  const parsed = identitySchema.safeParse(await response.json().catch(() => null));
  if (!parsed.success) return empty;
  const subject = parsed.data.sub ?? parsed.data.user_id;
  return {
    email: parsed.data.email_verified === false ? null : (parsed.data.email ?? null),
    subject: subject ? `${subject}:${parsed.data.account_id ?? ""}` : null,
    plan: parsed.data.plan_type ?? null,
  };
}

async function readSubscriptionEndpoint(input: {
  url: string;
  token: string;
  accountId?: string | undefined;
  grokBilling?: boolean;
}) {
  const headers = new Headers({ authorization: `Bearer ${input.token}` });
  if (input.accountId) headers.set("ChatGPT-Account-Id", input.accountId);
  if (input.grokBilling) headers.set("x-xai-token-auth", "xai-grok-cli");
  return fetch(input.url, {
    headers,
    redirect: "error",
    signal: AbortSignal.timeout(10_000),
  }).catch(() => null);
}

/** unknown quota is not a reason to skip a credential or change who pays. */
export async function probeSubscription(input: {
  name: SubscriptionName;
  value: string;
  model?: string | undefined;
}): Promise<ProbeVerdict> {
  if (input.name === "CLAUDE_CODE_OAUTH_TOKEN") {
    const result = await preflightClaudeSubscription({ token: input.value, model: input.model });
    // preflight fails open on a 5xx or 400; only a 2xx says the token works
    if (result.usable)
      return result.status !== undefined && result.status < 300 ? { status: "usable" } : unknown;
    return result.status === 429
      ? { status: "exhausted", detail: result.reason, resetAt: result.resetAt }
      : { status: "rejected", detail: result.reason };
  }
  const codex = input.name === "CODEX_AUTH_JSON" ? parseCodexAuthBody(input.value) : null;
  const grok = input.name === "GROK_AUTH_JSON" ? parseXaiAuthBody(input.value) : null;
  const auth = codex ?? grok;
  if (!auth) return { status: "rejected", detail: "the stored credential is malformed" };
  if (auth.refresh_rejected_at)
    return { status: "rejected", detail: `refresh rejected at ${auth.refresh_rejected_at}` };
  if (grok && input.model)
    return probeInference({
      name: "XAI_API_KEY",
      value: grok.tokens.access_token,
      model: input.model,
    });
  const response = await readSubscriptionEndpoint({
    url: codex
      ? "https://chatgpt.com/backend-api/wham/usage"
      : "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
    token: auth.tokens.access_token,
    accountId: codex?.tokens.account_id,
    grokBilling: !!grok,
  });
  if (response?.status === 401) return { status: "rejected", detail: "rejected with 401" };
  if (!response?.ok) return unknown;
  const body: unknown = await response.json().catch(() => null);
  // billing balance is not the subscription's remaining allowance.
  if (!codex) return unknown;
  const parsed = codexUsageSchema.safeParse(body);
  if (!parsed.success || !parsed.data.rate_limit) return unknown;
  const limit = parsed.data.rate_limit;
  const plan = parsed.data.plan_type;
  if (limit.allowed && !limit.limit_reached) return { status: "usable", plan };
  // workspace credits keep serving past the plan window until spent or capped
  const credits = parsed.data.credits;
  if (
    (credits?.has_credits || credits?.unlimited) &&
    !credits.overage_limit_reached &&
    !parsed.data.spend_control?.reached
  )
    return { status: "usable", plan };
  // the full window decides when the plan works again
  const windows = [limit.primary_window, limit.secondary_window];
  const full = windows.filter((window) => (window?.used_percent ?? 0) >= 100);
  const resets = (full.length ? full : windows).flatMap((window) =>
    window?.reset_at ? [window.reset_at] : []
  );
  return {
    status: "exhausted",
    detail: "the ChatGPT plan's usage limit is reached",
    resetAt: resets.length ? new Date(Math.max(...resets) * 1000) : undefined,
    plan,
  };
}

/** probe the chosen model, not a models-list permission a restricted key may lack. */
export async function probeInference(input: {
  name: string;
  value: string;
  model: string;
}): Promise<ProbeVerdict> {
  const anthropic = input.name === "ANTHROPIC_API_KEY";
  const openai = input.name === "OPENAI_API_KEY";
  if (!anthropic && !openai && input.name !== "XAI_API_KEY") {
    const result = await verifyCredential({ envVar: input.name, value: input.value });
    return result === "dead"
      ? { status: "rejected", detail: "rejected by its provider" }
      : result === "alive"
        ? { status: "usable" }
        : unknown;
  }
  const headers = new Headers({ "content-type": "application/json" });
  if (anthropic) {
    headers.set("x-api-key", input.value);
    headers.set("anthropic-version", "2023-06-01");
  } else headers.set("authorization", `Bearer ${input.value}`);
  const response = await fetch(
    anthropic
      ? "https://api.anthropic.com/v1/messages"
      : openai
        ? "https://api.openai.com/v1/responses"
        : "https://api.x.ai/v1/chat/completions",
    {
      method: "POST",
      headers,
      body: JSON.stringify(
        openai
          ? { model: input.model, input: "Reply OK.", max_output_tokens: 16, store: false }
          : {
              model: input.model,
              messages: [{ role: "user", content: "Reply OK." }],
              max_tokens: 1,
            }
      ),
      redirect: "error",
      signal: AbortSignal.timeout(10_000),
    }
  ).catch(() => null);
  if (response?.status === 400 && input.name === "XAI_API_KEY") {
    const error = z
      .object({ error: z.string() })
      .safeParse(await response.json().catch(() => null));
    return error.success && error.data.error.startsWith("Incorrect API key provided.")
      ? { status: "rejected", detail: "rejected with 400: incorrect API key" }
      : unknown;
  }
  // a 429 is usually a rate limit, which the run itself can wait out; only a spent OpenAI quota is final
  if (response?.status === 429 && openai) {
    const error = z
      .object({ error: z.object({ code: z.string() }) })
      .safeParse(await response.json().catch(() => null));
    return error.success && error.data.error.code === "insufficient_quota"
      ? { status: "exhausted", detail: "refused with 429: insufficient quota", resetAt: undefined }
      : unknown;
  }
  if (response?.status === 401 || response?.status === 403)
    return {
      status: "rejected",
      detail: `rejected with ${response.status}${await providerReason(response)}`,
    };
  await response?.body?.cancel();
  if (!response) return unknown;
  if (response.ok) return { status: "usable" };
  if (response.status === 402)
    return { status: "exhausted", detail: "refused with 402", resetAt: undefined };
  return unknown;
}

/** a key out of credit and a revoked one both answer 403; only the body says which. */
async function providerReason(response: Response) {
  const body = z
    .object({ error: z.union([z.string(), z.object({ message: z.string() })]) })
    .safeParse(await response.json().catch(() => null));
  if (!body.success) return "";
  const error = body.data.error;
  return `: ${(typeof error === "string" ? error : error.message).slice(0, 300)}`;
}