diff --git a/package-lock.json b/package-lock.json index 1fcffc9f5f..a94eac55c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "@vercel/functions": "^3.1.4", "@xyflow/react": "^12.8.6", "ai": "^6.0.72", - "aieo": "^0.1.34", + "aieo": "^0.1.35", "autoprefixer": "^10.4.21", "axios": "1.10.0", "bcryptjs": "^3.0.2", @@ -15592,14 +15592,15 @@ } }, "node_modules/aieo": { - "version": "0.1.34", - "resolved": "https://registry.npmjs.org/aieo/-/aieo-0.1.34.tgz", - "integrity": "sha512-0ZV2LvbBd5N1pc69EYzrnSF5dfX517n4CFITOM08K/+QkpWNBquGnJIpGTz7SN1Xa2CdQ/YPNs94rRTyF4q7qw==", + "version": "0.1.35", + "resolved": "https://registry.npmjs.org/aieo/-/aieo-0.1.35.tgz", + "integrity": "sha512-jwM0nXrmxVPCCRMhEJ6ODmV71Q458NrMsZkX44eKPEJdV7NKFDZGjCR/dZ57JyjD4wA/wPlULaIR5WpkVP+rug==", "license": "ISC", "dependencies": { "@ai-sdk/anthropic": "3.0.105", "@ai-sdk/google": "3.0.67", "@ai-sdk/openai": "3.0.61", + "@ai-sdk/xai": "3.0.88", "@openrouter/ai-sdk-provider": "2.10.0", "ai": "6.0.175", "zod": "4.3.6" @@ -15683,6 +15684,56 @@ "node": ">=18" } }, + "node_modules/aieo/node_modules/@ai-sdk/xai": { + "version": "3.0.88", + "resolved": "https://registry.npmjs.org/@ai-sdk/xai/-/xai-3.0.88.tgz", + "integrity": "sha512-9jc7CT+6YxdPnkMF66jX/LmFoEcRI2Cjr5CTxZcrCA8f764NPF0mGtaNRv3M0OzYwBIppgZ3F0TNhADguHWLrg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "2.0.46", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.26" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/aieo/node_modules/@ai-sdk/xai/node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.46.tgz", + "integrity": "sha512-23ExGdy3p0Grfz3BAjCbIOc74TjQc5nHu72e0+kx3hshvScp32a4nnQlzzG4VT1bDZxa9yPNNUNyb5nN6vJHcQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.26" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/aieo/node_modules/@ai-sdk/xai/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.26", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.26.tgz", + "integrity": "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", diff --git a/package.json b/package.json index 3a927db409..41170d481f 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@vercel/functions": "^3.1.4", "@xyflow/react": "^12.8.6", "ai": "^6.0.72", - "aieo": "^0.1.34", + "aieo": "^0.1.35", "autoprefixer": "^10.4.21", "axios": "1.10.0", "bcryptjs": "^3.0.2", diff --git a/src/__tests__/unit/lib/ai/models-aieo-parity.test.ts b/src/__tests__/unit/lib/ai/models-aieo-parity.test.ts new file mode 100644 index 0000000000..29bdf00c6f --- /dev/null +++ b/src/__tests__/unit/lib/ai/models-aieo-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect, afterEach } from "vitest"; +import { PROVIDERS, hasApiKeyForProvider } from "aieo"; +import { PROVIDER_API_KEY_ENV_VARS, PROVIDER_DISPLAY_LABELS } from "@/lib/ai/models"; + +/** + * `src/lib/ai/models.ts` can't import aieo (it's pulled into client + * bundles), so it carries its own provider -> env-var table. This test + * pins that table to aieo's key lookup so a provider added to one side + * (e.g. xai) can't silently be missing or mis-keyed on the other. + */ +describe("models.ts provider table matches aieo", () => { + const saved: Record = {}; + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + test.each(PROVIDERS)("aieo provider %s has a hive env var and display label", (provider) => { + const enumKey = provider.toUpperCase(); + expect(PROVIDER_API_KEY_ENV_VARS[enumKey]).toBeTruthy(); + expect(PROVIDER_DISPLAY_LABELS[enumKey]).toBeTruthy(); + }); + + test.each(PROVIDERS)("setting hive's env var for %s satisfies aieo's key lookup", (provider) => { + const envVar = PROVIDER_API_KEY_ENV_VARS[provider.toUpperCase()]!; + saved[envVar] = process.env[envVar]; + + delete process.env[envVar]; + expect(hasApiKeyForProvider(provider)).toBe(false); + + process.env[envVar] = "parity-test-key"; + expect(hasApiKeyForProvider(provider)).toBe(true); + }); +}); diff --git a/src/__tests__/unit/lib/ai/runCanvasAgent-xai.test.ts b/src/__tests__/unit/lib/ai/runCanvasAgent-xai.test.ts deleted file mode 100644 index fe91140c08..0000000000 --- a/src/__tests__/unit/lib/ai/runCanvasAgent-xai.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Unit test: runCanvasAgent must never silently answer an xai/* model - * selection as Anthropic. aieo (^0.1.34, pinned) has no "xai" provider - * entry, so this path throws an explicit, user-visible error instead. - */ -import { describe, it, expect, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ db: {} })); -vi.mock("@/lib/pusher", () => ({ - pusherServer: { trigger: vi.fn() }, - getWorkspaceChannelName: vi.fn(() => "ch"), - PUSHER_EVENTS: { HIGHLIGHT_NODES: "highlight" }, -})); -vi.mock("@/lib/ai/askTools", () => ({ - askTools: vi.fn(() => ({})), - listConcepts: vi.fn(async () => ({ concepts: [] })), - createHasEndMarkerCondition: vi.fn(() => () => false), -})); -vi.mock("@/lib/ai/askToolsMulti", () => ({ askToolsMulti: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/workspaceConfig", () => ({ - buildWorkspaceConfigs: vi.fn(async () => [ - { - workspaceId: "ws-1", - userId: "user-1", - slug: "ws-slug", - swarmUrl: "https://swarm", - swarmApiKey: "key", - repoUrls: [], - pat: "pat", - description: "", - members: [], - currentUserGithubUsername: null, - }, - ]), - buildPublicWorkspaceConfig: vi.fn(), - fetchConceptsForWorkspaces: vi.fn(async () => ({})), -})); -vi.mock("@/lib/ai/connectionTools", () => ({ buildConnectionTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/canvasTools", () => ({ buildCanvasTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/initiativeTools", () => ({ buildInitiativeTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/researchTools", () => ({ buildResearchTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/htmlArtifactTools", () => ({ buildHtmlArtifactTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/infraTools", () => ({ buildInfraTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/graphWalkerTools", () => ({ buildGraphWalkerTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/graphWalkDispatchTools", () => ({ buildGraphWalkDispatchTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/workflowExplorerTools", () => ({ buildWorkflowExplorerTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/promptTools", () => ({ buildPromptTools: vi.fn(() => ({})) })); -vi.mock("@/lib/ai/conceptTools", () => ({ buildConceptTools: vi.fn(() => ({})) })); -vi.mock("@/lib/canvas/linkedWorkspaces", () => ({ - getLinkedWorkspacesForInitiative: vi.fn(() => []), -})); -vi.mock("@/lib/ai/message-sanitizer", () => ({ - sanitizeAndCompleteToolCalls: vi.fn(async (msgs: unknown) => msgs), -})); -vi.mock("@/lib/ai/provider", () => ({ - getModel: vi.fn(() => ({ modelId: "mock-model" })), - getApiKeyForProvider: vi.fn(() => "api-key"), -})); -vi.mock("aieo", () => ({ - getProviderOptions: vi.fn(() => ({})), - hasApiKeyForProvider: vi.fn(() => true), - PROVIDERS: ["anthropic", "google", "openai", "openrouter"], -})); -vi.mock("@/services/bifrost/orchestrator", () => ({ - getBifrostForLLM: vi.fn(async () => undefined), -})); -vi.mock("@/lib/ai/canvas-system-prompt", () => ({ - getCanvasSystemPrompt: vi.fn(async () => ({ value: "system", promptId: null })), -})); -vi.mock("@/lib/ai/capabilityGates", () => ({ - isPromptsCapabilityEnabledForOrg: vi.fn(async () => false), - isGraphWriteCapabilityEnabledForOrg: vi.fn(async () => false), - isCodeChangeCapabilityEnabledForOrg: vi.fn(async () => false), -})); -vi.mock("@/lib/constants/prompt", () => ({ - getMultiWorkspacePrefixMessages: vi.fn(() => []), - getQuickAskPrefixMessages: vi.fn(() => []), - buildCanvasScopeMessage: vi.fn(() => null), - getRoadmapCapabilitySnippet: vi.fn(() => ""), - getWhiteboardCapabilitySnippet: vi.fn(() => ""), - getPlannerCapabilitySnippet: vi.fn(() => ""), - getResearchCapabilitySnippet: vi.fn(() => ""), - getConnectionsCapabilitySnippet: vi.fn(() => ""), - getHtmlPagesCapabilitySnippet: vi.fn(() => ""), - getGraphWalkerCapabilitySnippet: vi.fn(() => ""), - getInfraCapabilitySnippet: vi.fn(() => ""), - getWorkflowsCapabilitySnippet: vi.fn(() => ""), - getPromptsCapabilitySnippet: vi.fn(() => ""), - getConceptsCapabilitySnippet: vi.fn(() => ""), - getCanvasPromptSuffix: vi.fn(() => ""), -})); - -const mockStreamText = vi.fn(); -vi.mock("ai", () => ({ - streamText: (...args: unknown[]) => mockStreamText(...args), - tool: vi.fn((t: unknown) => t), -})); - -import { runCanvasAgent } from "@/lib/ai/runCanvasAgent"; -import type { ModelMessage } from "ai"; - -describe("runCanvasAgent — xAI model selection", () => { - it("throws an explicit error for an xai/* modelName instead of silently falling back to anthropic", async () => { - await expect( - runCanvasAgent({ - userId: "user-1", - workspaceSlugs: ["ws-slug"], - messages: [{ role: "user", content: "hello" }] as ModelMessage[], - modelName: "xai/grok-4", - }), - ).rejects.toThrow(/xAI\/Grok is not yet supported/i); - - // The streamText call (and therefore any actual LLM invocation) - // must never happen for a rejected xai/* selection. - expect(mockStreamText).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index 1999916c72..aa31b3a821 100644 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -362,14 +362,14 @@ async function createAgentSession( } } - // xAI bypass: the Bifrost VK reconciler falls back to anthropic's - // provider suffix for any model prefix it doesn't recognize, and its - // provider allow-list doesn't include xai — routing an xai/* session - // through Bifrost today would mint a VK pointed at the wrong - // provider. Skip the call for xai/* and use the direct XAI_API_KEY - // resolved above instead. Remove once an `aieo` gateway path + a - // Bifrost-side xai provider config both exist (see - // src/services/task-workflow.ts for the matching bypass). + // xAI bypass: the Bifrost VK provider allow-list (DEFAULT_PROVIDERS in + // src/services/bifrost/constants.ts) doesn't include xai, and the + // swarm gateways have no xai provider key configured — routing an + // xai/* session through Bifrost today would fail. Skip the call for + // xai/* and use the direct XAI_API_KEY resolved above instead. Remove + // once the gateways carry an xai key and DEFAULT_PROVIDERS lists it + // (aieo already maps xai onto the /openai/v1 gateway path). See + // src/services/task-workflow.ts for the matching bypass. const isXaiModel = effectiveModel?.startsWith("xai/") ?? false; // Bifrost routing for the goose-side LLM calls. When the rollout diff --git a/src/app/org/[githubLogin]/_components/CanvasAgentSettingsPopover.tsx b/src/app/org/[githubLogin]/_components/CanvasAgentSettingsPopover.tsx index 7c7bd13570..828ed395be 100644 --- a/src/app/org/[githubLogin]/_components/CanvasAgentSettingsPopover.tsx +++ b/src/app/org/[githubLogin]/_components/CanvasAgentSettingsPopover.tsx @@ -70,22 +70,14 @@ export function CanvasAgentSettingsPopover({ }; }, []); - // Load the available models for the picker. xAI/Grok rows are excluded - // here specifically (not just left to the server-side key filter) — - // `aieo` (the canvas agent's LLM SDK, ^0.1.34) has no xai provider - // entry yet, so `runCanvasAgent` throws a visible error rather than - // silently answering as Anthropic if one is selected. Hiding it from - // this picker means users never hit that error in the first place. - // Remove this filter once aieo supports xai — see the "Add xAI" - // feature notes. + // Load the available models for the picker. /api/llm-models already + // filters out providers whose API key isn't configured server-side. useEffect(() => { let cancelled = false; fetch("/api/llm-models") .then((res) => (res.ok ? res.json() : null)) .then((data) => { - if (!cancelled && data?.models) { - setModels((data.models as LlmModelOption[]).filter((m) => m.provider !== "XAI")); - } + if (!cancelled && data?.models) setModels(data.models); }) .catch(() => { /* leave empty; picker stays hidden until a retry */ diff --git a/src/config/env.ts b/src/config/env.ts index 92bb55b546..cc58e2f73a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -166,7 +166,7 @@ export const optionalEnvVars = { // raw `process.env` read — so it's a typed, discoverable config value like // its sibling provider keys. Absence is a normal, expected state in any // environment that hasn't onboarded xAI yet: `/api/llm-models` filters out - // XAI rows when this is unset (see step 8 of the xAI feature), so pickers + // XAI rows when this is unset, so pickers // stay empty rather than erroring. XAI_API_KEY: process.env.XAI_API_KEY || "", } as const; diff --git a/src/lib/ai/models.ts b/src/lib/ai/models.ts index d909e61d51..f8bf0fa5df 100644 --- a/src/lib/ai/models.ts +++ b/src/lib/ai/models.ts @@ -61,16 +61,20 @@ export function isValidModel(model: unknown): model is string { return false; } -// Map LlmProvider enum values to their API key environment variables +// Map LlmProvider enum values to their API key environment variables. +// +// For the providers aieo knows about (anthropic/google/openai/openrouter/xai) +// these MUST match the env vars aieo's own key lookup reads — aieo is the +// source of truth for provider routing, and `runCanvasAgent` resolves keys +// through it directly. This file is imported by client components, so it +// can't import aieo itself; `models-aieo-parity.test.ts` guards the two +// tables against drifting apart. AWS_BEDROCK/OTHER are hive-only. export const PROVIDER_API_KEY_ENV_VARS: Record = { ANTHROPIC: "ANTHROPIC_API_KEY", OPENAI: "OPENAI_API_KEY", GOOGLE: "GOOGLE_API_KEY", AWS_BEDROCK: "AWS_BEDROCK_API_KEY", OPENROUTER: "OPENROUTER_API_KEY", - // Direct xAI credential — Grok models resolve to `xai/` and use - // this key instead of routing through OpenRouter. See the "Add xAI" - // feature notes for the Bifrost/canvas-agent gaps this doesn't cover yet. XAI: "XAI_API_KEY", OTHER: null, }; diff --git a/src/lib/ai/runCanvasAgent.ts b/src/lib/ai/runCanvasAgent.ts index b1452abf37..467a360ea6 100644 --- a/src/lib/ai/runCanvasAgent.ts +++ b/src/lib/ai/runCanvasAgent.ts @@ -697,22 +697,7 @@ export async function runCanvasAgent( // flag is on for the primary workspace — see `getBifrostForLLM` below. let provider: Provider = "anthropic"; if (modelName?.includes("/")) { - const rawPrefix = modelName.split("/")[0]; - // aieo (^0.1.34, the pinned version) has no "xai" entry in `PROVIDERS` - // — it's not just an unconfigured key, the provider itself doesn't - // exist on this path yet. Falling through to the generic branch - // below would silently answer a Grok selection as Anthropic, which - // is exactly the failure mode this feature must not have. Fail - // loudly instead. `CanvasAgentSettingsPopover` excludes xai/* rows - // from the picker, so this should only fire on a stale/tampered - // `chatAgentModel` preference. Remove this guard once `aieo` (or its - // replacement) supports xai — see the "Add xAI" feature notes. - if (rawPrefix === "xai") { - throw new Error( - `runCanvasAgent: model "${modelName}" is not available on this path — xAI/Grok is not yet supported by the canvas agent's LLM SDK (aieo). Choose a different model.`, - ); - } - const prefix = rawPrefix as Provider; + const prefix = modelName.split("/")[0] as Provider; if (!PROVIDERS.includes(prefix)) { console.warn( `[runCanvasAgent] model "${modelName}" has unsupported provider prefix "${prefix}"; falling back to anthropic default`, diff --git a/src/services/task-workflow.ts b/src/services/task-workflow.ts index 9fa8fb6d4a..337992fe80 100644 --- a/src/services/task-workflow.ts +++ b/src/services/task-workflow.ts @@ -853,15 +853,14 @@ export async function callStakworkAPI(params: { // tuning of ttlSeconds / maxCostUsd / maxSteps is intentionally // deferred to a follow-up so this initial wiring stays small. // - // xAI bypass: `reconcileBifrostVK` derives the `baseUrl` provider - // suffix from the model prefix and falls back to anthropic for any - // prefix it doesn't recognize, and `DEFAULT_PROVIDERS` doesn't list - // "xai" — so routing an `xai/*` selection through Bifrost today would - // mint a VK pointed at the wrong provider (or error). Skip the - // Bifrost call entirely for xai/* and fall through to the direct - // `vars.apiKey` (XAI_API_KEY) resolved above. Remove this bypass once - // an `aieo` version with an xAI gateway path AND a Bifrost-side xAI - // provider config both exist — until then this trades away per-agent + // xAI bypass: the Bifrost VK provider allow-list (`DEFAULT_PROVIDERS`) + // doesn't list "xai" and the swarm gateways have no xai provider key + // configured, so routing an `xai/*` selection through Bifrost today + // would fail. Skip the Bifrost call entirely for xai/* and fall + // through to the direct `vars.apiKey` (XAI_API_KEY) resolved above. + // Remove this bypass once the gateways carry an xai key and + // `DEFAULT_PROVIDERS` lists it (aieo already maps xai onto the + // /openai/v1 gateway path) — until then this trades away per-agent // cost attribution / macaroon observability for Grok runs only. const isXaiModel = effectiveModel?.startsWith("xai/") ?? false; const bifrost = isXaiModel