Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions utils/apiKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const ENV_KEYS_TO_STRIP = [
/^VERTEX_SERVICE_ACCOUNT_JSON$/,
/^VERTEX_LOCATION$/,
/^VERTEX_MODEL_ID$/,
/^AZURE_RESOURCE_NAME$/,
/^AZURE_COGNITIVE_SERVICES_RESOURCE_NAME$/,
];

beforeEach(() => {
Expand Down Expand Up @@ -209,6 +211,65 @@ describe("validateAgentApiKey — Vertex routing", () => {
});
});

describe("validateAgentApiKey — Azure", () => {
const params = { agent: opencode, owner, name };
const model = "azure/gpt-5.6-sol";

it("passes when the model is in the authorized set", () => {
process.env.AZURE_RESOURCE_NAME = "my-resource";
process.env.AZURE_API_KEY = "azure-key";
expect(() =>
validateAgentApiKey({ ...params, model, authorized: new Set([model]) })
).not.toThrow();
});

it("names the missing resource name rather than claiming no key was found", () => {
process.env.AZURE_API_KEY = "azure-key";
expect(() => validateAgentApiKey({ ...params, model, authorized: new Set() })).toThrow(
"AZURE_RESOURCE_NAME"
);
});

it("names the missing api key", () => {
process.env.AZURE_RESOURCE_NAME = "my-resource";
expect(() => validateAgentApiKey({ ...params, model, authorized: new Set() })).toThrow(
"AZURE_API_KEY"
);
});

it("blames the deployment name when both vars are set but the model is unauthorized", () => {
// the case the generic copy gets wrong: credentials are fine, the Azure
// deployment is just named something other than the model id.
process.env.AZURE_RESOURCE_NAME = "my-resource";
process.env.AZURE_API_KEY = "azure-key";
let raised: Error | undefined;
try {
validateAgentApiKey({ ...params, model, authorized: new Set() });
} catch (error) {
raised = error as Error;
}
expect(raised?.message).toContain("deployment name mismatch");
expect(raised?.message).toContain("gpt-5.6-sol");
expect(raised?.message).not.toContain("no API key found");
});

it("uses the cognitive-services env var names for that provider", () => {
expect(() =>
validateAgentApiKey({
...params,
model: "azure-cognitive-services/gpt-5.6-sol",
authorized: new Set(),
})
).toThrow("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME");
});

it("leaves non-Azure providers on the generic missing-key error", () => {
expect(() =>
validateAgentApiKey({ ...params, model: "openai/gpt-5.6-sol", authorized: new Set() })
).toThrow("no API key found");
});
});

describe("isApiKeyAuthError", () => {
it("matches the missing-key marker thrown by validateAgentApiKey", () => {
expect(isApiKeyAuthError("no API key found. Pullfrog needs ...")).toBe(true);
Expand Down
89 changes: 89 additions & 0 deletions utils/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,47 @@ add the missing secret(s) to your GitHub repository at ${githubSecretsUrl}, then
for full setup instructions, see https://docs.pullfrog.com/vertex`;
}

/** models.dev exposes two Azure providers, differing only in env-var prefix.
* `azure` fronts `<resource>.openai.azure.com`; `azure-cognitive-services`
* fronts an AI Services / Foundry resource. */
const AZURE_PROVIDERS: Record<string, { resourceName: string; apiKey: string }> = {
azure: { resourceName: "AZURE_RESOURCE_NAME", apiKey: "AZURE_API_KEY" },
"azure-cognitive-services": {
resourceName: "AZURE_COGNITIVE_SERVICES_RESOURCE_NAME",
apiKey: "AZURE_COGNITIVE_SERVICES_API_KEY",
},
};

function buildAzureSetupError(params: {
owner: string;
name: string;
model: string;
deployment: string;
missing: string[];
envVars: { resourceName: string; apiKey: string };
}): string {
const githubSecretsUrl = `https://github.com/${params.owner}/${params.name}/settings/secrets/actions`;

if (params.missing.length > 0) {
return `Azure model selected but required configuration is missing: ${params.missing.join(", ")}.

add the missing secret(s) to your GitHub repository at ${githubSecretsUrl}, then reference them in your workflow's \`env:\` block:

${params.envVars.resourceName}: my-resource
${params.envVars.apiKey}: \${{ secrets.${params.envVars.apiKey} }}

\`${params.envVars.resourceName}\` is the resource name alone, not a URL — for \`https://my-resource.openai.azure.com\` it is \`my-resource\`.`;
}

return `Azure is configured (${params.envVars.resourceName} + ${params.envVars.apiKey} are both set) but OpenCode can't serve \`${params.model}\`.

the most likely cause is a deployment name mismatch. the Azure provider addresses a *deployment*, not a model, and uses the model id as the deployment name — so your deployment must be named exactly \`${params.deployment}\`.

check Azure AI Foundry → Deployments and either rename the deployment to \`${params.deployment}\`, or select the model whose id matches the name you already have.

if the name does match, confirm \`${params.envVars.resourceName}\` points at the resource hosting that deployment.`;
}

function hasEnvVar(name: string): boolean {
const value = process.env[name];
return typeof value === "string" && value.length > 0;
Expand Down Expand Up @@ -121,6 +162,44 @@ function validateVertexSetup(params: { owner: string; name: string }): void {
}
}

/**
* Azure arrives as a raw models.dev specifier (`azure/<model-id>`) rather than
* a curated slug, so there's no `routing` discriminant to branch on — the
* provider prefix is the only signal. Like Bedrock/Vertex the auth shape is
* multi-var, and unlike them there's a second failure mode that looks
* identical from `opencode models`: the provider addresses a *deployment* and
* uses the model id as the deployment name, so a differently-named deployment
* is indistinguishable from a missing key unless we say so explicitly.
*
* Only called once the caller has established the model is unauthorized, so
* this always throws for an Azure provider. Non-Azure providers return so the
* generic missing-key error still applies.
*/
function validateAzureSetup(params: {
owner: string;
name: string;
model: string;
provider: string;
}): void {
const envVars = AZURE_PROVIDERS[params.provider];
if (!envVars) return;

const missing: string[] = [];
if (!hasEnvVar(envVars.apiKey)) missing.push(envVars.apiKey);
if (!hasEnvVar(envVars.resourceName)) missing.push(envVars.resourceName);

throw new Error(
buildAzureSetupError({
owner: params.owner,
name: params.name,
model: params.model,
deployment: params.model.slice(params.model.indexOf("/") + 1),
missing,
envVars,
})
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Azure errors skip PR comment rendering

Medium Severity

validateAzureSetup throws messages that lack both the missing-key marker and a pass-through marker like MODEL_ACCESS_MARKER, so renderRunError treats them as generic failures. The PR comment collapses to a one-line logs link, and the job summary frames the setup guidance as an unexpected error. Before this change, Azure failures went through buildMissingApiKeyError and were mirrored onto the PR comment.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 21fa4cf. Configure here.

}

/**
* Validate that the resolved model can actually be served by the chosen
* agent. For routing slugs (Bedrock / Vertex) the auth shape is multi-var
Expand Down Expand Up @@ -165,6 +244,16 @@ export function validateAgentApiKey(params: {

if (params.agent.name === "opencode") {
if (params.authorized.has(params.model)) return;
// azure carries no curated alias, so it never reaches the `routing`
// branches above. its config gaps and its deployment-name mismatch both
// surface here as "unauthorized", which the generic copy misdiagnoses as
// a missing key. no-ops for every other provider.
validateAzureSetup({
owner: params.owner,
name: params.name,
model: params.model,
provider: params.model.slice(0, params.model.indexOf("/")).toLowerCase(),
});
throw new Error(
buildMissingApiKeyError({ owner: params.owner, name: params.name, model: params.model })
);
Expand Down