diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index a1faac272..4a8b08006 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -535,7 +535,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.authStatus, "unauthenticated"); assert.strictEqual( status.message, - "Claude is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", + "Claude is not configured with a supported Anthropic credential. Run `claude auth login`, or set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, and try again.", ); }).pipe( Effect.provide( @@ -554,17 +554,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ), ); - it.effect("returns unauthenticated when auth status reports oauth auth", () => + it.effect("returns authenticated when auth status reports Claude.ai auth", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus; assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); + assert.strictEqual(status.status, "ready"); assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", - ); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.message, "Claude Code CLI is ready via Claude.ai login."); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -641,12 +638,9 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { stderr: "", code: 0, }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); - assert.strictEqual( - parsed.message, - "Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", - ); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.message, "Claude Code CLI is ready via Claude.ai login."); }); it("JSON with loggedIn=false is unauthenticated", () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 927b51819..59b061663 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -130,9 +130,10 @@ function extractAuthString(value: unknown): string | undefined { return undefined; } -const CLAUDE_OAUTH_AUTH_METHODS = new Set(["claude.ai", "oauth"]); -const CLAUDE_SUPPORTED_AUTH_METHODS = new Set(["apiKey", "authToken"]); -const CLAUDE_AUTH_GUIDANCE = "Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again."; +const CLAUDE_CLI_AUTH_METHODS = new Set(["claude.ai", "oauth"]); +const CLAUDE_SUPPORTED_AUTH_METHODS = new Set(["apiKey", "authToken", "claude.ai", "oauth"]); +const CLAUDE_AUTH_GUIDANCE = + "Run `claude auth login`, or set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, and try again."; export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: ServerProviderStatusState; @@ -619,14 +620,6 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { const authMethod = parsedAuth.authMethod?.trim(); const normalizedAuthMethod = authMethod?.toLowerCase(); - if (normalizedAuthMethod && CLAUDE_OAUTH_AUTH_METHODS.has(normalizedAuthMethod)) { - return { - status: "error", - authStatus: "unauthenticated", - message: `Claude Code is signed in with OAuth, which is not supported here. ${CLAUDE_AUTH_GUIDANCE}`, - }; - } - if (parsedAuth.auth === true) { if (authMethod && !CLAUDE_SUPPORTED_AUTH_METHODS.has(authMethod)) { return { @@ -635,6 +628,13 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { message: `Claude authentication status reported an unsupported credential type '${authMethod}'. ${CLAUDE_AUTH_GUIDANCE}`, }; } + if (normalizedAuthMethod && CLAUDE_CLI_AUTH_METHODS.has(normalizedAuthMethod)) { + return { + status: "ready", + authStatus: "authenticated", + message: "Claude Code CLI is ready via Claude.ai login.", + }; + } return { status: "ready", authStatus: "authenticated" }; } if (parsedAuth.auth === false) { diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 8856be8fb..bbe3db0c2 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -95,7 +95,7 @@ describe("SmeChatServiceLive", () => { projectId, title: "Architecture Q&A", provider: "claudeAgent", - authMethod: "apiKey", + authMethod: "auto", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -236,6 +236,80 @@ describe("SmeChatServiceLive", () => { ]); }); + it("honors the selected API key auth method even when an auth token helper is also configured", async () => { + const projectId = ProjectId.makeUnsafe("project-api-key"); + const conversationId = SmeConversationId.makeUnsafe("conversation-api-key"); + const conversationRow: SmeConversationRow = { + conversationId, + projectId, + title: "API key only", + provider: "claudeAgent", + authMethod: "apiKey", + model: "claude-sonnet-4-6", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }; + const { repository: messageRepo } = makeMessageRepository(); + const sendInputs: Array = []; + const sendClaudeMessage = (input: any) => + Effect.sync(() => { + sendInputs.push(input); + return "Used API key"; + }); + + const layer = makeSmeChatServiceLive({ sendSmeViaAnthropic: sendClaudeMessage }).pipe( + Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), + Layer.provideMerge( + Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), + ), + Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), + ); + + const savedEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + }; + process.env.ANTHROPIC_API_KEY = "api-key-from-env"; + delete process.env.ANTHROPIC_AUTH_TOKEN; + + try { + await Effect.runPromise( + Effect.gen(function* () { + const service = yield* SmeChatService; + yield* service.sendMessage({ + conversationId, + text: "Use the SDK credentials", + providerOptions: { + claudeAgent: { + authTokenHelperCommand: "printf helper-token", + }, + }, + }); + }).pipe(Effect.provide(layer)), + ); + } finally { + if (savedEnv.ANTHROPIC_API_KEY === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = savedEnv.ANTHROPIC_API_KEY; + } + if (savedEnv.ANTHROPIC_AUTH_TOKEN === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = savedEnv.ANTHROPIC_AUTH_TOKEN; + } + } + + expect(sendInputs).toHaveLength(1); + expect(sendInputs[0].clientOptions).toEqual( + expect.objectContaining({ + apiKey: "api-key-from-env", + authToken: null, + }), + ); + }); + it("fails before sending when Claude credentials are unavailable", async () => { const projectId = ProjectId.makeUnsafe("project-2"); const conversationId = SmeConversationId.makeUnsafe("conversation-2"); diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index 1c4b09a6b..67ed0f55b 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -23,8 +23,8 @@ import crypto from "node:crypto"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; -import { isValidSmeAuthMethod } from "../authValidation.ts"; -import { resolveAnthropicClientOptions, sendSmeViaAnthropic } from "../backends/anthropic.ts"; +import { isValidSmeAuthMethod, resolveClaudeSmeSetup } from "../authValidation.ts"; +import { sendSmeViaAnthropic } from "../backends/anthropic.ts"; import { buildSmeSystemPrompt } from "../promptBuilder.ts"; import { SmeChatError, @@ -161,36 +161,19 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => }; } - const clientOptions = yield* Effect.try({ + const resolvedSetup = yield* Effect.try({ try: () => - resolveAnthropicClientOptions({ + resolveClaudeSmeSetup({ + authMethod: conversation.authMethod as Extract< + SmeAuthMethod, + "auto" | "apiKey" | "authToken" + >, providerOptions: providerOptions?.claudeAgent, }), catch: (cause) => new SmeChatError("validateSetup", String(cause), cause), }); - if (!clientOptions.apiKey && !clientOptions.authToken) { - return { - ok: false, - severity: "error" as const, - message: - "Claude SME Chat needs an Anthropic API key, auth token, or auth token helper command. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or configure `authTokenHelperCommand` in Settings.", - resolvedAuthMethod: conversation.authMethod, - resolvedAccountType: "unknown" as const, - }; - } - - return { - ok: true, - severity: "ready" as const, - message: - clientOptions.apiKey !== null - ? "Claude SME Chat can use the configured Anthropic API key." - : "Claude SME Chat can use the configured Anthropic auth token.", - resolvedAuthMethod: conversation.authMethod, - resolvedAccountType: - clientOptions.apiKey !== null ? ("apiKey" as const) : ("unknown" as const), - }; + return resolvedSetup.validation; }); const uploadDocument: SmeChatServiceShape["uploadDocument"] = (input) => @@ -454,23 +437,29 @@ const makeSmeChatService = (options?: SmeChatServiceLiveOptions) => role: message.role, text: message.text, })); - const anthropicClientOptions = yield* Effect.try({ + const resolvedAnthropicSetup = yield* Effect.try({ try: () => - resolveAnthropicClientOptions({ + resolveClaudeSmeSetup({ + authMethod: conv.authMethod as Extract< + SmeAuthMethod, + "auto" | "apiKey" | "authToken" + >, providerOptions: input.providerOptions?.claudeAgent, }), catch: (cause) => new SmeChatError("sendMessage:providerRuntime", String(cause), cause), }); - if (!anthropicClientOptions.apiKey && !anthropicClientOptions.authToken) { + if (!resolvedAnthropicSetup.clientOptions) { return yield* Effect.fail( new SmeChatError( "sendMessage:providerRuntime", - "Claude SME Chat needs an Anthropic API key, auth token, or auth token helper command. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or configure `authTokenHelperCommand` in Settings.", + resolvedAnthropicSetup.validation.message, ), ); } + const anthropicClientOptions = resolvedAnthropicSetup.clientOptions; + const systemPrompt = buildSmeSystemPrompt(docs); const messages: Array = [ ...(promptHistory diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 7d82b9eb0..e476bd0c8 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -3,7 +3,10 @@ import { type SmeValidateSetupResult, type ProviderKind, type ServerProviderStatus, + type ProviderStartOptions, } from "@okcode/contracts"; + +import { resolveAnthropicClientOptions } from "./backends/anthropic.ts"; import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; @@ -17,6 +20,8 @@ import { } from "../codexAppServerManager.ts"; const OPENAI_MODEL_PROVIDERS = new Set(["openai"]); +const CLAUDE_SME_MISSING_CREDENTIALS_MESSAGE = + "Claude SME Chat uses direct Anthropic credentials, not the Claude CLI login. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN, or configure `authTokenHelperCommand` in Settings."; export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAuthMethod[] { switch (provider) { @@ -34,7 +39,7 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": - return "apiKey"; + return "auto"; case "copilot": return "auto"; case "codex": @@ -48,63 +53,136 @@ export function isValidSmeAuthMethod(provider: ProviderKind, authMethod: SmeAuth return getAllowedSmeAuthMethods(provider).includes(authMethod); } -export function validateAnthropicSetup(input: { +export function resolveClaudeSmeSetup(input: { readonly authMethod: Extract; - readonly providerStatus?: ServerProviderStatus | null | undefined; -}): SmeValidateSetupResult { - const providerStatus = input.providerStatus; - if (!providerStatus) { + readonly providerOptions?: ProviderStartOptions["claudeAgent"]; + readonly env?: NodeJS.ProcessEnv; +}): { + readonly validation: SmeValidateSetupResult; + readonly clientOptions: { + readonly apiKey: string | null; + readonly authToken: string | null; + readonly baseURL?: string; + } | null; +} { + const resolved = resolveAnthropicClientOptions({ + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(input.env ? { env: input.env } : {}), + }); + + const withBaseUrl = (clientOptions: { + readonly apiKey: string | null; + readonly authToken: string | null; + }) => ({ + ...clientOptions, + ...(resolved.baseURL ? { baseURL: resolved.baseURL } : {}), + }); + + if (input.authMethod === "apiKey") { + if (!resolved.apiKey) { + return { + validation: { + ok: false, + severity: "error", + message: + "Claude SME Chat is set to Anthropic API Key, but no ANTHROPIC_API_KEY is configured.", + resolvedAuthMethod: "apiKey", + resolvedAccountType: "unknown", + }, + clientOptions: null, + }; + } + return { - ok: false, - severity: "error", - message: "Claude Code CLI status is unavailable.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + validation: { + ok: true, + severity: "ready", + message: "Claude SME Chat can use the configured Anthropic API key.", + resolvedAuthMethod: "apiKey", + resolvedAccountType: "apiKey", + }, + clientOptions: withBaseUrl({ apiKey: resolved.apiKey, authToken: null }), }; } - if (!providerStatus.available || providerStatus.status === "error") { + if (input.authMethod === "authToken") { + if (!resolved.authToken) { + return { + validation: { + ok: false, + severity: "error", + message: + "Claude SME Chat is set to Auth Token, but no ANTHROPIC_AUTH_TOKEN or auth token helper command is configured.", + resolvedAuthMethod: "authToken", + resolvedAccountType: "unknown", + }, + clientOptions: null, + }; + } + return { - ok: false, - severity: "error", - message: - providerStatus.message ?? "Claude Code CLI is not installed or not available on PATH.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + validation: { + ok: true, + severity: "ready", + message: "Claude SME Chat can use the configured Anthropic auth token.", + resolvedAuthMethod: "authToken", + resolvedAccountType: "unknown", + }, + clientOptions: withBaseUrl({ apiKey: null, authToken: resolved.authToken }), }; } - if (providerStatus.authStatus === "unauthenticated") { + if (resolved.authToken) { return { - ok: false, - severity: "error", - message: - providerStatus.message ?? - "Claude Code is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + validation: { + ok: true, + severity: "ready", + message: "Claude SME Chat can use the configured Anthropic auth token.", + resolvedAuthMethod: "authToken", + resolvedAccountType: "unknown", + }, + clientOptions: withBaseUrl({ apiKey: null, authToken: resolved.authToken }), }; } - if (providerStatus.status === "warning") { + if (resolved.apiKey) { return { - ok: true, - severity: "warning", - message: providerStatus.message ?? "Claude Code CLI is available but needs verification.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + validation: { + ok: true, + severity: "ready", + message: "Claude SME Chat can use the configured Anthropic API key.", + resolvedAuthMethod: "apiKey", + resolvedAccountType: "apiKey", + }, + clientOptions: withBaseUrl({ apiKey: resolved.apiKey, authToken: null }), }; } return { - ok: true, - severity: "ready", - message: providerStatus.message ?? "Claude Code CLI is ready.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + validation: { + ok: false, + severity: "error", + message: CLAUDE_SME_MISSING_CREDENTIALS_MESSAGE, + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }, + clientOptions: null, }; } +export function validateAnthropicSetup(input: { + readonly authMethod: Extract; + readonly providerOptions?: ProviderStartOptions["claudeAgent"]; + readonly env?: NodeJS.ProcessEnv; + readonly providerStatus?: ServerProviderStatus | null | undefined; +}): SmeValidateSetupResult { + return resolveClaudeSmeSetup({ + authMethod: input.authMethod, + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(input.env ? { env: input.env } : {}), + }).validation; +} + export const validateClaudeSetup = validateAnthropicSetup; async function readCodexConfigModelProvider( diff --git a/apps/web/src/components/sme/SmeConversationDialog.tsx b/apps/web/src/components/sme/SmeConversationDialog.tsx index facc8372e..22f724faa 100644 --- a/apps/web/src/components/sme/SmeConversationDialog.tsx +++ b/apps/web/src/components/sme/SmeConversationDialog.tsx @@ -201,7 +201,8 @@ export function SmeConversationDialog({ {conversation ? "Conversation settings" : "New SME conversation"} - Choose the provider, auth method, and model used for future SME replies. + Choose the provider, auth method, and model used for future SME replies. SME Chat uses + direct provider credentials, not the Claude CLI login. @@ -246,6 +247,13 @@ export function SmeConversationDialog({ ))} + {provider === "claudeAgent" ? ( +

+ Claude SME Chat talks to Anthropic directly. "Auto" prefers an auth token or helper + command, then falls back to ANTHROPIC_API_KEY. A Claude Max / claude.ai CLI login + alone does not power SME Chat. +

+ ) : null}