diff --git a/packages/docs/src/content/docs/config/models.mdx b/packages/docs/src/content/docs/config/models.mdx index 60b6e2a7..5be8125e 100644 --- a/packages/docs/src/content/docs/config/models.mdx +++ b/packages/docs/src/content/docs/config/models.mdx @@ -40,20 +40,49 @@ Examples: | --- | --- | | `openai/gpt-5.5` | `WARDEN_OPENAI_API_KEY` | | `anthropic/claude-sonnet-4-6` | `WARDEN_ANTHROPIC_API_KEY` | +| `google/` | `WARDEN_GEMINI_API_KEY` | | `fireworks/accounts/fireworks/models/kimi-k2p6` | `WARDEN_FIREWORKS_API_KEY` | | `groq/llama-3.3-70b-versatile` | `WARDEN_GROQ_API_KEY` | | `openrouter/meta-llama/llama-3.3-70b-instruct` | `WARDEN_OPENROUTER_API_KEY` | +| `vercel-ai-gateway/` | `WARDEN_AI_GATEWAY_API_KEY` or `WARDEN_VERCEL_AI_GATEWAY_API_KEY` | | `together/meta-llama/Llama-3.3-70B-Instruct-Turbo` | `WARDEN_TOGETHER_API_KEY` | +| `cloudflare-workers-ai/@cf/` | `WARDEN_CLOUDFLARE_API_KEY` + `WARDEN_CLOUDFLARE_ACCOUNT_ID` | +| `cloudflare-ai-gateway/workers-ai/@cf/` | `WARDEN_CLOUDFLARE_API_KEY` + `WARDEN_CLOUDFLARE_ACCOUNT_ID` + `WARDEN_CLOUDFLARE_GATEWAY_ID` | +| `cloudflare-ai-gateway/` | `WARDEN_CLOUDFLARE_API_KEY` + `WARDEN_CLOUDFLARE_ACCOUNT_ID` + `WARDEN_CLOUDFLARE_GATEWAY_ID` | +| `cloudflare-ai-gateway/` | `WARDEN_CLOUDFLARE_API_KEY` + `WARDEN_CLOUDFLARE_ACCOUNT_ID` + `WARDEN_CLOUDFLARE_GATEWAY_ID` | Warden splits Pi selectors at the first `/`. The provider comes before it and the provider-specific model ID comes after it, so model IDs may contain additional slashes. +**Notes on specific providers:** + +- **Google Gemini:** Pi's provider name is `google`, not `gemini`. The credential env var is + `GEMINI_API_KEY` (or `WARDEN_GEMINI_API_KEY`). Use `google/`, not `gemini/`. + +- **Vercel AI Gateway:** Pi's env var is `AI_GATEWAY_API_KEY`. The canonical Warden alias is + `WARDEN_AI_GATEWAY_API_KEY`. `WARDEN_VERCEL_AI_GATEWAY_API_KEY` is also accepted as a + convenience alias and will be bridged automatically. + +- **Cloudflare Workers AI:** Requires both `CLOUDFLARE_API_KEY` and `CLOUDFLARE_ACCOUNT_ID`. + Use the `@cf/provider/model-id` model format: e.g. `cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6`. + +- **Cloudflare AI Gateway:** Routes OpenAI/Anthropic models using their native IDs + (e.g. `cloudflare-ai-gateway/gpt-5.1`, `cloudflare-ai-gateway/claude-sonnet-4-5`). + Workers AI models use the `workers-ai/@cf/...` prefix. Upstream credentials are managed in + Cloudflare's AI Gateway dashboard (unified billing, stored BYOK) — Warden's provider API keys + are not forwarded through the gateway. + **Credential convention:** Set `WARDEN_{PROVIDER}_API_KEY` to authenticate with a provider. Warden mirrors these to the native `{PROVIDER}_API_KEY` expected by each SDK at runtime. If you already have a native provider key set (e.g. `OPENAI_API_KEY`), Warden will use it directly and the `WARDEN_`-prefixed form is not required. +**Multi-part credentials:** Some providers require additional env vars beyond an API key. +Cloudflare providers require `CLOUDFLARE_ACCOUNT_ID` (or `WARDEN_CLOUDFLARE_ACCOUNT_ID`). +Cloudflare AI Gateway additionally requires `CLOUDFLARE_GATEWAY_ID` (or `WARDEN_CLOUDFLARE_GATEWAY_ID`). +Warden mirrors these automatically when the `WARDEN_`-prefixed form is set. + ## Claude Runtime Models When `runtime = "claude"`, use the model IDs accepted by Claude Code: diff --git a/packages/warden/src/action/triggers/executor.ts b/packages/warden/src/action/triggers/executor.ts index 2a85158d..be153ff4 100644 --- a/packages/warden/src/action/triggers/executor.ts +++ b/packages/warden/src/action/triggers/executor.ts @@ -28,7 +28,7 @@ import { SkillRunnerError } from '../../sdk/errors.js'; import type { Semaphore } from '../../utils/index.js'; import { Verbosity } from '../../cli/output/verbosity.js'; import type { ProviderFailureCircuitBreaker } from '../../sdk/circuit-breaker.js'; -import { assertValidPiModelSelectors } from '../../sdk/runtimes/model-selectors.js'; +import { assertValidPiModelSelectors, findMissingCloudflareEnv, missingCloudflareEnvMessage } from '../../sdk/runtimes/model-selectors.js'; import { captureActionTriggerError } from '../error-reporting.js'; /** Log-mode output for CI: no TTY, no color. */ @@ -135,6 +135,11 @@ export async function executeTrigger( try { assertValidPiModelSelectors([trigger]); + const missingCloudflare = findMissingCloudflareEnv([trigger]); + if (missingCloudflare) { + throw new Error(missingCloudflareEnvMessage(missingCloudflare)); + } + const taskOptions: SkillTaskOptions = { name: trigger.name, displayName: trigger.skill, diff --git a/packages/warden/src/action/workflow/schedule.ts b/packages/warden/src/action/workflow/schedule.ts index 21abd6b1..0890f845 100644 --- a/packages/warden/src/action/workflow/schedule.ts +++ b/packages/warden/src/action/workflow/schedule.ts @@ -15,7 +15,7 @@ import type { LayeredSkillRootsByName, ResolvedTrigger } from '../../config/load import type { ScheduleConfig } from '../../config/schema.js'; import { buildScheduleEventContext } from '../../event/schedule-context.js'; import { runSkill } from '../../sdk/runner.js'; -import { assertValidPiModelSelectors } from '../../sdk/runtimes/model-selectors.js'; +import { assertValidPiModelSelectors, findMissingCloudflareEnv, missingCloudflareEnvMessage } from '../../sdk/runtimes/model-selectors.js'; import { createOrUpdateIssue } from '../../output/github-issues.js'; import { shouldFail, countFindingsAtOrAbove, countSeverity } from '../../triggers/matcher.js'; import { resolveSkillAsync } from '../../skills/loader.js'; @@ -179,6 +179,11 @@ async function runScheduleWorkflowInner( try { assertValidPiModelSelectors([resolved]); + const missingCloudflare = findMissingCloudflareEnv([resolved]); + if (missingCloudflare) { + throw new Error(missingCloudflareEnvMessage(missingCloudflare)); + } + // Build context from paths filter const patterns = resolved.filters?.paths ?? ['**/*']; const ignorePatterns = resolved.filters?.ignorePaths; diff --git a/packages/warden/src/cli/main.ts b/packages/warden/src/cli/main.ts index f1bc5bb4..7dce7de9 100644 --- a/packages/warden/src/cli/main.ts +++ b/packages/warden/src/cli/main.ts @@ -7,8 +7,12 @@ import type { SkillDefinition, WardenConfig, Effort } from '../config/schema.js' import { verifyAuth, type WardenAuthenticationError, type SkillRunnerOptions, type ChunkAnalysisResult } from '../sdk/runner.js'; import { findInvalidPiModelSelector as findInvalidPiModelSelectorTarget, + findMissingCloudflareEnv, invalidPiModelSelectorMessage, + missingCloudflareEnvMessage, + piModelSelectorTip, type InvalidPiModelSelector, + type MissingCloudflareEnv, } from '../sdk/runtimes/model-selectors.js'; import { mapExtractionErrorCode } from '../sdk/errors.js'; import { aggregateAuxiliaryUsageAttribution, mergeAuxiliaryUsage } from '../sdk/usage.js'; @@ -732,7 +736,25 @@ export function findInvalidPiModelSelector( function reportInvalidPiModelSelector(reporter: Reporter, invalid: InvalidPiModelSelector): void { reporter.error(invalidPiModelSelectorMessage(invalid)); - reporter.tip('Set a Pi model selector such as anthropic/claude-sonnet-4-6.'); + reporter.tip(piModelSelectorTip(invalid.model)); +} + +function reportMissingCloudflareEnv(reporter: Reporter, missing: MissingCloudflareEnv): void { + reporter.error(missingCloudflareEnvMessage(missing)); + const tip = missing.missing.map((v) => `export WARDEN_${v}=...`).join(' '); + reporter.tip(tip); +} + +function emitMissingCloudflareEnvRunLog( + repoPath: string, + options: CLIOptions, + missing: MissingCloudflareEnv, +): void { + emitEmptyRunLog(repoPath, options, { + code: 'auth_failed', + message: missingCloudflareEnvMessage(missing), + timestamp: new Date().toISOString(), + }); } function emitInvalidPiModelSelectorRunLog( @@ -1188,6 +1210,12 @@ export async function runSkills( emitInvalidPiModelSelectorRunLog(repoPath ?? cwd, options, invalidModelSelector); return 1; } + const missingCloudflare = findMissingCloudflareEnv(specs.map((s) => ({ ...s.runnerOptions }))); + if (missingCloudflare) { + reportMissingCloudflareEnv(reporter, missingCloudflare); + emitMissingCloudflareEnvRunLog(repoPath ?? cwd, options, missingCloudflare); + return 1; + } let tasks: SkillTaskOptions[]; const concurrency = options.parallel ?? DEFAULT_CONCURRENCY; try { @@ -1518,6 +1546,12 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise ({ ...s.runnerOptions }))); + if (missingCloudflare) { + reportMissingCloudflareEnv(reporter, missingCloudflare); + emitMissingCloudflareEnvRunLog(repoPath, options, missingCloudflare); + return 1; + } let tasks: SkillTaskOptions[]; const concurrency = options.parallel ?? config.runner?.concurrency ?? DEFAULT_CONCURRENCY; try { diff --git a/packages/warden/src/sdk/analyze.ts b/packages/warden/src/sdk/analyze.ts index e2398ad3..4f6a12b6 100644 --- a/packages/warden/src/sdk/analyze.ts +++ b/packages/warden/src/sdk/analyze.ts @@ -1144,9 +1144,17 @@ async function runSkillAnalysis( { code: 'provider_unavailable' }, ); } + // Include the first failure message so users can diagnose config problems + // without inspecting per-hunk logs. Messages are already sanitized by the + // time they reach failureMessage. + const firstFailureMsg = analysisFailures[0]?.message; + const failureDetail = firstFailureMsg + ? ` First failure: ${firstFailureMsg.slice(0, 500)}.` + : ''; throw new SkillRunnerError( - `All ${totalHunks} chunk${totalHunks === 1 ? '' : 's'} failed to analyze. ` + - `This usually indicates an authentication problem. ${allHunksFailedGuidance(options.runtime)}`, + `All ${totalHunks} chunk${totalHunks === 1 ? '' : 's'} failed to analyze.${failureDetail} ` + + `This usually indicates a runtime, model, or provider configuration problem. ` + + `${allHunksFailedGuidance(options.runtime)}`, { code: 'all_hunks_failed' }, ); } diff --git a/packages/warden/src/sdk/errors.ts b/packages/warden/src/sdk/errors.ts index babc0bb6..48cb61f8 100644 --- a/packages/warden/src/sdk/errors.ts +++ b/packages/warden/src/sdk/errors.ts @@ -67,8 +67,12 @@ https://console.anthropic.com/ for API keys`; /** User-friendly error message for authentication failures (Pi runtime) */ const PI_AUTH_GUIDANCE = ` - export WARDEN_MODEL=provider/model-id # e.g. openai/gpt-5.5 - export WARDEN_{PROVIDER}_API_KEY=... # WARDEN-prefixed key for that provider + export WARDEN_MODEL=provider/model-id # e.g. openai/gpt-5.5, cloudflare-workers-ai/@cf/model + export WARDEN_{PROVIDER}_API_KEY=... # WARDEN-prefixed API key for that provider + +Note: Some providers require additional env vars beyond an API key. +Cloudflare providers also require CLOUDFLARE_ACCOUNT_ID (or WARDEN_CLOUDFLARE_ACCOUNT_ID). +Cloudflare AI Gateway additionally requires CLOUDFLARE_GATEWAY_ID (or WARDEN_CLOUDFLARE_GATEWAY_ID). See https://warden.sentry.dev/config/models for provider selectors and credential names.`; diff --git a/packages/warden/src/sdk/runtimes/model-selectors.test.ts b/packages/warden/src/sdk/runtimes/model-selectors.test.ts index 8bcd43c8..5cf0c6b2 100644 --- a/packages/warden/src/sdk/runtimes/model-selectors.test.ts +++ b/packages/warden/src/sdk/runtimes/model-selectors.test.ts @@ -1,9 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { assertValidPiModelSelectors, isPiModelSelector } from './model-selectors.js'; +import { + assertValidPiModelSelectors, + findMissingCloudflareEnv, + invalidPiModelSelectorMessage, + isPiModelSelector, + piModelSelectorTip, +} from './model-selectors.js'; describe('isPiModelSelector', () => { - it('accepts provider-prefixed model IDs', () => { + it('accepts standard provider/model selectors', () => { expect(isPiModelSelector('openai/gpt-5.5')).toBe(true); + expect(isPiModelSelector('anthropic/claude-sonnet-4-6')).toBe(true); + expect(isPiModelSelector('groq/llama-3.3-70b-versatile')).toBe(true); + expect(isPiModelSelector('openrouter/meta-llama/llama-3.3-70b-instruct')).toBe(true); + }); + + it('accepts Pi provider names with hyphens', () => { + expect(isPiModelSelector('cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6')).toBe(true); + expect(isPiModelSelector('cloudflare-ai-gateway/@cf/moonshotai/kimi-k2.6')).toBe(true); + expect(isPiModelSelector('amazon-bedrock/us.anthropic.claude-sonnet-4')).toBe(true); + }); + + it('accepts provider-specific model IDs with internal slashes', () => { expect(isPiModelSelector('fireworks/accounts/fireworks/models/kimi-k2p6')).toBe(true); }); @@ -12,6 +30,104 @@ describe('isPiModelSelector', () => { expect(isPiModelSelector('/gpt-5.5')).toBe(false); expect(isPiModelSelector('fireworks/')).toBe(false); }); + + it('rejects Cloudflare native model IDs used without a Pi provider prefix', () => { + expect(isPiModelSelector('@cf/moonshotai/kimi-k2.6')).toBe(false); + expect(isPiModelSelector('@cf/meta/llama-3.3-70b')).toBe(false); + }); + + it('rejects provider-native namespace prefixes generally', () => { + // Any segment starting with @ is not a valid Pi provider name + expect(isPiModelSelector('@vendor/some-model')).toBe(false); + }); +}); + +describe('invalidPiModelSelectorMessage', () => { + it('emits targeted guidance for Cloudflare Workers AI native model IDs', () => { + const msg = invalidPiModelSelectorMessage({ option: 'model', model: '@cf/moonshotai/kimi-k2.6' }); + expect(msg).toContain('cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6'); + expect(msg).toContain('CLOUDFLARE_API_KEY'); + expect(msg).toContain('CLOUDFLARE_ACCOUNT_ID'); + }); + + it('emits generic namespace guidance for non-Cloudflare @ prefixes', () => { + const msg = invalidPiModelSelectorMessage({ option: 'model', model: '@vendor/some-model' }); + expect(msg).toContain('@vendor/'); + expect(msg).toContain('provider-native'); + }); + + it('emits targeted guidance for gemini/... (wrong provider name for Google)', () => { + const msg = invalidPiModelSelectorMessage({ option: 'model', model: 'gemini/gemini-2.5-flash' }); + expect(msg).toContain('google/gemini-2.5-flash'); + expect(msg).toContain('WARDEN_GEMINI_API_KEY'); + expect(msg).not.toContain('must use provider/model format'); + }); + + it('emits invalid-provider-segment guidance when provider fails Pi naming rules', () => { + // e.g. uppercase, underscores — valid shape but invalid provider segment format + const msg = invalidPiModelSelectorMessage({ option: 'model', model: 'OPENAI/gpt-5.5' }); + expect(msg).toContain('invalid provider segment'); + expect(msg).toContain('OPENAI'); + expect(msg).toContain('lowercase'); + expect(msg).not.toContain('could not find provider or model'); + expect(msg).not.toContain('must use provider/model format'); + }); + + it('emits provider-or-model-not-found guidance for valid-shape selectors (unknown or stale model)', () => { + // Covers both: unknown provider name and known provider with stale/wrong model ID + const msg = invalidPiModelSelectorMessage({ option: 'model', model: 'unknown-provider/gpt-5.5' }); + expect(msg).toContain('could not find provider or model'); + expect(msg).toContain('unknown-provider/gpt-5.5'); + expect(msg).not.toContain('must use provider/model format'); + expect(msg).not.toContain('unknown Pi provider'); + }); + + it('uses same could-not-find wording for known providers with stale model IDs', () => { + const msg = invalidPiModelSelectorMessage({ option: 'model', model: 'openai/nonexistent-model' }); + expect(msg).toContain('could not find provider or model'); + expect(msg).toContain('openai/nonexistent-model'); + // Does not claim openai is an unknown provider + expect(msg).not.toContain('unknown Pi provider'); + }); + + it('emits standard format guidance for plain model IDs without a provider', () => { + const msg = invalidPiModelSelectorMessage({ option: 'model', model: 'gpt-5.5' }); + expect(msg).toContain('provider/model format'); + expect(msg).toContain('gpt-5.5'); + }); + + it('includes the spec name when provided', () => { + const msg = invalidPiModelSelectorMessage({ + specName: 'security-review', + option: 'auxiliaryModel', + model: '@cf/meta/llama-3.3', + }); + expect(msg).toContain('security-review'); + }); +}); + +describe('piModelSelectorTip', () => { + it('gives Cloudflare-specific repair tip for @cf/ models', () => { + const tip = piModelSelectorTip('@cf/moonshotai/kimi-k2.6'); + expect(tip).toContain('cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6'); + expect(tip).toContain('CLOUDFLARE_ACCOUNT_ID'); + }); + + it('gives generic namespace tip for non-Cloudflare @ models', () => { + const tip = piModelSelectorTip('@vendor/model'); + expect(tip).toContain('provider-name/@vendor/model'); + }); + + it('gives targeted Google tip for gemini/... models', () => { + const tip = piModelSelectorTip('gemini/gemini-2.5-flash'); + expect(tip).toContain('google/gemini-2.5-flash'); + expect(tip).toContain('WARDEN_GEMINI_API_KEY'); + }); + + it('gives standard tip for plain model IDs', () => { + const tip = piModelSelectorTip('gpt-5.5'); + expect(tip).toContain('anthropic/claude-sonnet-4-6'); + }); }); describe('assertValidPiModelSelectors', () => { @@ -25,4 +141,139 @@ describe('assertValidPiModelSelectors', () => { }, ])).not.toThrow(); }); + + it('allows cloudflare-workers-ai selectors with native model IDs', () => { + expect(() => assertValidPiModelSelectors([ + { + runtime: 'pi', + model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6', + }, + ])).not.toThrow(); + }); + + it('throws for Cloudflare native model IDs used without a Pi provider prefix', () => { + expect(() => assertValidPiModelSelectors([ + { + runtime: 'pi', + model: '@cf/moonshotai/kimi-k2.6', + }, + ])).toThrow(/cloudflare-workers-ai\/@cf\/moonshotai\/kimi-k2\.6/); + }); +}); + +describe('findMissingCloudflareEnv', () => { + it('returns undefined when no Cloudflare provider is configured', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'openai/gpt-5.5' }], + { OPENAI_API_KEY: 'key' }, + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when cloudflare-workers-ai has all required env vars', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6' }], + { CLOUDFLARE_API_KEY: 'key', CLOUDFLARE_ACCOUNT_ID: 'acct' }, + ); + expect(result).toBeUndefined(); + }); + + it('accepts WARDEN_CLOUDFLARE_ACCOUNT_ID as an alternative', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6' }], + { CLOUDFLARE_API_KEY: 'key', WARDEN_CLOUDFLARE_ACCOUNT_ID: 'acct' }, + ); + expect(result).toBeUndefined(); + }); + + it('reports missing CLOUDFLARE_ACCOUNT_ID for cloudflare-workers-ai', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6' }], + { CLOUDFLARE_API_KEY: 'key' }, + ); + expect(result).toMatchObject({ + provider: 'cloudflare-workers-ai', + missing: ['CLOUDFLARE_ACCOUNT_ID'], + }); + }); + + it('reports both missing vars for cloudflare-ai-gateway', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6' }], + { CLOUDFLARE_API_KEY: 'key' }, + ); + expect(result).toMatchObject({ + provider: 'cloudflare-ai-gateway', + missing: expect.arrayContaining(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID']), + }); + }); + + it('reports only CLOUDFLARE_GATEWAY_ID when account ID is present', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6' }], + { CLOUDFLARE_API_KEY: 'key', CLOUDFLARE_ACCOUNT_ID: 'acct' }, + ); + expect(result).toMatchObject({ + provider: 'cloudflare-ai-gateway', + missing: ['CLOUDFLARE_GATEWAY_ID'], + }); + }); + + it('accepts WARDEN_CLOUDFLARE_GATEWAY_ID as an alternative', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6' }], + { + CLOUDFLARE_API_KEY: 'key', + CLOUDFLARE_ACCOUNT_ID: 'acct', + WARDEN_CLOUDFLARE_GATEWAY_ID: 'gw', + }, + ); + expect(result).toBeUndefined(); + }); + + it('skips non-Pi runtimes', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'claude', model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6' }], + {}, + ); + expect(result).toBeUndefined(); + }); + + it('skips targets with invalid selectors (already caught by other checks)', () => { + const result = findMissingCloudflareEnv( + [{ runtime: 'pi', model: '@cf/moonshotai/kimi-k2.6' }], + {}, + ); + expect(result).toBeUndefined(); + }); + + it('detects missing account ID when Cloudflare provider is in auxiliaryModel but not model', () => { + const result = findMissingCloudflareEnv( + [{ + runtime: 'pi', + model: 'anthropic/claude-sonnet-4-6', + auxiliaryModel: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6', + }], + { CLOUDFLARE_API_KEY: 'key' }, + ); + expect(result).toMatchObject({ + provider: 'cloudflare-workers-ai', + missing: ['CLOUDFLARE_ACCOUNT_ID'], + }); + }); + + it('detects missing account ID when Cloudflare provider is in synthesisModel only', () => { + const result = findMissingCloudflareEnv( + [{ + runtime: 'pi', + model: 'openai/gpt-5.5', + synthesisModel: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.6', + }], + { CLOUDFLARE_API_KEY: 'key' }, + ); + expect(result).toMatchObject({ + provider: 'cloudflare-workers-ai', + missing: ['CLOUDFLARE_ACCOUNT_ID'], + }); + }); }); diff --git a/packages/warden/src/sdk/runtimes/model-selectors.ts b/packages/warden/src/sdk/runtimes/model-selectors.ts index 8706ca84..7cceb01c 100644 --- a/packages/warden/src/sdk/runtimes/model-selectors.ts +++ b/packages/warden/src/sdk/runtimes/model-selectors.ts @@ -1,9 +1,23 @@ /** * Return true when a Pi model selector uses provider/model-id syntax. + * + * Valid Pi selectors split at the first slash: the segment before it is the Pi + * provider name and the segment after is the provider-specific model ID. + * + * Pi provider names are lowercase alphanumeric strings with hyphens, e.g. + * `openai`, `anthropic`, `cloudflare-workers-ai`. Provider-native namespaces + * such as Cloudflare's `@cf/...` model IDs must be prefixed with the Pi + * provider name to form a valid Warden selector. */ export function isPiModelSelector(model: string): boolean { const slashIndex = model.indexOf('/'); - return slashIndex > 0 && slashIndex < model.length - 1; + if (slashIndex <= 0 || slashIndex >= model.length - 1) return false; + + // Pi provider names are lowercase letters, digits, and hyphens. + // Reject provider-native namespace prefixes (e.g. @cf/...) and other + // non-conforming provider segments that would silently fail at model lookup. + const provider = model.slice(0, slashIndex); + return /^[a-z0-9][a-z0-9-]*$/.test(provider); } export type PiModelSelectorOption = 'model' | 'auxiliaryModel' | 'synthesisModel'; @@ -24,10 +38,101 @@ export interface InvalidPiModelSelector { /** * Format the user-facing error for an invalid Pi model selector. + * + * Emits targeted guidance for known misuse patterns: + * - @cf/... Cloudflare-native model IDs without a Pi provider prefix + * - Other @namespace/... provider-native IDs without a Pi provider prefix + * - Common wrong provider names (e.g. "gemini" instead of "google") + * - Valid-shape selectors with an unknown Pi provider name */ export function invalidPiModelSelectorMessage(invalid: InvalidPiModelSelector): string { const target = invalid.specName ? ` for ${invalid.specName}` : ''; - return `Pi runtime ${invalid.option}${target} must use provider/model format: ${invalid.model}`; + const { model } = invalid; + const slashIndex = model.indexOf('/'); + + // Cloudflare Workers AI native model IDs start with @cf/. Users sometimes + // set these directly instead of using the Warden Pi provider prefix. + if (model.startsWith('@cf/')) { + return ( + `Pi runtime ${invalid.option}${target} received a Cloudflare Workers AI native model ID: ${model}. ` + + `Use cloudflare-workers-ai/${model} as the Warden Pi selector instead. ` + + `Set CLOUDFLARE_API_KEY (or WARDEN_CLOUDFLARE_API_KEY) and CLOUDFLARE_ACCOUNT_ID ` + + `(or WARDEN_CLOUDFLARE_ACCOUNT_ID) for this provider.` + ); + } + + // Generic provider-native namespace prefix (@vendor/...) without a Pi provider. + if (model.startsWith('@') && slashIndex > 0) { + const namespace = model.slice(0, slashIndex + 1); + return ( + `Pi runtime ${invalid.option}${target} received a provider-native model ID: ${model}. ` + + `"${namespace}..." is a provider-native namespace, not a Pi provider name. ` + + `Prefix with the Pi provider name, e.g. provider-name/${model}. ` + + `See https://warden.sentry.dev/config/models for supported providers.` + ); + } + + // The model has provider/model shape (contains a slash with content on both sides). + // Distinguish between three sub-cases: + // (a) provider segment has invalid format (fails Pi naming rules) + // (b) known wrong provider alias (e.g. "gemini" instead of "google") + // (c) valid format and provider alias, but not found in Pi's registry + if (slashIndex > 0 && slashIndex < model.length - 1) { + const provider = model.slice(0, slashIndex); + + // (a) Provider segment contains characters Pi doesn't accept. + // Pi provider names use lowercase letters, digits, and hyphens only. + // This catches e.g. OPENAI/gpt-5.5 or My_Provider/model being rejected + // by the regex — the issue is the format, not a registry miss. + if (!/^[a-z0-9][a-z0-9-]*$/.test(provider)) { + return ( + `Pi runtime ${invalid.option}${target} has an invalid provider segment "${provider}" in ${model}. ` + + `Pi provider names use lowercase letters, digits, and hyphens (e.g. openai, cloudflare-workers-ai). ` + + `See https://warden.sentry.dev/config/models for supported providers and selectors.` + ); + } + + // (b) Google Gemini: Pi provider name is "google", env var is GEMINI_API_KEY. + // Users commonly guess "gemini/..." from the product name. + if (provider === 'gemini') { + const modelId = model.slice(slashIndex + 1); + return ( + `Pi runtime ${invalid.option}${target} received "gemini/..." but Google Gemini's Pi provider name is "google", not "gemini". ` + + `Use google/${modelId} as the selector and set WARDEN_GEMINI_API_KEY or GEMINI_API_KEY.` + ); + } + + // (c) Valid format, valid name, but provider or model not in Pi's registry. + // Covers both unknown providers and known providers with stale model IDs. + return ( + `Pi runtime ${invalid.option}${target} could not find provider or model: ${model}. ` + + `Verify the Pi provider name and model ID are correct. ` + + `See https://warden.sentry.dev/config/models for supported providers and selectors.` + ); + } + + return `Pi runtime ${invalid.option}${target} must use provider/model format: ${model}`; +} + +/** + * Return a contextual repair tip for an invalid Pi model selector. + * Used alongside invalidPiModelSelectorMessage to give actionable next steps. + */ +export function piModelSelectorTip(model: string): string { + if (model.startsWith('@cf/')) { + return `Use cloudflare-workers-ai/${model} and set CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID.`; + } + if (model.startsWith('@')) { + return 'Prefix the model with its Pi provider name, e.g. provider-name/@vendor/model-id.'; + } + const slashIndex = model.indexOf('/'); + if (slashIndex > 0) { + const provider = model.slice(0, slashIndex); + if (provider === 'gemini') { + return `Use google/${model.slice(slashIndex + 1)} (Pi provider name is "google") and set WARDEN_GEMINI_API_KEY.`; + } + } + return 'Set a Pi model selector such as anthropic/claude-sonnet-4-6 or google/gemini-2.5-flash.'; } /** @@ -43,6 +148,77 @@ export class InvalidPiModelSelectorError extends Error { } } +export interface MissingCloudflareEnv { + provider: string; + /** The env var names that are missing (native form, e.g. CLOUDFLARE_ACCOUNT_ID). */ + missing: string[]; +} + +/** + * Find required Cloudflare provider env vars that are not set. + * + * Cloudflare Workers AI requires CLOUDFLARE_ACCOUNT_ID in addition to an API + * key. Cloudflare AI Gateway additionally requires CLOUDFLARE_GATEWAY_ID. + * Neither can be inferred from model selectors alone and both must be set as + * environment variables (Pi does not read them from its auth.json). + * + * Both the native form (CLOUDFLARE_ACCOUNT_ID) and the Warden alias + * (WARDEN_CLOUDFLARE_ACCOUNT_ID) are accepted. + * + * Returns the first target with missing required vars, or undefined. + */ +export function findMissingCloudflareEnv( + targets: PiModelSelectorTarget[], + env: NodeJS.ProcessEnv = process.env, +): MissingCloudflareEnv | undefined { + for (const target of targets) { + if ((target.runtime ?? 'pi') !== 'pi') continue; + + // Check each model lane independently. A target can mix providers across + // lanes (e.g. anthropic for `model`, cloudflare-workers-ai for + // `auxiliaryModel`), so each lane that resolves to a Cloudflare provider + // must be validated independently. + for (const lane of ['model', 'auxiliaryModel', 'synthesisModel'] as const) { + const model = target[lane]; + if (!model || !isPiModelSelector(model)) continue; + + const slashIndex = model.indexOf('/'); + const provider = model.slice(0, slashIndex); + + if (provider !== 'cloudflare-workers-ai' && provider !== 'cloudflare-ai-gateway') continue; + + const missing: string[] = []; + + if (!env['CLOUDFLARE_ACCOUNT_ID'] && !env['WARDEN_CLOUDFLARE_ACCOUNT_ID']) { + missing.push('CLOUDFLARE_ACCOUNT_ID'); + } + + if (provider === 'cloudflare-ai-gateway') { + if (!env['CLOUDFLARE_GATEWAY_ID'] && !env['WARDEN_CLOUDFLARE_GATEWAY_ID']) { + missing.push('CLOUDFLARE_GATEWAY_ID'); + } + } + + if (missing.length > 0) { + return { provider, missing }; + } + } + } + + return undefined; +} + +/** + * Format the user-facing error for missing required Cloudflare env vars. + */ +export function missingCloudflareEnvMessage(missing: MissingCloudflareEnv): string { + const vars = missing.missing.map((v) => `${v} (or WARDEN_${v})`).join(', '); + return ( + `Pi provider ${missing.provider} requires additional environment variables: ${vars}. ` + + `Set these alongside CLOUDFLARE_API_KEY before running Warden.` + ); +} + /** * Find the first Pi runner option using a model ID that is not provider/model. */ diff --git a/packages/warden/src/sdk/runtimes/pi.ts b/packages/warden/src/sdk/runtimes/pi.ts index 9827fc0f..42de2375 100644 --- a/packages/warden/src/sdk/runtimes/pi.ts +++ b/packages/warden/src/sdk/runtimes/pi.ts @@ -131,6 +131,23 @@ function parseModelSelector(model: string): PiModelSelector { }; } +/** + * Throw a classified invalid-selector error when Pi's registry cannot resolve + * a provider/model pair. + * + * Converts the generic "Pi model not found" error into an + * InvalidPiModelSelectorError so it is classified as `invalid_model_selector` + * rather than `unknown`. This (a) opens the circuit breaker immediately and + * (b) gives the user an actionable message instead of "This usually indicates + * an authentication problem." + */ +function throwModelNotFound(model: string): never { + // Cloudflare-specific: @cf/ is a provider-native model ID, not a Pi selector. + // isPiModelSelector now rejects these at preflight, but guard here too in + // case the runtime is invoked directly without the CLI preflight. + throw new InvalidPiModelSelectorError({ option: 'model', model }); +} + function legacyApiKeyProvider(model: string | undefined): string | undefined { if (!model) { return 'anthropic'; @@ -160,9 +177,7 @@ function resolvePiModel( const { provider, modelId } = parseModelSelector(model); const resolved = registry.find(provider, modelId); if (!resolved) { - throw new Error( - `Pi model not found: ${model}. Use provider/model, for example openai/gpt-5.5.` - ); + throwModelNotFound(model); } return resolved; } diff --git a/packages/warden/src/utils/index.test.ts b/packages/warden/src/utils/index.test.ts index 90b9695a..20894580 100644 --- a/packages/warden/src/utils/index.test.ts +++ b/packages/warden/src/utils/index.test.ts @@ -118,4 +118,84 @@ describe('bridgeWardenProviderApiKeyEnv', () => { expect(env['MODEL']).toBeUndefined(); expect(env['SENTRY_DSN']).toBeUndefined(); }); + + it('bridges WARDEN_CLOUDFLARE_ACCOUNT_ID to CLOUDFLARE_ACCOUNT_ID', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_CLOUDFLARE_API_KEY: 'cf-key', + WARDEN_CLOUDFLARE_ACCOUNT_ID: 'cf-account', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['CLOUDFLARE_API_KEY']).toBe('cf-key'); + expect(env['CLOUDFLARE_ACCOUNT_ID']).toBe('cf-account'); + }); + + it('bridges WARDEN_CLOUDFLARE_GATEWAY_ID to CLOUDFLARE_GATEWAY_ID', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_CLOUDFLARE_API_KEY: 'cf-key', + WARDEN_CLOUDFLARE_ACCOUNT_ID: 'cf-account', + WARDEN_CLOUDFLARE_GATEWAY_ID: 'cf-gateway', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['CLOUDFLARE_ACCOUNT_ID']).toBe('cf-account'); + expect(env['CLOUDFLARE_GATEWAY_ID']).toBe('cf-gateway'); + }); + + it('does not overwrite native CLOUDFLARE_ACCOUNT_ID when Warden alias is also set', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_CLOUDFLARE_ACCOUNT_ID: 'warden-account', + CLOUDFLARE_ACCOUNT_ID: 'native-account', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['CLOUDFLARE_ACCOUNT_ID']).toBe('native-account'); + }); + + it('does not bridge Cloudflare account ID when the Warden alias is empty', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_CLOUDFLARE_ACCOUNT_ID: '', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['CLOUDFLARE_ACCOUNT_ID']).toBeUndefined(); + }); + + it('bridges WARDEN_VERCEL_AI_GATEWAY_API_KEY to AI_GATEWAY_API_KEY', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_VERCEL_AI_GATEWAY_API_KEY: 'vercel-key', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['AI_GATEWAY_API_KEY']).toBe('vercel-key'); + }); + + it('does not overwrite AI_GATEWAY_API_KEY when already set via WARDEN_AI_GATEWAY_API_KEY', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_AI_GATEWAY_API_KEY: 'direct-alias', + WARDEN_VERCEL_AI_GATEWAY_API_KEY: 'provider-alias', + }; + + bridgeWardenProviderApiKeyEnv(env); + + // Generic API-key bridge runs first: WARDEN_AI_GATEWAY_API_KEY -> AI_GATEWAY_API_KEY + // Explicit bridge then does not overwrite the already-set value. + expect(env['AI_GATEWAY_API_KEY']).toBe('direct-alias'); + }); + + it('does not overwrite native AI_GATEWAY_API_KEY when Vercel alias is set', () => { + const env: NodeJS.ProcessEnv = { + WARDEN_VERCEL_AI_GATEWAY_API_KEY: 'warden-key', + AI_GATEWAY_API_KEY: 'native-key', + }; + + bridgeWardenProviderApiKeyEnv(env); + + expect(env['AI_GATEWAY_API_KEY']).toBe('native-key'); + }); }); diff --git a/packages/warden/src/utils/index.ts b/packages/warden/src/utils/index.ts index 4b57e832..5470b555 100644 --- a/packages/warden/src/utils/index.ts +++ b/packages/warden/src/utils/index.ts @@ -60,9 +60,38 @@ export function getAnthropicApiKey(): string | undefined { } /** - * Mirrors WARDEN-prefixed provider API keys to the env names expected by SDKs. + * Additional WARDEN-prefixed env vars to bridge for providers that require + * non-API-key credentials alongside their API key. + * + * These cannot be inferred from the WARDEN_X_API_KEY → X_API_KEY pattern + * because they do not follow the _API_KEY suffix convention. + * + * Each entry is [warden-alias, native-env-var]. + */ +const WARDEN_PROVIDER_ENV_BRIDGE = [ + // Cloudflare Workers AI: requires account ID in addition to API key + ['WARDEN_CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_ACCOUNT_ID'], + // Cloudflare AI Gateway: additionally requires a gateway ID + ['WARDEN_CLOUDFLARE_GATEWAY_ID', 'CLOUDFLARE_GATEWAY_ID'], + // Vercel AI Gateway: Pi uses AI_GATEWAY_API_KEY, not VERCEL_AI_GATEWAY_API_KEY. + // Accept the provider-name-derived alias so users following the WARDEN_{PROVIDER}_API_KEY + // convention can discover the correct native env var through Warden's bridging. + ['WARDEN_VERCEL_AI_GATEWAY_API_KEY', 'AI_GATEWAY_API_KEY'], +] as const; + +/** + * Mirrors WARDEN-prefixed provider credentials to the env names expected by SDKs. + * + * Handles two classes of bridging: + * + * 1. Generic API keys: WARDEN_X_API_KEY → X_API_KEY for any provider. + * 2. Provider-specific non-key vars: explicit list for credentials that + * providers require beyond their API key (e.g. Cloudflare account ID). + * + * Existing native env vars are never overwritten. */ export function bridgeWardenProviderApiKeyEnv(env: NodeJS.ProcessEnv = process.env): void { + // Bridge WARDEN_X_API_KEY → X_API_KEY for all providers for (const [key, value] of Object.entries(env)) { if (!value || !key.startsWith('WARDEN_') || !key.endsWith('_API_KEY')) { continue; @@ -73,4 +102,12 @@ export function bridgeWardenProviderApiKeyEnv(env: NodeJS.ProcessEnv = process.e env[providerKey] = value; } } + + // Bridge provider-specific non-key credentials + for (const [wardenKey, nativeKey] of WARDEN_PROVIDER_ENV_BRIDGE) { + const value = env[wardenKey]; + if (value && !env[nativeKey]) { + env[nativeKey] = value; + } + } } diff --git a/skills/warden/references/config-schema.md b/skills/warden/references/config-schema.md index 5747a670..230b3153 100644 --- a/skills/warden/references/config-schema.md +++ b/skills/warden/references/config-schema.md @@ -126,8 +126,15 @@ Always skipped (cannot be overridden): | Variable | Purpose | |----------|---------| | `WARDEN_MODEL` | Default model (lowest priority) | -| `WARDEN_OPENAI_API_KEY` | OpenAI API key for OpenAI Pi models | -| `WARDEN_ANTHROPIC_API_KEY` | Anthropic API key for Anthropic Pi models or Claude runtime | +| `WARDEN_OPENAI_API_KEY` | OpenAI API key (Pi provider `openai`); bridged to `OPENAI_API_KEY` | +| `WARDEN_ANTHROPIC_API_KEY` | Anthropic API key (Pi provider `anthropic` or Claude runtime); bridged to `ANTHROPIC_API_KEY` | +| `WARDEN_GEMINI_API_KEY` | Google Gemini API key (Pi provider `google`); bridged to `GEMINI_API_KEY`. **Note:** Pi provider name is `google`, not `gemini`; use `google/` selectors. | +| `WARDEN_OPENROUTER_API_KEY` | OpenRouter API key (Pi provider `openrouter`); bridged to `OPENROUTER_API_KEY` | +| `WARDEN_AI_GATEWAY_API_KEY` | Vercel AI Gateway API key (Pi provider `vercel-ai-gateway`); bridged to `AI_GATEWAY_API_KEY` | +| `WARDEN_VERCEL_AI_GATEWAY_API_KEY` | Convenience alias for Vercel AI Gateway; bridged to `AI_GATEWAY_API_KEY`. Accepted in addition to `WARDEN_AI_GATEWAY_API_KEY`. | +| `WARDEN_CLOUDFLARE_API_KEY` | Cloudflare API key for `cloudflare-workers-ai` or `cloudflare-ai-gateway`; bridged to `CLOUDFLARE_API_KEY` | +| `WARDEN_CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (required for all Cloudflare providers); bridged to `CLOUDFLARE_ACCOUNT_ID` | +| `WARDEN_CLOUDFLARE_GATEWAY_ID` | Cloudflare AI Gateway ID (required for `cloudflare-ai-gateway`); bridged to `CLOUDFLARE_GATEWAY_ID` | | `WARDEN_STATE_DIR` | Override cache location (default: `~/.local/warden`) | | `WARDEN_SKILL_CACHE_TTL` | Cache TTL in seconds for unpinned remotes (default: 86400) |