diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/gateway-tool-setup-dialog.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/gateway-tool-setup-dialog.tsx new file mode 100644 index 0000000000..78131991bc --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/_components/gateway-tool-setup-dialog.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Dialog } from "@base-ui/react/dialog"; +import { DEFAULT_GATEWAY_API_KEY_EXPIRY, type CreatedGatewayApiKey } from "@openwork/types/den/gateway-api-keys"; +import { KeyRound, Lock, Plug, Plus, X } from "lucide-react"; +import { useState } from "react"; +import { DenBrandMark } from "../../_components/ui/brand-mark"; +import { DenButton } from "../../_components/ui/button"; +import { expiryFromDays, formatExpiry, useCreateGatewayApiKey } from "./gateway-api-keys-data"; +import { CopyField, GATEWAY_TOOLS, GatewayToolSteps, type GatewayToolId } from "./gateway-tool-setup"; + +/** + * Library › Models › "Use OpenWork AI Gateway with other tools": one small + * logo button per tool. Each opens that tool's steps in a dialog, with a way + * to create a key there so every step shows the real key, ready to copy. + */ + +type Tool = (typeof GATEWAY_TOOLS)[number]; + +/** Shorter names for the button row, where the logo already says whose tool it is. */ +const BUTTON_LABELS: Partial> = { + codex: "Codex CLI", + "codex-app": "Codex app", + other: "Other", +}; + +function ToolMark({ tool, size }: { tool: Tool; size: "sm" | "md" }) { + const box = size === "sm" ? "h-5 w-5 rounded-[6px] border-0 bg-transparent" : "h-9 w-9 rounded-[10px]"; + const image = size === "sm" ? "h-[14px] w-[14px]" : "h-[18px] w-[18px]"; + if (!tool.slug) { + return ( + + + + ); + } + return ; +} + +/** The "Set up" row: a logo button per tool. */ +export function GatewayToolSetupRow({ enabled, baseUrl }: { enabled: boolean; baseUrl: string }) { + const [open, setOpen] = useState(null); + const tool = GATEWAY_TOOLS.find((entry) => entry.id === open) ?? null; + return ( +
+ Set up +
+ {GATEWAY_TOOLS.map((entry) => ( + + ))} +
+ setOpen(null)} /> +
+ ); +} + +function GatewayToolSetupDialog({ tool, enabled, baseUrl, onClose }: { tool: Tool | null; enabled: boolean; baseUrl: string; onClose: () => void }) { + return ( + { if (!next) onClose(); }}> + + + + {/* Keyed by tool and mounted only while open: closing forgets a key it showed. */} + {tool ? : null} + + + + ); +} + +function ToolSetup({ tool, enabled, baseUrl, onClose }: { tool: Tool; enabled: boolean; baseUrl: string; onClose: () => void }) { + const [created, setCreated] = useState(null); + return ( +
+
+ + {`Set up ${tool.name}`} + + + +
+ +
+ +
+ Done +
+
+ ); +} + +/** + * The key the steps use: one created right here (shown once, then filled into + * every step), or a note to use one made before. + */ +function ApiKeyStep({ tool, enabled, created, onCreated }: { + tool: Tool; + enabled: boolean; + created: CreatedGatewayApiKey | null; + onCreated: (key: CreatedGatewayApiKey) => void; +}) { + const create = useCreateGatewayApiKey(); + + async function createKey() { + try { + onCreated(await create.mutateAsync({ name: tool.id === "other" ? "My tool" : tool.name, expiresAt: expiryFromDays(DEFAULT_GATEWAY_API_KEY_EXPIRY) })); + } catch { + // Shown below from the mutation's error. + } + } + + if (created) { + return ( +
+ + API key, shown once + {`${created.apiKey.name} · ${formatExpiry(created.apiKey.expiresAt)}`} + + + It's filled into the steps below. +
+ ); + } + + if (!enabled) { + return ( +

+

+ ); + } + + return ( +
+
+ + + {`A key for ${tool.id === "other" ? "your tool" : tool.name}`} + Or use one you already made. + + void createKey()} data-testid="gateway-tool-setup-create-key"> + Create key + +
+ {create.error ?

{create.error.message}

: null} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/member-gateway-api-keys-section.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/member-gateway-api-keys-section.tsx index 58d12ce2e0..e5cc7113b2 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/member-gateway-api-keys-section.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/member-gateway-api-keys-section.tsx @@ -15,8 +15,7 @@ import { useEffect, useState, type FormEvent, type ReactNode } from "react"; import { DenButton, buttonVariants } from "../../_components/ui/button"; import { DenInput } from "../../_components/ui/input"; import { DenSelect } from "../../_components/ui/select"; -import { useOrgDashboard } from "../_providers/org-dashboard-provider"; -import { getCursorSetupRoute } from "./cursor-setup-data"; +import { GatewayToolSetupRow } from "./gateway-tool-setup-dialog"; import { BackButton, GatewayToolPicker, GatewayToolSteps, gatewayToolName, type GatewayToolId } from "./gateway-tool-setup"; import { ItemMenu } from "./item-list"; import { LibraryCompactSurface } from "./library-compact"; @@ -41,7 +40,6 @@ import { export function MemberGatewayApiKeysSection() { const keys = useMyGatewayApiKeys(); const revoke = useRevokeGatewayApiKey("mine"); - const { orgSlug } = useOrgDashboard(); const [creating, setCreating] = useState(false); /** The key "Set up" was chosen for: the tool choice again, without the key, which can't be shown twice. */ const [settingUp, setSettingUp] = useState(null); @@ -70,16 +68,12 @@ export function MemberGatewayApiKeysSection() {

- API keys for 3rd party tools - {apiKeys.length} + Use OpenWork AI Gateway with other tools

How to use - - Set up Cursor - {enabled ? ( setCreating(true)} data-testid="gateway-api-keys-create"> Create key @@ -88,6 +82,12 @@ export function MemberGatewayApiKeysSection() {
+ +

+ Your API keys + {apiKeys.length} +

+ {!enabled ? (

- Create a key to use your models in Claude Code, Codex, Cursor or any OpenAI or Anthropic compatible tool. + No keys yet. Pick a tool above to create one as you set it up.

) : (
diff --git a/evals/proofs/gateway-tool-setup-buttons.proof.test.ts b/evals/proofs/gateway-tool-setup-buttons.proof.test.ts new file mode 100644 index 0000000000..5b81379466 --- /dev/null +++ b/evals/proofs/gateway-tool-setup-buttons.proof.test.ts @@ -0,0 +1,44 @@ +import { expect } from "vitest"; +import { spec } from "@openwork/testkit"; +import { gatewayCursorSetupWorld } from "../worlds/gateway-cursor-setup.ts"; + +const test = spec.world(gatewayCursorSetupWorld, { + timeout: 600_000, + resources: { surfaces: ["web"], services: ["den", "mock"] }, +}); + +test("My Library › Models sets up each tool from its logo, creating a key that fills into the steps", async ({ user, probe, step, evidence }) => { + await step("the section is renamed and shows a logo button per tool", async () => { + await user.see({ testId: "gateway-tool-setup-row" }, { timeoutMs: 60_000 }); + await user.see({ text: "Use OpenWork AI Gateway with other tools" }); + const buttons = await probe.dom('[data-testid^="gateway-tool-setup-"][aria-label^="Set up"]'); + evidence.recordAssertionEvidence("One button per tool", `buttons=${buttons.elements.length}`, buttons.elements.length === 6); + expect(buttons.elements).toHaveLength(6); + await user.screenshot(); + }); + + await step("Claude Code opens its steps with a way to create a key", async () => { + await user.click({ testId: "gateway-tool-setup-claude-code" }); + await user.see({ testId: "gateway-tool-setup-dialog" }); + await user.see({ testId: "gateway-tool-setup-create-key" }); + await user.see({ testId: "gateway-tool-claude-code-settings" }, { timeoutMs: 30_000 }); + await user.screenshot(); + }); + + await step("creating the key fills it into the settings to copy", async () => { + await user.click({ testId: "gateway-tool-setup-create-key" }); + await user.see({ testId: "gateway-tool-setup-key-secret" }, { timeoutMs: 30_000 }); + const secret = (await probe.dom('[data-testid="gateway-tool-setup-key-secret"]')).elements[0]?.text ?? ""; + const settings = (await probe.dom('[data-testid="gateway-tool-claude-code-settings"]')).elements[0]?.text ?? ""; + evidence.recordAssertionEvidence("The new key is in the Claude Code settings", `key shown=${secret.length > 0}; in settings=${settings.includes(secret)}`, secret.length > 0 && settings.includes(secret)); + expect(settings).toContain(secret); + await user.screenshot(); + }); + + await step("Cursor shows its own steps", async () => { + await user.press("Escape"); + await user.click({ testId: "gateway-tool-setup-cursor" }); + await user.see({ testId: "gateway-tool-cursor" }); + await user.screenshot(); + }); +}); diff --git a/packages/docs/ai-gateway/cursor.mdx b/packages/docs/ai-gateway/cursor.mdx index 4b92673eb7..e533b60daf 100644 --- a/packages/docs/ai-gateway/cursor.mdx +++ b/packages/docs/ai-gateway/cursor.mdx @@ -16,7 +16,7 @@ Cursor setup available. Create a key in **My Library › Models** and pick **Cursor** under **Start using this key**: it shows everything to paste, with your key filled in. Already have -a key? Open **Set up Cursor** in the same section. +a key? Select **Cursor** under **Set up** in the same section. 1. **Create an API key.** Select **Create key**, name it (`Cursor on my laptop`) and copy it. It's shown once. diff --git a/packages/docs/ai-gateway/third-party-tools.mdx b/packages/docs/ai-gateway/third-party-tools.mdx index 82f412f38d..e45f0dec76 100644 --- a/packages/docs/ai-gateway/third-party-tools.mdx +++ b/packages/docs/ai-gateway/third-party-tools.mdx @@ -29,7 +29,9 @@ tab, marked as not valid, and work again when it's turned back on. ## Create a key 1. Open **My Library** and select the **Models** tab. -2. Under **API keys for 3rd party tools**, select **Create key**. +2. Under **Use OpenWork AI Gateway with other tools**, select **Create key**. + Or, under **Set up**, select your tool's logo and then **Create key**: the + key is filled into that tool's setup steps. 3. Give the key a name, for example the tool and computer you'll use it on (`Claude Code on my laptop`). 4. Choose when it expires: in 7 days, 30 days, 90 days (the default), 1 year, or