mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 17:59:09 +08:00
- Add shared Anthropic utilities package with OAuth and API key client creation - Implement provider-specific message routing alongside existing v1 API - Enhance authentication middleware with priority handling (API key > Bearer token) - Add comprehensive auth middleware test suite with timing attack protection - Update session handling and message transformation for Claude Code integration - Improve error handling and validation across message processing pipeline - Standardize import formatting and code structure across affected modules This establishes the foundation for Claude Code OAuth authentication while maintaining backward compatibility with existing API key authentication methods.
228 lines
7.0 KiB
TypeScript
228 lines
7.0 KiB
TypeScript
// src/main/services/agents/services/claudecode/index.ts
|
|
import { EventEmitter } from 'node:events'
|
|
import { createRequire } from 'node:module'
|
|
|
|
import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-code'
|
|
import { loggerService } from '@logger'
|
|
import { config as apiConfigService } from '@main/apiServer/config'
|
|
import { validateModelId } from '@main/apiServer/utils'
|
|
|
|
import { GetAgentSessionResponse } from '../..'
|
|
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
|
import { transformSDKMessageToStreamParts } from './transform'
|
|
|
|
const require_ = createRequire(import.meta.url)
|
|
const logger = loggerService.withContext('ClaudeCodeService')
|
|
|
|
class ClaudeCodeStream extends EventEmitter implements AgentStream {
|
|
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
|
|
declare on: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
|
declare once: (event: 'data', listener: (data: AgentStreamEvent) => void) => this
|
|
}
|
|
|
|
class ClaudeCodeService implements AgentServiceInterface {
|
|
private claudeExecutablePath: string
|
|
|
|
constructor() {
|
|
// Resolve Claude Code CLI robustly (works in dev and in asar)
|
|
this.claudeExecutablePath = require_.resolve('@anthropic-ai/claude-code/cli.js')
|
|
}
|
|
|
|
async invoke(
|
|
prompt: string,
|
|
session: GetAgentSessionResponse,
|
|
abortController: AbortController,
|
|
lastAgentSessionId?: string
|
|
): Promise<AgentStream> {
|
|
const aiStream = new ClaudeCodeStream()
|
|
|
|
// Validate session accessible paths and make sure it exists as a directory
|
|
const cwd = session.accessible_paths[0]
|
|
if (!cwd) {
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error('No accessible paths defined for the agent session')
|
|
})
|
|
return aiStream
|
|
}
|
|
|
|
// Validate model info
|
|
const modelInfo = await validateModelId(session.model)
|
|
if (!modelInfo.valid) {
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error(`Invalid model ID '${session.model}': ${JSON.stringify(modelInfo.error)}`)
|
|
})
|
|
return aiStream
|
|
}
|
|
if (modelInfo.provider?.type !== 'anthropic' || modelInfo.provider.apiKey === '') {
|
|
aiStream.emit('data', {
|
|
type: 'error',
|
|
error: new Error(`Invalid provider type '${modelInfo.provider?.type}'. Expected 'anthropic' provider type.`)
|
|
})
|
|
return aiStream
|
|
}
|
|
|
|
// TODO: use cherry studio api server config instead of direct provider config to provide more flexibility (e.g. custom headers, proxy, statistics, etc).
|
|
const apiConfig = await apiConfigService.get()
|
|
// process.env.ANTHROPIC_AUTH_TOKEN = apiConfig.apiKey
|
|
// process.env.ANTHROPIC_BASE_URL = `http://${apiConfig.host}:${apiConfig.port}`
|
|
const env = {
|
|
...process.env,
|
|
ANTHROPIC_API_KEY: apiConfig.apiKey,
|
|
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
|
ELECTRON_RUN_AS_NODE: '1'
|
|
}
|
|
|
|
// Build SDK options from parameters
|
|
const options: Options = {
|
|
abortController,
|
|
cwd,
|
|
env,
|
|
model: modelInfo.modelId,
|
|
pathToClaudeCodeExecutable: this.claudeExecutablePath,
|
|
stderr: (chunk: string) => {
|
|
logger.info('claude stderr', { chunk })
|
|
},
|
|
appendSystemPrompt: session.instructions,
|
|
permissionMode: session.configuration?.permission_mode,
|
|
maxTurns: session.configuration?.max_turns
|
|
}
|
|
|
|
if (session.accessible_paths.length > 1) {
|
|
options.additionalDirectories = session.accessible_paths.slice(1)
|
|
}
|
|
|
|
if (session.mcps && session.mcps.length > 0) {
|
|
// mcp configs
|
|
const mcpList: Record<string, McpHttpServerConfig> = {}
|
|
for (const mcpId of session.mcps) {
|
|
mcpList[mcpId] = {
|
|
type: 'http',
|
|
url: `http://${apiConfig.host}:${apiConfig.port}/v1/mcps/${mcpId}/mcp`,
|
|
headers: {
|
|
Authorization: `Bearer ${apiConfig.apiKey}`
|
|
}
|
|
}
|
|
}
|
|
options.mcpServers = mcpList
|
|
options.strictMcpConfig = true
|
|
}
|
|
|
|
if (lastAgentSessionId) {
|
|
options.resume = lastAgentSessionId
|
|
}
|
|
|
|
logger.silly('Starting Claude Code SDK query', {
|
|
prompt,
|
|
options
|
|
})
|
|
|
|
// Start async processing
|
|
this.processSDKQuery(prompt, options, aiStream)
|
|
|
|
return aiStream
|
|
}
|
|
|
|
private async *userMessages(prompt: string) {
|
|
{
|
|
yield {
|
|
type: 'user' as const,
|
|
parent_tool_use_id: null,
|
|
session_id: '',
|
|
message: {
|
|
role: 'user' as const,
|
|
content: prompt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process SDK query and emit stream events
|
|
*/
|
|
private async processSDKQuery(prompt: string, options: Options, stream: ClaudeCodeStream): Promise<void> {
|
|
const jsonOutput: SDKMessage[] = []
|
|
let hasCompleted = false
|
|
const startTime = Date.now()
|
|
|
|
try {
|
|
// Process streaming responses using SDK query
|
|
for await (const message of query({
|
|
prompt: this.userMessages(prompt),
|
|
options
|
|
})) {
|
|
if (hasCompleted) break
|
|
|
|
jsonOutput.push(message)
|
|
logger.silly('claude response', { message })
|
|
if (message.type === 'assistant' || message.type === 'user') {
|
|
logger.silly('message content', {
|
|
message: JSON.stringify({ role: message.message.role, content: message.message.content })
|
|
})
|
|
}
|
|
|
|
// Transform SDKMessage to UIMessageChunks
|
|
const chunks = transformSDKMessageToStreamParts(message)
|
|
for (const chunk of chunks) {
|
|
stream.emit('data', {
|
|
type: 'chunk',
|
|
chunk
|
|
})
|
|
}
|
|
}
|
|
|
|
// Successfully completed
|
|
hasCompleted = true
|
|
const duration = Date.now() - startTime
|
|
|
|
logger.debug('SDK query completed successfully', {
|
|
duration,
|
|
messageCount: jsonOutput.length
|
|
})
|
|
|
|
// Emit completion event
|
|
stream.emit('data', {
|
|
type: 'complete'
|
|
})
|
|
} catch (error) {
|
|
if (hasCompleted) return
|
|
hasCompleted = true
|
|
|
|
const duration = Date.now() - startTime
|
|
|
|
// Check if this is an abort error
|
|
const errorObj = error as any
|
|
const isAborted =
|
|
errorObj?.name === 'AbortError' ||
|
|
errorObj?.message?.includes('aborted') ||
|
|
options.abortController?.signal.aborted
|
|
|
|
if (isAborted) {
|
|
logger.info('SDK query aborted by client disconnect', { duration })
|
|
// Simply cleanup and return - don't emit error events
|
|
stream.emit('data', {
|
|
type: 'cancelled',
|
|
error: new Error('Request aborted by client')
|
|
})
|
|
return
|
|
}
|
|
|
|
// Original error handling for non-abort errors
|
|
logger.error('SDK query error:', {
|
|
error: errorObj instanceof Error ? errorObj.message : String(errorObj),
|
|
duration,
|
|
messageCount: jsonOutput.length
|
|
})
|
|
|
|
// Emit error event
|
|
stream.emit('data', {
|
|
type: 'error',
|
|
error: errorObj instanceof Error ? errorObj : new Error(String(errorObj))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
export default ClaudeCodeService
|