diff --git a/apps/claude-bridge/package.json b/apps/claude-bridge/package.json index c1a9ca8..9fddac6 100644 --- a/apps/claude-bridge/package.json +++ b/apps/claude-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/claude-bridge", - "version": "1.0.10", + "version": "1.0.14", "description": "Use non-Anthropic models with Claude Code by proxying requests through the lemmy unified interface", "type": "module", "main": "dist/index.js", diff --git a/apps/claude-bridge/src/interceptor.ts b/apps/claude-bridge/src/interceptor.ts index 10d1c77..f21b169 100644 --- a/apps/claude-bridge/src/interceptor.ts +++ b/apps/claude-bridge/src/interceptor.ts @@ -12,7 +12,7 @@ import { import { transformAnthropicToLemmy } from "./transforms/anthropic-to-lemmy.js"; import { createAnthropicSSE } from "./transforms/lemmy-to-anthropic.js"; import { jsonSchemaToZod } from "./transforms/tool-schemas.js"; -import type { MessageCreateParamsBase } from "@anthropic-ai/sdk/resources/messages/messages.js"; +import type { MessageCreateParamsBase, ThinkingConfigEnabled } from "@anthropic-ai/sdk/resources/messages/messages.js"; import { Context, type AskResult, @@ -31,7 +31,6 @@ import { parseSSE, extractAssistantFromSSE } from "./utils/sse.js"; import { parseAnthropicMessageCreateRequest, parseResponse, - isAnthropicAPI, generateRequestId, type ParsedRequestData, } from "./utils/request-parser.js"; @@ -233,7 +232,16 @@ export class ClaudeBridgeInterceptor { } // Convert thinking parameters for provider - const askOptions = convertThinkingParameters(this.clientInfo.provider, originalRequest); + this.logger.log(`Original thinking config: ${JSON.stringify(originalRequest.thinking)}`); + const askOptions: any = { + ...(originalRequest.thinking?.type === "enabled" && { + thinking: { + type: "enabled", + budget_tokens: originalRequest.thinking.budget_tokens, + } as ThinkingConfigEnabled, + }), + }; + this.logger.log(`Converted thinking config: ${JSON.stringify(askOptions.thinking)}`); // Apply capability adjustments if (validation.adjustments.maxOutputTokens) { @@ -456,3 +464,10 @@ export async function initializeInterceptor(config?: BridgeConfig): Promise) => { + const request = args[0]; + const config = (client as any).config || {}; + + // For OpenAI provider, the request will be transformed internally + // so we just log what we're sending to the lemmy client + if (provider === "openai") { + console.debug(`[${provider}] Sending request to lemmy client:`, { + model: config.model, + baseURL: config.baseURL, + request: JSON.stringify(request, null, 2), + }); + } else { + const fullUrl = config.baseURL?.endsWith("/completions") + ? config.baseURL + : `${config.baseURL}/chat/completions`; + + // Generate curl command for easy debugging + const curlCommand = [ + "curl", + "-X POST", + `"${fullUrl}"`, + '-H "Content-Type: application/json"', + `-H "Authorization: Bearer ${config.apiKey || "YOUR_API_KEY"}"`, + `-d '${JSON.stringify(request)}'`, + ].join(" \\\n "); + + console.debug(`[${provider}] Request as curl command:\n${curlCommand}`); + + console.debug(`[${provider}] Request details:`, { + baseURL: config.baseURL, + fullUrl, + headers: { + Authorization: "Bearer " + (config.apiKey ? "***" + config.apiKey.slice(-4) : "undefined"), + "Content-Type": "application/json", + }, + body: JSON.stringify(request, null, 2), + }); + } + + try { + const response = await client.ask(...args); + console.debug(`[${provider}] Response:`, JSON.stringify(response, null, 2)); + return response; + } catch (error) { + console.debug(`[${provider}] Error:`, error); + throw error; + } + }, + }; +} /** * Create provider-agnostic client for a given model */ export async function createProviderClient(config: BridgeConfig): Promise { - // For known models, use the registry - const modelData = findModelData(config.model); let provider: Provider; let client: ChatClient; - if (modelData) { - // Known model - use standard approach - provider = getProviderForModel(config.model as AllModels) as Provider; + // If proxy provider is specified, use it directly without model registry lookup + if (config.provider === "proxy") { + provider = "proxy"; const providerConfig = buildProviderConfig(provider, config); - client = createClientForModel(config.model as AllModels, providerConfig); + + // Create OpenAI-compatible client for proxy + const { lemmy } = await import("@mariozechner/lemmy"); + const baseURL = providerConfig.baseURL?.endsWith("/completions") + ? providerConfig.baseURL + : providerConfig.baseURL?.endsWith("/v1/chat") + ? `${providerConfig.baseURL}/completions` + : providerConfig.baseURL?.endsWith("/v1") + ? `${providerConfig.baseURL}/chat/completions` + : `${providerConfig.baseURL}/v1/chat/completions`; + + console.debug(`[${provider}] Setting up client with baseURL:`, baseURL); + + client = lemmy.openai({ + ...(providerConfig as OpenAIConfig), + baseURL, + }); + + // Wrap the client to handle Anthropic-style tool parsing and request transformation + client = { + ...client, + ask: async (options: any) => { + // Transform request to OpenAI format + const contentBlocks = Array.isArray(options.content) + ? options.content + : [{ type: "text", text: options.content }]; + + const openaiRequest: any = { + model: options.model || config.model, + messages: [ + { + role: "user", + content: + contentBlocks.length === 1 && contentBlocks[0]?.type === "text" + ? contentBlocks[0].text + : contentBlocks, + }, + ], + stream: false, + max_tokens: options.maxOutputTokens || 512, + temperature: options.temperature || 0.7, + }; + + // Handle tool results if present + if (options.toolResults && Array.isArray(options.toolResults)) { + // Ensure content is an array before pushing tool results + if (typeof openaiRequest.messages[0].content === "string") { + openaiRequest.messages[0].content = [{ type: "text", text: openaiRequest.messages[0].content }]; + } + // Add tool results to the content array of the first message + openaiRequest.messages[0].content.push( + ...options.toolResults.map((result: any) => ({ + type: "tool_result", + tool_use_id: result.toolCallId, + content: result.content, + })), + ); + } + + // Parse tools in Anthropic style before sending to OpenAI-compatible API + if (options.tools) { + openaiRequest.tools = options.tools.map((tool: any) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + })); + } + + // Handle thinking parameters + if (options.thinking?.type === "enabled") { + // Add thinking configuration to root request + openaiRequest.thinking = { + type: "enabled", + budget_tokens: options.thinking.budget_tokens, + } as ThinkingConfigEnabled; + + // Add thinking block to each message's content + openaiRequest.messages = openaiRequest.messages.map((msg: any) => { + const content = Array.isArray(msg.content) + ? msg.content + : [ + { + type: "text", + text: msg.content, + }, + ]; + + // Add thinking block to content + const thinkingBlock: ThinkingBlock = { + type: "thinking", + thinking: "", + signature: "", + }; + content.push(thinkingBlock); + + return { + ...msg, + content, + }; + }); + } + + // Log the request + if (config.debug) { + const fullUrl = config.baseURL?.endsWith("/completions") + ? config.baseURL + : `${config.baseURL}/chat/completions`; + + // Generate curl command for easy debugging + const curlCommand = [ + "curl", + "-X POST", + `"${fullUrl}"`, + '-H "Content-Type: application/json"', + `-H "Authorization: Bearer ${config.apiKey || "YOUR_API_KEY"}"`, + `-d '${JSON.stringify(openaiRequest)}'`, + ].join(" \\\n "); + + console.debug(`[${provider}] Request as curl command:\n${curlCommand}`); + + console.debug(`[${provider}] Request details:`, { + baseURL: config.baseURL, + fullUrl, + headers: { + Authorization: "Bearer " + (config.apiKey ? "***" + config.apiKey.slice(-4) : "undefined"), + "Content-Type": "application/json", + }, + body: JSON.stringify(openaiRequest, null, 2), + }); + } + + const response = await client.ask(openaiRequest); + console.debug(`[${provider}] Response:`, JSON.stringify(response, null, 2)); + return response; + }, + }; } else { - // Unknown model - use the configured provider directly - provider = config.provider; - const providerConfig = buildProviderConfig(provider, config); + // For known models, use the registry + const modelData = findModelData(config.model); - // Create client directly using lemmy's provider factories - switch (provider) { - case "openai": { - const { lemmy } = await import("@mariozechner/lemmy"); - client = lemmy.openai(providerConfig as OpenAIConfig); - break; - } - case "google": { - const { lemmy } = await import("@mariozechner/lemmy"); - client = lemmy.google(providerConfig as GoogleConfig); - break; - } - case "anthropic": { - const { lemmy } = await import("@mariozechner/lemmy"); - client = lemmy.anthropic(providerConfig as AnthropicConfig); - break; + if (modelData) { + // Known model - use standard approach + provider = getProviderForModel(config.model as AllModels) as Provider; + const providerConfig = buildProviderConfig(provider, config); + client = createClientForModel(config.model as AllModels, providerConfig); + } else { + // Unknown model - use the configured provider directly + provider = config.provider; + const providerConfig = buildProviderConfig(provider, config); + + // Create client directly using lemmy's provider factories + switch (provider) { + case "openai": { + const { lemmy } = await import("@mariozechner/lemmy"); + // For custom baseURL, pass it directly without modification + client = lemmy.openai({ + ...(providerConfig as OpenAIConfig), + // Keep the user's baseURL exactly as provided + baseURL: config.baseURL, + }); + break; + } + case "google": { + const { lemmy } = await import("@mariozechner/lemmy"); + client = lemmy.google(providerConfig as GoogleConfig); + break; + } + case "anthropic": { + const { lemmy } = await import("@mariozechner/lemmy"); + client = lemmy.anthropic(providerConfig as AnthropicConfig); + break; + } + default: + const _exhaustiveCheck: never = provider; + throw new Error(`Unsupported provider: ${_exhaustiveCheck}`); } - default: - const _exhaustiveCheck: never = provider; - throw new Error(`Unsupported provider: ${_exhaustiveCheck}`); } } + // Wrap all clients with debug logging if debug mode is enabled + // Skip for proxy provider as it has its own logging + if (provider !== "proxy") { + client = wrapClientWithLogging(client, provider, config.debug || false); + } + return { client, provider, model: config.model, - modelData: modelData || null, // null for unknown models + modelData: findModelData(config.model) || null, }; } @@ -89,6 +308,7 @@ function buildProviderConfig(provider: Provider, config: BridgeConfig): Provider case "anthropic": return baseConfig as AnthropicConfig; case "openai": + case "proxy": return baseConfig as OpenAIConfig; case "google": return baseConfig as GoogleConfig; @@ -109,6 +329,7 @@ function getDefaultApiKey(provider: Provider): string { if (!anthropicKey) throw new Error("ANTHROPIC_API_KEY environment variable is required"); return anthropicKey; case "openai": + case "proxy": const openaiKey = process.env["OPENAI_API_KEY"]; if (!openaiKey) throw new Error("OPENAI_API_KEY environment variable is required"); return openaiKey; @@ -168,56 +389,64 @@ export function validateCapabilities( }; } +/** + * Validate thinking parameters + */ +function validateThinkingParameters(thinking: ThinkingConfigEnabled | undefined): void { + if (!thinking) return; + + if (thinking.type !== "enabled") { + throw new Error("Invalid thinking type. Only 'enabled' is supported."); + } + + if (thinking.budget_tokens !== undefined) { + if (typeof thinking.budget_tokens !== "number" || thinking.budget_tokens < 1024) { + throw new Error("Invalid thinking budget_tokens. Must be a number >= 1024."); + } + } +} + /** * Convert thinking parameters based on provider type */ -export function convertThinkingParameters( - provider: Provider, - anthropicRequest: MessageCreateParamsBase, -): AnthropicAskOptions | OpenAIAskOptions | GoogleAskOptions { - const baseOptions = { - maxOutputTokens: anthropicRequest.max_tokens, - }; +export function convertThinkingParameters(thinking: ThinkingConfigEnabled | undefined, provider: Provider) { + if (!thinking) { + return undefined; + } + // Validate thinking parameters + if (thinking.type !== "enabled") { + throw new Error('Invalid thinking configuration: type must be "enabled"'); + } + + if (typeof thinking.budget_tokens !== "number" || thinking.budget_tokens < 1024) { + throw new Error("Invalid thinking configuration: budget_tokens must be a number >= 1024"); + } + + // Convert based on provider switch (provider) { case "anthropic": return { - ...baseOptions, - // Anthropic uses the same thinking parameters - ...(anthropicRequest.thinking?.type == "enabled" && { - thinkingEnabled: true, - }), - ...(anthropicRequest.thinking?.type == "enabled" && - anthropicRequest.thinking.budget_tokens !== undefined && { - maxThinkingTokens: anthropicRequest.thinking.budget_tokens, - }), - } as AnthropicAskOptions; - + type: "enabled", + budget_tokens: thinking.budget_tokens, + }; case "google": - const options: GoogleAskOptions = { - ...baseOptions, - // Google uses includeThoughts for thinking - ...(anthropicRequest.thinking?.type == "enabled" && { - includeThoughts: true, - }), - ...(anthropicRequest.thinking?.type == "enabled" && - anthropicRequest.thinking.budget_tokens !== undefined && { - thinkingBudget: anthropicRequest.thinking.budget_tokens, - }), + return { + includeThoughts: true, + thinkingBudget: thinking.budget_tokens, }; - return options; - case "openai": return { - ...baseOptions, - ...(anthropicRequest.thinking?.type == "enabled" && { - reasoningEffort: "medium" as const, - }), - } as OpenAIAskOptions; - + reasoningEffort: "high", + }; + case "proxy": + return { + thinking: { + type: "enabled", + budget_tokens: thinking.budget_tokens, + }, + }; default: - // TypeScript exhaustiveness check - const _exhaustiveCheck: never = provider; - throw new Error(`Unsupported provider: ${_exhaustiveCheck}`); + return undefined; } } diff --git a/apps/diffy-mcp/package.json b/apps/diffy-mcp/package.json index e562a34..41b94fb 100644 --- a/apps/diffy-mcp/package.json +++ b/apps/diffy-mcp/package.json @@ -6,12 +6,13 @@ "packages/*" ], "scripts": { - "build": "npm run build --workspace=packages/server --workspace=packages/frontend", - "dev": "npm run dev --workspace=packages/server --workspace=packages/frontend", - "test": "npm run test --workspace=packages/server --workspace=packages/frontend", + "prebuild": "npm install", + "build": "cd packages/server && npm run build && cd ../frontend && npm run build", + "dev": "cd packages/server && npm run dev && cd ../frontend && npm run dev", + "test": "cd packages/server && npm run test && cd ../frontend && npm run test", "test:manual": "node scripts/test-manual.js", "test:auto": "node scripts/test-auto.js", - "clean": "npm run clean --workspace=packages/server --workspace=packages/frontend" + "clean": "cd packages/server && npm run clean && cd ../frontend && npm run clean" }, "keywords": [ "mcp", diff --git a/apps/snap-happy/package.json b/apps/snap-happy/package.json index 1da159a..68164c0 100644 --- a/apps/snap-happy/package.json +++ b/apps/snap-happy/package.json @@ -8,7 +8,7 @@ "snap-happy": "dist/index.js" }, "scripts": { - "build": "tsc && chmod +x dist/index.js && cd native && make universal", + "build": "tsc && chmod +x dist/index.js && ([ \"$(uname)\" = \"Darwin\" ] && cd native && make universal || echo 'Skipping native build on non-macOS platform')", "build:dev": "tsc && chmod +x dist/index.js && cd native && make dev", "build:native": "cd native && make universal", "build:native:dev": "cd native && make dev", diff --git a/package-lock.json b/package-lock.json index e480cd3..be4e530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,6 +126,7 @@ }, "apps/code-search-mcp": { "version": "1.0.0", + "extraneous": true, "license": "MIT", "dependencies": { "web-tree-sitter": "^0.24.4" @@ -3837,10 +3838,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/code-search-mcp": { - "resolved": "apps/code-search-mcp", - "link": true - }, "node_modules/collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", @@ -10944,12 +10941,6 @@ "node": ">= 14" } }, - "node_modules/web-tree-sitter": { - "version": "0.24.7", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.7.tgz", - "integrity": "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==", - "license": "MIT" - }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/packages/lemmy/src/generated/models.ts b/packages/lemmy/src/generated/models.ts index 4b33359..126ce93 100644 --- a/packages/lemmy/src/generated/models.ts +++ b/packages/lemmy/src/generated/models.ts @@ -174,6 +174,7 @@ export const AnthropicModelData = { export type OpenAIModels = | "babbage-002" | "chatgpt-4o-latest" + | "claude-opus-4-20250514" | "codex-mini-latest" | "computer-use-preview" | "computer-use-preview-2025-03-11" @@ -265,6 +266,16 @@ export const OpenAIModelData = { outputPerMillion: 15, }, }, + "claude-opus-4-20250514": { + contextWindow: 200000, + maxOutputTokens: 32000, + supportsTools: true, + supportsImageInput: true, + pricing: { + inputPerMillion: 15, + outputPerMillion: 75, + }, + }, "codex-mini-latest": { contextWindow: 200000, maxOutputTokens: 100000, @@ -1492,10 +1503,10 @@ export const ModelToProvider = { "claude-3-opus-20240229": "anthropic", "claude-3-opus-latest": "anthropic", "claude-3-sonnet-20240229": "anthropic", - "claude-opus-4-20250514": "anthropic", "claude-sonnet-4-20250514": "anthropic", "babbage-002": "openai", "chatgpt-4o-latest": "openai", + "claude-opus-4-20250514": "openai", "codex-mini-latest": "openai", "computer-use-preview": "openai", "computer-use-preview-2025-03-11": "openai",