From d6becc884f5e96fb45ebbe5832d478adb166ebb4 Mon Sep 17 00:00:00 2001 From: Insung Park Date: Wed, 15 Jul 2026 16:15:04 +0900 Subject: [PATCH] feat: add Claude Platform on AWS provider (anthropic-aws) --- README.md | 27 +++- src/agent/index.ts | 48 ++++++- src/constants.ts | 108 +++++++++++++--- src/credentials.tsx | 255 +++++++++++++++++++++++++++++++++++++- src/diagnostics.ts | 2 + src/env.ts | 11 ++ test/constants.test.ts | 68 ++++++++++ test/credentials.test.ts | 83 ++++++++++++- test/env-behavior.test.ts | 35 ++++++ 9 files changed, 615 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f0344609..ff325516 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ notes. ## Customizing -OpenWiki supports OpenAI (with an API key or a ChatGPT login), OpenRouter, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, and Anthropic out of the box. The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. +OpenWiki supports OpenAI (with an API key or a ChatGPT login), OpenRouter, Fireworks, Baseten, NVIDIA NIM, an OpenAI-compatible provider, and Anthropic out of the box, plus an env-configured `anthropic-aws` provider for [Claude Platform on AWS](#claude-platform-on-aws). The onboarding default is OpenAI with `gpt-5.6-terra`, and each inference provider also includes pre-defined model options plus support for custom model IDs. ### Alternative base URLs @@ -206,6 +206,31 @@ ANTHROPIC_API_KEY=your-key ANTHROPIC_BASE_URL=https://your-gateway.example.com/anthropic ``` +### Claude Platform on AWS + +[Claude Platform on AWS](https://platform.claude.com/docs/en/build-with-claude/claude-platform-on-aws) +is a distinct, Anthropic-managed offering fronted by AWS (SigV4/API-key auth and +AWS Marketplace billing) with its own environment variables, separate from the +first-party `anthropic` provider above. OpenWiki exposes it as the dedicated +`anthropic-aws` provider. You can pick it in the interactive `openwiki --init` +setup (it prompts for the API key, AWS region, and workspace ID) or configure it +directly through environment variables, whose names mirror the official Anthropic +AWS SDK/CLI: + +```bash +OPENWIKI_PROVIDER=anthropic-aws +ANTHROPIC_AWS_API_KEY=your-key # sent as the x-api-key header +ANTHROPIC_AWS_WORKSPACE_ID=wrkspc_XXXXXXXXXXXXXX # required; anthropic-workspace-id header +AWS_REGION=us-west-2 # base URL is derived from the region +``` + +The base URL is built from the workspace's region as +`https://aws-external-anthropic.{region}.api.aws` (`AWS_DEFAULT_REGION` is used as +a fallback). To point at a proxy or a non-standard endpoint instead, set an +explicit `ANTHROPIC_AWS_BASE_URL`, which overrides the region-derived URL. Setting +`ANTHROPIC_AWS_API_KEY` also auto-selects the `anthropic-aws` provider when +`OPENWIKI_PROVIDER` is unset. + ### OpenAI-compatible endpoints The `openai-compatible` provider targets any OpenAI-compatible chat-completions diff --git a/src/agent/index.ts b/src/agent/index.ts index 0f9455c4..597d990d 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -37,7 +37,10 @@ import type { OpenWikiRunResult, } from "./types.js"; import { + ANTHROPIC_AWS_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + AWS_REGION_ENV_KEY, getDefaultModelId, getProviderApiKeyEnvKey, getProviderBaseUrlEnvKey, @@ -115,6 +118,7 @@ export async function runOpenWikiAgent( ensureProviderKey(provider); emitDebug(options, `credentials=${provider} key present`); ensureProviderBaseUrl(provider); + ensureAwsWorkspaceId(provider); if (provider === "openai-chatgpt") { // Refresh before the model is built, so `createModel` stays synchronous. @@ -418,6 +422,14 @@ function ensureProviderBaseUrl(provider: OpenWikiProvider): void { } if (!resolveProviderBaseUrl(provider)) { + // `anthropic-aws` derives its base URL from AWS_REGION, so surface both ways + // to satisfy the requirement instead of just naming the override key. + if (provider === "anthropic-aws") { + throw new Error( + `${AWS_REGION_ENV_KEY} (or an explicit ${ANTHROPIC_AWS_BASE_URL_ENV_KEY}) is required to run OpenWiki with ${getProviderLabel(provider)}.`, + ); + } + const baseUrlEnvKey = getProviderBaseUrlEnvKey(provider) ?? "base URL"; throw new Error( @@ -426,6 +438,18 @@ function ensureProviderBaseUrl(provider: OpenWikiProvider): void { } } +function ensureAwsWorkspaceId(provider: OpenWikiProvider): void { + if (provider !== "anthropic-aws") { + return; + } + + if (!process.env[ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY]?.trim()) { + throw new Error( + `${ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY} is required to run OpenWiki with ${getProviderLabel(provider)}.`, + ); + } +} + function resolveModelId( options: OpenWikiRunOptions, provider: OpenWikiProvider, @@ -452,12 +476,27 @@ function createModel( ) { const retryOptions = { maxRetries: providerRetryAttempts }; - if (provider === "anthropic") { + if (provider === "anthropic" || provider === "anthropic-aws") { const baseURL = resolveProviderBaseUrl(provider); + // Claude Platform on AWS scopes every request to a workspace via the + // `anthropic-workspace-id` header (its base URL + API key are handled by the + // shared config path above). The first-party `anthropic` provider has no + // workspace concept, so the header is only sent for `anthropic-aws`. + const workspaceId = + provider === "anthropic-aws" + ? process.env[ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY]?.trim() + : undefined; return new ChatAnthropic(modelId, { apiKey: process.env[getProviderApiKeyEnvKey(provider)], ...(baseURL ? { anthropicApiUrl: baseURL } : {}), + ...(workspaceId + ? { + clientOptions: { + defaultHeaders: { "anthropic-workspace-id": workspaceId }, + }, + } + : {}), ...retryOptions, }); } @@ -1318,6 +1357,7 @@ function formatDebugValue(key: string, value: string | undefined): string { if ( key === "LANGCHAIN_ENDPOINT" || key === ANTHROPIC_BASE_URL_ENV_KEY || + key === ANTHROPIC_AWS_BASE_URL_ENV_KEY || key === OPENAI_COMPATIBLE_BASE_URL_ENV_KEY ) { return formatUrlDebugValue(value); @@ -1330,7 +1370,11 @@ function formatDebugValue(key: string, value: string | undefined): string { if ( key === OPENWIKI_MODEL_ID_ENV_KEY || key === OPENWIKI_PROVIDER_ENV_KEY || - key === OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY + key === OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY || + // Non-secret Claude Platform on AWS identifiers: show them verbatim in + // debug output, matching how the credential diagnostics panel treats them. + key === ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY || + key === AWS_REGION_ENV_KEY ) { return `set(value=${JSON.stringify(value)})`; } diff --git a/src/constants.ts b/src/constants.ts index cb62841f..9686b258 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -16,6 +16,19 @@ export const OPENAI_CHATGPT_EMAIL_ENV_KEY = "OPENAI_CHATGPT_EMAIL"; export const OPENAI_CHATGPT_PLAN_ENV_KEY = "OPENAI_CHATGPT_PLAN"; export const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY"; export const ANTHROPIC_BASE_URL_ENV_KEY = "ANTHROPIC_BASE_URL"; +// Claude Platform on AWS (https://platform.claude.com/docs/en/build-with-claude/claude-platform-on-aws) +// is a distinct, Anthropic-managed offering fronted by AWS with its own env +// names, mirroring the official Anthropic SDK/CLI (`ANTHROPIC_AWS_*`, `AWS_REGION`). +// Its base URL is derived from the workspace's AWS region +// (`https://aws-external-anthropic.{region}.api.aws`); ANTHROPIC_AWS_BASE_URL is an +// optional explicit override for proxies/testing. Every request is scoped to a +// workspace via the required `anthropic-workspace-id` header (workspace IDs look +// like `wrkspc_…`). +export const ANTHROPIC_AWS_API_KEY_ENV_KEY = "ANTHROPIC_AWS_API_KEY"; +export const ANTHROPIC_AWS_BASE_URL_ENV_KEY = "ANTHROPIC_AWS_BASE_URL"; +export const ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY = "ANTHROPIC_AWS_WORKSPACE_ID"; +export const AWS_REGION_ENV_KEY = "AWS_REGION"; +export const AWS_DEFAULT_REGION_ENV_KEY = "AWS_DEFAULT_REGION"; export const OPENROUTER_API_KEY_ENV_KEY = "OPENROUTER_API_KEY"; export const OPENWIKI_PROVIDER_ENV_KEY = "OPENWIKI_PROVIDER"; export const OPENWIKI_MODEL_ID_ENV_KEY = "OPENWIKI_MODEL_ID"; @@ -55,6 +68,7 @@ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; export type OpenWikiProvider = | "anthropic" + | "anthropic-aws" | "baseten" | "fireworks" | "nvidia" @@ -90,6 +104,17 @@ const OPENAI_MODEL_OPTIONS: ProviderModelOption[] = [ { id: "gpt-5.4-mini", label: "5.4 mini" }, ]; +/** + * Claude model options. Shared by the first-party `anthropic` provider and the + * `anthropic-aws` (Claude Platform on AWS) provider so both expose an identical + * model list — both target the same Claude API surface. + */ +const CLAUDE_MODEL_OPTIONS: ProviderModelOption[] = [ + { id: "claude-haiku-4-5", label: "Haiku" }, + { id: "claude-sonnet-5", label: "Sonnet" }, + { id: "claude-opus-4-8", label: "Opus" }, +]; + type ProviderConfig = { apiKeyEnvKey: string; /** @@ -104,9 +129,17 @@ type ProviderConfig = { * with an alternative base URL (e.g. a self-hosted or proxied endpoint). */ baseUrlEnvKey?: string; + /** + * Derives a base URL from the environment when neither + * {@link ProviderConfig.baseUrlEnvKey} override nor {@link ProviderConfig.baseURL} + * default applies. Used by `anthropic-aws`, whose endpoint is built from the + * workspace's AWS region rather than a fixed URL. + */ + deriveBaseUrl?: (env: NodeJS.ProcessEnv) => string | undefined; /** * When true, the provider has no default endpoint and requires a base URL to - * be supplied via {@link ProviderConfig.baseUrlEnvKey}. + * be supplied via {@link ProviderConfig.baseUrlEnvKey} (or, for `anthropic-aws`, + * derived via {@link ProviderConfig.deriveBaseUrl}). */ requiresBaseUrl?: boolean; label: string; @@ -117,6 +150,7 @@ export const SELECTABLE_OPENWIKI_PROVIDERS = [ "openai", "openai-chatgpt", "anthropic", + "anthropic-aws", "openrouter", "openai-compatible", "fireworks", @@ -190,11 +224,15 @@ export const PROVIDER_CONFIGS: Record = { apiKeyEnvKey: ANTHROPIC_API_KEY_ENV_KEY, baseUrlEnvKey: ANTHROPIC_BASE_URL_ENV_KEY, label: "Anthropic", - modelOptions: [ - { id: "claude-haiku-4-5", label: "Haiku" }, - { id: "claude-sonnet-5", label: "Sonnet" }, - { id: "claude-opus-4-8", label: "Opus" }, - ], + modelOptions: CLAUDE_MODEL_OPTIONS, + }, + "anthropic-aws": { + apiKeyEnvKey: ANTHROPIC_AWS_API_KEY_ENV_KEY, + baseUrlEnvKey: ANTHROPIC_AWS_BASE_URL_ENV_KEY, + deriveBaseUrl: resolveAwsAnthropicBaseUrl, + requiresBaseUrl: true, + label: "Claude Platform on AWS", + modelOptions: CLAUDE_MODEL_OPTIONS, }, openrouter: { apiKeyEnvKey: OPENROUTER_API_KEY_ENV_KEY, @@ -259,7 +297,43 @@ export function resolveProviderBaseUrl( return trimmedOverride; } - return config.baseURL; + return config.baseURL ?? config.deriveBaseUrl?.(env); +} + +/** + * The AWS region a Claude Platform on AWS workspace is bound to, read from + * `AWS_REGION` with a fallback to `AWS_DEFAULT_REGION` (matching the standard AWS + * SDK resolution the official Anthropic AWS client uses). + */ +export function resolveAwsRegion( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const region = ( + env[AWS_REGION_ENV_KEY] ?? env[AWS_DEFAULT_REGION_ENV_KEY] + )?.trim(); + + return region ? region : undefined; +} + +/** + * Builds the Claude Platform on AWS gateway base URL for a region, e.g. + * `https://aws-external-anthropic.us-west-2.api.aws`. + */ +export function buildAwsAnthropicBaseUrl(region: string): string { + return `https://aws-external-anthropic.${region}.api.aws`; +} + +/** + * Derives the `anthropic-aws` base URL from the configured AWS region, or + * `undefined` when no region is set (callers then require an explicit + * `ANTHROPIC_AWS_BASE_URL` override). + */ +function resolveAwsAnthropicBaseUrl( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const region = resolveAwsRegion(env); + + return region ? buildAwsAnthropicBaseUrl(region) : undefined; } export function getProviderBaseUrlEnvKey( @@ -325,15 +399,17 @@ export function resolveConfiguredProvider( ? "openai-compatible" : env[OPENROUTER_API_KEY_ENV_KEY] ? "openrouter" - : env[ANTHROPIC_API_KEY_ENV_KEY] - ? "anthropic" - : env[BASETEN_API_KEY_ENV_KEY] - ? "baseten" - : env[FIREWORKS_API_KEY_ENV_KEY] - ? "fireworks" - : env[NVIDIA_API_KEY_ENV_KEY] - ? "nvidia" - : DEFAULT_PROVIDER) + : env[ANTHROPIC_AWS_API_KEY_ENV_KEY] + ? "anthropic-aws" + : env[ANTHROPIC_API_KEY_ENV_KEY] + ? "anthropic" + : env[BASETEN_API_KEY_ENV_KEY] + ? "baseten" + : env[FIREWORKS_API_KEY_ENV_KEY] + ? "fireworks" + : env[NVIDIA_API_KEY_ENV_KEY] + ? "nvidia" + : DEFAULT_PROVIDER) ); } diff --git a/src/credentials.tsx b/src/credentials.tsx index 9b3ae8bf..e2dc737b 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -7,6 +7,8 @@ import { Box, Text, useInput, useStdout } from "ink"; import { configureAuthProvider } from "./auth/configure.js"; import { runOAuthAuth } from "./auth/oauth.js"; import { + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, + AWS_REGION_ENV_KEY, DEFAULT_PROVIDER, getDefaultModelId, getProviderApiKeyEnvKey, @@ -29,6 +31,7 @@ import { providerRequiresBaseUrl, providerUsesOAuth, resolveConfiguredProvider, + resolveProviderBaseUrl, SELECTABLE_OPENWIKI_PROVIDERS, } from "./constants.js"; import { @@ -89,6 +92,8 @@ type InitSetupProps = { type PromptStep = | "api-key" + | "aws-region" + | "aws-workspace-id" | "base-url" | "code-repo-confirm" | "code-repo-path" @@ -360,6 +365,8 @@ export function needsCredentialSetup( const needsCredentials = !hasValidConfiguredProvider() || needsCredentialStep(provider) || + needsAwsRegionStep(provider) || + needsAwsWorkspaceStep(provider) || needsBaseUrlStep(provider) || (modelIdOverride === null && process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || @@ -397,6 +404,12 @@ function hasValidStoredToken(env: NodeJS.ProcessEnv = process.env): boolean { } function needsBaseUrlStep(provider: OpenWikiProvider): boolean { + // Claude Platform on AWS derives its base URL from the region, so it uses the + // dedicated `aws-region` step instead of the generic base-URL prompt. + if (isAwsProvider(provider)) { + return false; + } + if (!providerRequiresBaseUrl(provider)) { return false; } @@ -410,6 +423,26 @@ function isBaseUrlConfigured(provider: OpenWikiProvider): boolean { return baseUrlEnvKey ? Boolean(process.env[baseUrlEnvKey]) : false; } +function isAwsProvider(provider: OpenWikiProvider): boolean { + return provider === "anthropic-aws"; +} + +/** + * Claude Platform on AWS needs a region (to build the gateway base URL) unless + * one is already resolvable — either `AWS_REGION`/`AWS_DEFAULT_REGION` or an + * explicit `ANTHROPIC_AWS_BASE_URL` override. + */ +function needsAwsRegionStep(provider: OpenWikiProvider): boolean { + return isAwsProvider(provider) && !resolveProviderBaseUrl(provider); +} + +function needsAwsWorkspaceStep(provider: OpenWikiProvider): boolean { + return ( + isAwsProvider(provider) && + !process.env[ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY]?.trim() + ); +} + function isCredentialConfigured(provider: OpenWikiProvider): boolean { return providerUsesOAuth(provider) ? hasValidStoredToken() @@ -486,6 +519,8 @@ export function InitSetup({ const [provider, setProvider] = useState(initialProvider); const [apiKey, setApiKey] = useState(null); const [baseUrl, setBaseUrl] = useState(null); + const [awsRegion, setAwsRegion] = useState(null); + const [awsWorkspaceId, setAwsWorkspaceId] = useState(null); const [modelId, setModelId] = useState(null); const [langSmithKey, setLangSmithKey] = useState(null); const [input, setInput] = useState(""); @@ -1202,6 +1237,85 @@ export function InitSetup({ return; } + if (step === "aws-region") { + const trimmedInput = input.trim(); + + if (trimmedInput.length === 0) { + setError(`${AWS_REGION_ENV_KEY} is required.`); + return; + } + + setAwsRegion(trimmedInput); + setInput(""); + const nextStep = getNextStepAfterAwsRegion( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextAwsRegion: trimmedInput, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + + if (step === "aws-workspace-id") { + const trimmedInput = input.trim(); + + if (trimmedInput.length === 0) { + setError(`${ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY} is required.`); + return; + } + + setAwsWorkspaceId(trimmedInput); + setInput(""); + const nextStep = getNextStepAfterAwsWorkspace( + provider, + modelIdOverride, + onboardingConfig, + selectedMode, + forceModelStep, + ); + + if (nextStep) { + setIsCustomModelInput( + nextStep === "model" && shouldStartWithCustomModelInput(provider), + ); + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: apiKey, + nextBaseUrl: baseUrl, + nextAwsRegion: awsRegion, + nextAwsWorkspaceId: trimmedInput, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextOAuthTokens: oauthTokens, + nextProvider: provider, + runMode: selectedMode, + }); + return; + } + if (step === "model") { const selectedModelId = getSelectedModelId( provider, @@ -1555,6 +1669,10 @@ export function InitSetup({ type CompleteSetupOptions = { nextApiKey: string | null; nextBaseUrl: string | null; + // Claude Platform on AWS extras; default to the component state when a call + // site does not pass them (mirrors how nextOAuthTokens falls back to state). + nextAwsRegion?: string | null; + nextAwsWorkspaceId?: string | null; nextLangSmithKey: string | null; nextModelId: string | null; nextOAuthTokens?: CodexTokens | null; @@ -1641,6 +1759,8 @@ export function InitSetup({ async function saveCredentialUpdates({ nextApiKey, nextBaseUrl, + nextAwsRegion = awsRegion, + nextAwsWorkspaceId = awsWorkspaceId, nextLangSmithKey, nextModelId, nextOAuthTokens = oauthTokens, @@ -1671,6 +1791,14 @@ export function InitSetup({ } } + if (nextAwsRegion) { + updates[AWS_REGION_ENV_KEY] = nextAwsRegion; + } + + if (nextAwsWorkspaceId) { + updates[ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY] = nextAwsWorkspaceId; + } + if (nextModelId !== null) { updates[OPENWIKI_MODEL_ID_ENV_KEY] = nextModelId; } @@ -1933,6 +2061,8 @@ export function InitSetup({ const needsCredentialPrompt = !hasValidConfiguredProvider() || needsCredentialStep(provider) || + needsAwsRegionStep(provider) || + needsAwsWorkspaceStep(provider) || needsBaseUrlStep(provider) || (modelIdOverride === null && process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) || @@ -1965,7 +2095,40 @@ export function InitSetup({ } detail={getCredentialSetupDetail(provider, oauthTokens)} /> - {providerRequiresBaseUrl(provider) ? ( + {isAwsProvider(provider) ? ( + <> + + + + ) : providerRequiresBaseUrl(provider) ? ( + Enter your Claude Platform on AWS workspace region. + + $ {AWS_REGION_ENV_KEY}={" "} + {input} + + + The gateway endpoint is built from the region (for example + `us-west-2`). Press Enter to save it. + + + ); + } + + if (step === "aws-workspace-id") { + return ( + + Enter your Claude Platform on AWS workspace ID. + + $ {ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY}={" "} + {input} + + + Workspace IDs look like `wrkspc_…` and are sent as the + `anthropic-workspace-id` header. Press Enter to save it. + + + ); + } + if (step === "model") { if (isCustomModelInput) { return ( @@ -3125,6 +3320,14 @@ export function getInitialStep( return credentialStep(provider); } + if (needsAwsRegionStep(provider)) { + return "aws-region"; + } + + if (needsAwsWorkspaceStep(provider)) { + return "aws-workspace-id"; + } + if (needsBaseUrlStep(provider)) { return "base-url"; } @@ -3190,6 +3393,14 @@ function getNextStepAfterApiKey( mode: OpenWikiRunMode, forceModelStep = false, ): PromptStep | null { + if (needsAwsRegionStep(provider)) { + return "aws-region"; + } + + if (needsAwsWorkspaceStep(provider)) { + return "aws-workspace-id"; + } + if (needsBaseUrlStep(provider)) { return "base-url"; } @@ -3203,6 +3414,42 @@ function getNextStepAfterApiKey( ); } +function getNextStepAfterAwsRegion( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + if (needsAwsWorkspaceStep(provider)) { + return "aws-workspace-id"; + } + + return getNextStepAfterBaseUrl( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + +function getNextStepAfterAwsWorkspace( + provider: OpenWikiProvider, + modelIdOverride: string | null, + onboardingConfig: OpenWikiOnboardingConfig, + mode: OpenWikiRunMode, + forceModelStep = false, +): PromptStep | null { + return getNextStepAfterBaseUrl( + provider, + modelIdOverride, + onboardingConfig, + mode, + forceModelStep, + ); +} + function getNextStepAfterBaseUrl( provider: OpenWikiProvider, modelIdOverride: string | null, @@ -3505,7 +3752,11 @@ function getInputDisplayWidth(stdoutColumns: number | undefined): number { } function getProviderArticle(provider: OpenWikiProvider): "a" | "an" { - return provider === "baseten" || provider === "fireworks" ? "a" : "an"; + return provider === "baseten" || + provider === "fireworks" || + provider === "anthropic-aws" + ? "a" + : "an"; } function getTemplateGoal(templateId: string | undefined): string { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index c6fefa8a..1936c7fe 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,5 +1,6 @@ import { ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, BASETEN_API_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, NVIDIA_API_KEY_ENV_KEY, @@ -27,6 +28,7 @@ export function sanitizeDiagnosticText(value: string): string { OPENAI_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, "LANGSMITH_API_KEY", ]) { diff --git a/src/env.ts b/src/env.ts index 2db41781..4ad81a78 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,7 +3,11 @@ import os from "node:os"; import path from "node:path"; import { ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, + ANTHROPIC_AWS_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + AWS_REGION_ENV_KEY, BASETEN_API_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, isValidModelId, @@ -85,6 +89,10 @@ export const MANAGED_ENV_KEYS = [ OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, + ANTHROPIC_AWS_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, + AWS_REGION_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -266,6 +274,9 @@ function isNonSecretDiagnosticKey(key: string): boolean { key === OPENWIKI_PROVIDER_ENV_KEY || key === OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY || key === ANTHROPIC_BASE_URL_ENV_KEY || + key === ANTHROPIC_AWS_BASE_URL_ENV_KEY || + key === ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY || + key === AWS_REGION_ENV_KEY || key === OPENAI_COMPATIBLE_BASE_URL_ENV_KEY ); } diff --git a/test/constants.test.ts b/test/constants.test.ts index bbcae399..466c9147 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + buildAwsAnthropicBaseUrl, DEFAULT_MODEL_ID, DEFAULT_PROVIDER_RETRY_ATTEMPTS, DEFAULT_PROVIDER, @@ -10,6 +11,7 @@ import { isValidProvider, normalizeModelId, normalizeProvider, + resolveAwsRegion, resolveConfiguredProvider, resolveProviderBaseUrl, resolveProviderRetryAttempts, @@ -59,6 +61,7 @@ describe("normalizeProvider / isValidProvider", () => { test("isValidProvider is a type guard over the known set", () => { expect(isValidProvider("anthropic")).toBe(true); + expect(isValidProvider("anthropic-aws")).toBe(true); expect(isValidProvider("openai-compatible")).toBe(true); expect(isValidProvider("nvidia")).toBe(true); expect(isValidProvider("nope")).toBe(false); @@ -82,6 +85,21 @@ describe("resolveConfiguredProvider", () => { expect(resolveConfiguredProvider({ NVIDIA_API_KEY: "x" })).toBe("nvidia"); }); + test("auto-selects anthropic-aws when only its API key is present", () => { + expect(resolveConfiguredProvider({ ANTHROPIC_AWS_API_KEY: "x" })).toBe( + "anthropic-aws", + ); + }); + + test("prefers anthropic-aws over first-party anthropic when both keys exist", () => { + expect( + resolveConfiguredProvider({ + ANTHROPIC_AWS_API_KEY: "aws", + ANTHROPIC_API_KEY: "first-party", + }), + ).toBe("anthropic-aws"); + }); + test("falls back to the default provider when nothing is configured", () => { expect(resolveConfiguredProvider({})).toBe(DEFAULT_PROVIDER); }); @@ -121,6 +139,56 @@ describe("resolveProviderBaseUrl", () => { test("returns undefined for a provider with no default and no override", () => { expect(resolveProviderBaseUrl("openai", {})).toBeUndefined(); }); + + test("derives the anthropic-aws base URL from AWS_REGION", () => { + expect( + resolveProviderBaseUrl("anthropic-aws", { AWS_REGION: "us-west-2" }), + ).toBe("https://aws-external-anthropic.us-west-2.api.aws"); + }); + + test("derives the anthropic-aws base URL from AWS_DEFAULT_REGION fallback", () => { + expect( + resolveProviderBaseUrl("anthropic-aws", { + AWS_DEFAULT_REGION: "eu-central-1", + }), + ).toBe("https://aws-external-anthropic.eu-central-1.api.aws"); + }); + + test("prefers an explicit ANTHROPIC_AWS_BASE_URL override over the region", () => { + expect( + resolveProviderBaseUrl("anthropic-aws", { + AWS_REGION: "us-west-2", + ANTHROPIC_AWS_BASE_URL: "https://proxy.example/anthropic", + }), + ).toBe("https://proxy.example/anthropic"); + }); + + test("returns undefined for anthropic-aws with no region and no override", () => { + expect(resolveProviderBaseUrl("anthropic-aws", {})).toBeUndefined(); + }); +}); + +describe("resolveAwsRegion / buildAwsAnthropicBaseUrl", () => { + test("reads AWS_REGION, then AWS_DEFAULT_REGION, trimming whitespace", () => { + expect(resolveAwsRegion({ AWS_REGION: " us-west-2 " })).toBe("us-west-2"); + expect(resolveAwsRegion({ AWS_DEFAULT_REGION: "eu-central-1" })).toBe( + "eu-central-1", + ); + expect( + resolveAwsRegion({ AWS_REGION: "us-east-1", AWS_DEFAULT_REGION: "x" }), + ).toBe("us-east-1"); + }); + + test("returns undefined when neither region var is set or is blank", () => { + expect(resolveAwsRegion({})).toBeUndefined(); + expect(resolveAwsRegion({ AWS_REGION: " " })).toBeUndefined(); + }); + + test("builds the region-scoped gateway URL", () => { + expect(buildAwsAnthropicBaseUrl("ap-northeast-2")).toBe( + "https://aws-external-anthropic.ap-northeast-2.api.aws", + ); + }); }); describe("resolveProviderRetryAttempts", () => { diff --git a/test/credentials.test.ts b/test/credentials.test.ts index d5ef68df..1d2b75ee 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -1,11 +1,16 @@ import { afterEach, describe, expect, test } from "vitest"; -import { needsCredentialSetup } from "../src/credentials.tsx"; +import { getInitialStep, needsCredentialSetup } from "../src/credentials.tsx"; const ENV_KEYS = [ "LANGSMITH_API_KEY", "OPENROUTER_API_KEY", "OPENWIKI_MODEL_ID", "OPENWIKI_PROVIDER", + "ANTHROPIC_AWS_API_KEY", + "ANTHROPIC_AWS_BASE_URL", + "ANTHROPIC_AWS_WORKSPACE_ID", + "AWS_REGION", + "AWS_DEFAULT_REGION", ] as const; const originalEnv = new Map( @@ -33,4 +38,80 @@ describe("needsCredentialSetup", () => { expect(needsCredentialSetup()).toBe(true); }); + + test("anthropic-aws still needs setup when the AWS region is missing", () => { + // Everything else is present, so only the region/workspace gate can flip + // this to true — proving the new AWS steps are part of the gate. + process.env.OPENWIKI_PROVIDER = "anthropic-aws"; + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + process.env.ANTHROPIC_AWS_WORKSPACE_ID = "wrkspc_placeholder"; + process.env.OPENWIKI_MODEL_ID = "claude-sonnet-5"; + process.env.LANGSMITH_API_KEY = "lsv2_placeholder"; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.ANTHROPIC_AWS_BASE_URL; + + expect(needsCredentialSetup()).toBe(true); + }); + + test("anthropic-aws still needs setup when the workspace ID is missing", () => { + process.env.OPENWIKI_PROVIDER = "anthropic-aws"; + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + process.env.AWS_REGION = "us-west-2"; + process.env.OPENWIKI_MODEL_ID = "claude-sonnet-5"; + process.env.LANGSMITH_API_KEY = "lsv2_placeholder"; + delete process.env.ANTHROPIC_AWS_WORKSPACE_ID; + + expect(needsCredentialSetup()).toBe(true); + }); +}); + +describe("getInitialStep for the anthropic-aws wizard", () => { + function cleanAwsEnv() { + process.env.OPENWIKI_PROVIDER = "anthropic-aws"; + delete process.env.ANTHROPIC_AWS_API_KEY; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.ANTHROPIC_AWS_BASE_URL; + delete process.env.ANTHROPIC_AWS_WORKSPACE_ID; + delete process.env.OPENWIKI_MODEL_ID; + } + + test("starts at the API key step when no credential is present", () => { + cleanAwsEnv(); + + expect(getInitialStep(null, "anthropic-aws")).toBe("api-key"); + }); + + test("routes to the AWS region step once the API key is set", () => { + cleanAwsEnv(); + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + + expect(getInitialStep(null, "anthropic-aws")).toBe("aws-region"); + }); + + test("routes to the workspace step once the region is set", () => { + cleanAwsEnv(); + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + process.env.AWS_REGION = "us-west-2"; + + expect(getInitialStep(null, "anthropic-aws")).toBe("aws-workspace-id"); + }); + + test("skips the region step when ANTHROPIC_AWS_BASE_URL is set", () => { + cleanAwsEnv(); + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + process.env.ANTHROPIC_AWS_BASE_URL = "https://proxy.example/anthropic"; + + expect(getInitialStep(null, "anthropic-aws")).toBe("aws-workspace-id"); + }); + + test("advances to the model step once region and workspace are set", () => { + cleanAwsEnv(); + process.env.ANTHROPIC_AWS_API_KEY = "sk-aws-placeholder"; + process.env.AWS_REGION = "us-west-2"; + process.env.ANTHROPIC_AWS_WORKSPACE_ID = "wrkspc_placeholder"; + + expect(getInitialStep(null, "anthropic-aws")).toBe("model"); + }); }); diff --git a/test/env-behavior.test.ts b/test/env-behavior.test.ts index 03bace18..d309f1d1 100644 --- a/test/env-behavior.test.ts +++ b/test/env-behavior.test.ts @@ -17,7 +17,11 @@ import { } from "../src/env.ts"; import { ANTHROPIC_API_KEY_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, + ANTHROPIC_AWS_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + AWS_REGION_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -37,6 +41,10 @@ import { const KEYS_UNDER_TEST = [ ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_API_KEY_ENV_KEY, + ANTHROPIC_AWS_BASE_URL_ENV_KEY, + ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, + AWS_REGION_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -221,6 +229,33 @@ describe("getCredentialDiagnostics", () => { expect(entry?.preview).toBe('"https://gateway.example.com/anthropic"'); }); + test("surfaces the Claude Platform on AWS workspace ID verbatim, not masked", async () => { + await saveOpenWikiEnv({ + [ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY]: "wrkspc_XXXXXXXXXXXXXX", + }); + + const diagnostics = await getCredentialDiagnostics(); + const entry = diagnostics.find( + (item) => item.key === ANTHROPIC_AWS_WORKSPACE_ID_ENV_KEY, + ); + + expect(entry?.preview).toBe('"wrkspc_XXXXXXXXXXXXXX"'); + }); + + test("masks the Claude Platform on AWS API key like other secrets", async () => { + await saveOpenWikiEnv({ + [ANTHROPIC_AWS_API_KEY_ENV_KEY]: "sk-aws-anthropic-secret-123456", + }); + + const diagnostics = await getCredentialDiagnostics(); + const entry = diagnostics.find( + (item) => item.key === ANTHROPIC_AWS_API_KEY_ENV_KEY, + ); + + expect(entry?.preview).not.toContain("sk-aws-anthropic-secret-123456"); + expect(entry?.length).toBe("sk-aws-anthropic-secret-123456".length); + }); + test("flags an invalid model ID with a warning", async () => { await saveOpenWikiEnv({ [OPENWIKI_MODEL_ID_ENV_KEY]: "bad model id" });