utils/modelAccess.ts
Rama main · réplica de lectura del repositorio original.
/**
* Model-access gate for explicitly-requested per-run models (`--opus`,
* `--model=<slug>`). An explicit model the run can't serve hard-fails *before*
* the agent starts with one consistent, reason-tailored error.
*
* The provenance split lives upstream: `payload.modelExplicit` is true only for
* a flag in the triggerer's own @pullfrog prompt. A standing default (repo
* setting, a model flag in org/repo `baseInstructions`, or a trigger's fallback
* instructions) keeps `modelExplicit = false` and never reaches the error
* branches here — a missing key for a standing default surfaces through
* `validateAgentApiKey`'s missing-key error instead (#938).
*
* `decideModelAccess` is pure (no env / IO) so the branching is unit-testable;
* `buildModelAccessError` renders the user-facing markdown that `main.ts`
* throws and `runErrorRenderer.ts` re-surfaces on both run surfaces.
*/
import {
AZURE_PROVIDER,
isOssAllowedModel,
OPENAI_COMPATIBLE_PROVIDER,
ossFundedModelNames,
resolveOpenRouterModel,
} from "../models.ts";
import { pickModelLink, providerLink } from "./apiKeys.ts";
import { renderFailure } from "./failureBody.ts";
export type ModelAccessReason = "oss" | "byok_no_key" | "router";
export type ModelAccessDecision =
| { kind: "ok" }
/** route through the Router/OSS proxy, re-targeted to the requested model. */
| { kind: "proxy"; target: string }
/** run BYOK with the requested model — clear the minted proxy target. */
| { kind: "byok" }
| { kind: "error"; reason: ModelAccessReason };
/**
* decide whether an (already-resolved) requested model can run, and how.
*
* - non-explicit / no model → `ok` (caller's missing-key validation applies).
* - proxy active + OSS → the funded subsidy target and anything else on
* `OSS_MODEL_ALLOWLIST` route; a model off the list needs the repo's own key
* (`byok`) else `error("oss")`. the allowlist arm is what keeps
* `--model=<allowlisted>` from hard-failing on a pick the console offers.
* - proxy active + Router → honor any Router-servable model (`proxy`); fall to
* BYOK when locally authorized, else `error("router")`.
* - no proxy → the resolved model must be locally authorized, else
* `error("byok_no_key")`.
*/
export function decideModelAccess(input: {
modelExplicit: boolean;
model: string | undefined;
oss: boolean;
proxyActive: boolean;
subsidyTarget: string | undefined;
resolvedModel: string | undefined;
authorized: Set<string>;
}): ModelAccessDecision {
if (!input.modelExplicit || !input.model) return { kind: "ok" };
// raw routing slugs (bedrock/vertex — no provider/model slash) and the
// provider-prefixed backends that are absent from the opencode `authorized`
// snapshot (openai-compatible always; azure whenever the deployment name isn't
// also a catalog model id) are validated by their own setup checks.
// discriminate on the RESOLVED specifier, matching `validateAgentApiKey`: both
// the catalog slug and a raw `<provider>/<id>` specifier resolve to the same
// prefixed form, so one check covers both and the two gates can't disagree
// about what they'll admit.
const byokAuthorized =
!!input.resolvedModel &&
(input.authorized.has(input.resolvedModel) ||
!input.resolvedModel.includes("/") ||
input.resolvedModel.startsWith(`${OPENAI_COMPATIBLE_PROVIDER}/`) ||
input.resolvedModel.startsWith(`${AZURE_PROVIDER}/`));
if (input.proxyActive) {
const target = resolveOpenRouterModel(input.model);
if (input.oss) {
if (target && (target === input.subsidyTarget || isOssAllowedModel(input.model))) {
return { kind: "proxy", target };
}
if (byokAuthorized) return { kind: "byok" };
return { kind: "error", reason: "oss" };
}
if (target) return { kind: "proxy", target };
if (byokAuthorized) return { kind: "byok" };
return { kind: "error", reason: "router" };
}
if (byokAuthorized) return { kind: "ok" };
return { kind: "error", reason: "byok_no_key" };
}
/** marker on the throw message so `runErrorRenderer` can reclassify it. */
export const MODEL_ACCESS_MARKER = "requested model is not available";
/** display names of the OSS-funded set, for the `oss` failure copy. */
function ossAllowedLabels(): string {
return ossFundedModelNames()
.map((name) => `\`${name}\``)
.join(", ");
}
/**
* render the model-access failure body (used for both the thrown error and the
* PR comment / job summary). one shape, reason-branched why → CTA.
*/
export function buildModelAccessError(input: {
reason: ModelAccessReason;
model: string;
owner: string;
name: string;
}): string {
const why: Record<ModelAccessReason, string> = {
oss: `This repo runs on Pullfrog's OSS subsidy, which funds a set of pre-approved models: ${ossAllowedLabels()}. Pick one of those, or connect your own provider to run a different one.`,
byok_no_key: `This run has no API key for \`${input.model}\`. Connect one, or pick a model you already have a key for.`,
router:
"Pullfrog Router can't serve this model. Pick a Router-supported model, or connect your own provider to run it directly.",
};
return renderFailure({
headline: `The ${MODEL_ACCESS_MARKER}: \`${input.model}\`.`,
detail: why[input.reason],
links: [pickModelLink(input), providerLink(input.owner)],
});
}