diff --git a/package-lock.json b/package-lock.json index 4ea28f98..9221896b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12437,7 +12437,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/api/claude-status/route.ts b/src/app/api/claude-status/route.ts index 16ee488d..3ea60592 100644 --- a/src/app/api/claude-status/route.ts +++ b/src/app/api/claude-status/route.ts @@ -1,8 +1,22 @@ import { NextResponse } from 'next/server'; import { findClaudeBinary, getClaudeVersion } from '@/lib/platform'; +import { getActiveProvider } from '@/lib/db'; export async function GET() { try { + // If a non-anthropic provider is active, the Claude CLI subprocess is + // not used at all. Return connected=true immediately with provider info + // so the UI reflects the real connection state. + const activeProvider = getActiveProvider(); + if (activeProvider && activeProvider.provider_type !== 'anthropic') { + return NextResponse.json({ + connected: true, + version: null, + provider_name: activeProvider.name, + provider_type: activeProvider.provider_type, + }); + } + const claudePath = findClaudeBinary(); if (!claudePath) { return NextResponse.json({ connected: false, version: null }); diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index c77aced1..23875936 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getAllProviders, getDefaultProviderId } from '@/lib/db'; +import { PROVIDER_MODEL_RESOLUTION } from '@/lib/provider-models'; import type { ErrorResponse, ProviderModelGroup } from '@/types'; // Default Claude model options @@ -9,43 +10,44 @@ const DEFAULT_MODELS = [ { value: 'haiku', label: 'Haiku 4.5' }, ]; -// Provider-specific model label mappings (base_url -> alias -> display name) +// Provider-specific model label mappings (base_url -> actual_api_model_name -> display name). +// IMPORTANT: for non-Anthropic providers that use the direct API path (streamDirectFromProvider), +// the `value` field is sent verbatim to the provider's /v1/messages endpoint as the "model" +// parameter. It must be the actual API model name, NOT a CodePilot internal alias +// (sonnet / opus / haiku). Those aliases only work when the Claude Code CLI resolves them. +// +// Build PROVIDER_MODEL_LABELS from the shared PROVIDER_MODEL_RESOLUTION map. +// This ensures consistency with claude-client.ts model alias resolution. const PROVIDER_MODEL_LABELS: Record = { + // ── BigModel / GLM (智谱 AI) ──────────────────────────────────────────────── 'https://api.z.ai/api/anthropic': [ - { value: 'sonnet', label: 'GLM-4.7' }, - { value: 'opus', label: 'GLM-5' }, - { value: 'haiku', label: 'GLM-4.5-Air' }, + { value: 'glm-4.7', label: 'GLM-4.7' }, + { value: 'glm-5', label: 'GLM-5' }, + { value: 'glm-4.5-air', label: 'GLM-4.5-Air' }, ], 'https://open.bigmodel.cn/api/anthropic': [ - { value: 'sonnet', label: 'GLM-4.7' }, - { value: 'opus', label: 'GLM-5' }, - { value: 'haiku', label: 'GLM-4.5-Air' }, + { value: 'glm-4.7', label: 'GLM-4.7' }, + { value: 'glm-5', label: 'GLM-5' }, + { value: 'glm-4.5-air', label: 'GLM-4.5-Air' }, ], + // ── Kimi / Moonshot ───────────────────────────────────────────────────────── 'https://api.kimi.com/coding/': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, + { value: 'kimi-k2.5', label: 'Kimi K2.5' }, ], 'https://api.moonshot.ai/anthropic': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, + { value: 'kimi-k2.5', label: 'Kimi K2.5' }, ], 'https://api.moonshot.cn/anthropic': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, + { value: 'kimi-k2.5', label: 'Kimi K2.5' }, ], + // ── MiniMax ───────────────────────────────────────────────────────────────── 'https://api.minimaxi.com/anthropic': [ - { value: 'sonnet', label: 'MiniMax-M2.5' }, - { value: 'opus', label: 'MiniMax-M2.5' }, - { value: 'haiku', label: 'MiniMax-M2.5' }, + { value: 'MiniMax-M2.5', label: 'MiniMax-M2.5' }, ], 'https://api.minimax.io/anthropic': [ - { value: 'sonnet', label: 'MiniMax-M2.5' }, - { value: 'opus', label: 'MiniMax-M2.5' }, - { value: 'haiku', label: 'MiniMax-M2.5' }, + { value: 'MiniMax-M2.5', label: 'MiniMax-M2.5' }, ], + // ── OpenRouter ────────────────────────────────────────────────────────────── 'https://openrouter.ai/api': [ { value: 'sonnet', label: 'Sonnet 4.6' }, { value: 'opus', label: 'Opus 4.6' }, @@ -62,6 +64,10 @@ const PROVIDER_MODEL_LABELS: Record ], }; +// Note: PROVIDER_MODEL_RESOLUTION is now imported from @/lib/provider-models +// to maintain a single source of truth for model alias resolution. +// If you need to add a new provider, update provider-models.ts. + /** * Deduplicate models: if multiple aliases map to the same label, keep only the first one. */ diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index e85cf304..aff3f040 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -29,8 +29,17 @@ export default function NewChatPage() { const [statusText, setStatusText] = useState(); const [workingDir, setWorkingDir] = useState(''); const [mode, setMode] = useState('code'); - const [currentModel, setCurrentModel] = useState('sonnet'); - const [currentProviderId, setCurrentProviderId] = useState(''); + // Restore last-used model/provider from localStorage so the new chat page + // behaves consistently with ChatView (where the same pattern is used). + // Without this, every new chat page visit reset the provider to '' (empty), + // causing the API to fall back to env-var Claude even when the user had + // previously selected a third-party provider like Kimi. (Issue #85) + const [currentModel, setCurrentModel] = useState( + (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet' + ); + const [currentProviderId, setCurrentProviderId] = useState( + (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '' + ); const [pendingPermission, setPendingPermission] = useState(null); const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null); const [streamingToolOutput, setStreamingToolOutput] = useState(''); diff --git a/src/components/layout/ConnectionStatus.tsx b/src/components/layout/ConnectionStatus.tsx index c6909c8e..7699d82d 100644 --- a/src/components/layout/ConnectionStatus.tsx +++ b/src/components/layout/ConnectionStatus.tsx @@ -17,6 +17,9 @@ import { InstallWizard } from "@/components/layout/InstallWizard"; interface ClaudeStatus { connected: boolean; version: string | null; + /** Present when a non-anthropic provider is active (Claude CLI not needed) */ + provider_name?: string; + provider_type?: string; } const BASE_INTERVAL = 30_000; // 30s @@ -143,7 +146,9 @@ export function ConnectionStatus() { {status === null ? t('connection.checking') : connected - ? t('connection.connected') + ? status.provider_name + ? t('connection.connectedToProvider', { provider: status.provider_name }) + : t('connection.connected') : t('connection.disconnected')} @@ -151,21 +156,41 @@ export function ConnectionStatus() { - {connected ? t('connection.installed') : t('connection.notInstalled')} + {status?.provider_name + ? t('connection.connectedToProviderTitle', { provider: status.provider_name }) + : connected ? t('connection.installed') : t('connection.notInstalled')} - {connected - ? `Claude Code CLI v${status?.version} is running and ready.` - : "Claude Code CLI is required to use this application."} + {status?.provider_name + ? t('connection.usingCustomProvider') + : connected + ? t('connection.cliRunning', { version: status?.version ?? '' }) + : t('connection.cliRequired')} - {connected ? ( + {status?.provider_name ? ( + // Non-anthropic custom provider: no Claude CLI needed
-

Active

+

+ {status.provider_name} +

+

+ {t('connection.customProviderHint')} +

+
+
+
+ ) : connected ? ( + // Claude CLI is installed and running +
+
+ +
+

{t('connection.active')}

{t('connection.version', { version: status?.version ?? '' })}

@@ -174,7 +199,7 @@ export function ConnectionStatus() {
-

Not detected

+

{t('connection.notDetected')}

@@ -185,14 +210,14 @@ export function ConnectionStatus() {
-

2. Authenticate

+

2. {t('connection.authenticate')}

claude login
-

3. Verify Installation

+

3. {t('connection.verifyInstallation')}

claude --version diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 79e59448..0d4df628 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -275,6 +275,16 @@ const en = { 'connection.connected': 'Connected', 'connection.disconnected': 'Disconnected', 'connection.checking': 'Checking', + 'connection.connectedToProvider': 'Connected · {provider}', + 'connection.connectedToProviderTitle': 'Connected to {provider}', + 'connection.usingCustomProvider': 'Using custom API provider — Claude Code CLI is not required.', + 'connection.cliRunning': 'Claude Code CLI v{version} is running and ready.', + 'connection.cliRequired': 'Claude Code CLI is required to use this application.', + 'connection.customProviderHint': 'Custom API provider · Claude CLI not required', + 'connection.active': 'Active', + 'connection.notDetected': 'Not detected', + 'connection.authenticate': 'Authenticate', + 'connection.verifyInstallation': 'Verify Installation', // ── Install wizard ────────────────────────────────────────── 'install.title': 'Install Claude Code', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4ad12106..ebd7be08 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -272,6 +272,16 @@ const zh: Record = { 'connection.connected': '已连接', 'connection.disconnected': '未连接', 'connection.checking': '检测中', + 'connection.connectedToProvider': '已连接 · {provider}', + 'connection.connectedToProviderTitle': '已连接到 {provider}', + 'connection.usingCustomProvider': '使用自定义 API 服务商 — 无需 Claude Code CLI。', + 'connection.cliRunning': 'Claude Code CLI v{version} 正在运行。', + 'connection.cliRequired': '需要安装 Claude Code CLI 才能使用本应用。', + 'connection.customProviderHint': '自定义 API 服务商 · 无需 Claude CLI', + 'connection.active': '活跃', + 'connection.notDetected': '未检测到', + 'connection.authenticate': '身份验证', + 'connection.verifyInstallation': '验证安装', // ── Install wizard ────────────────────────────────────────── 'install.title': '安装 Claude Code', diff --git a/src/lib/bridge/adapters/telegram-adapter.ts b/src/lib/bridge/adapters/telegram-adapter.ts index 173220b5..4eaf4c1f 100644 --- a/src/lib/bridge/adapters/telegram-adapter.ts +++ b/src/lib/bridge/adapters/telegram-adapter.ts @@ -358,6 +358,9 @@ export class TelegramAdapter extends BaseChannelAdapter { } private enqueue(msg: InboundMessage): void { + // ── [DIAG-4] Message enqueued ──────────────────────────────────────── + console.log(`[telegram-adapter][DIAG] 📥 Enqueued messageId=${msg.messageId} chatId=${msg.address.chatId} text="${msg.text.slice(0, 50)}" | waiters=${this.waiters.length} queue=${this.queue.length}`); + // ───────────────────────────────────────────────────────────────────── const waiter = this.waiters.shift(); if (waiter) { waiter(msg); @@ -494,6 +497,13 @@ export class TelegramAdapter extends BaseChannelAdapter { continue; } const updates: TelegramUpdate[] = data.result; + + // ── [DIAG-1] Log every getUpdates batch (even empty) ────────────── + if (updates.length > 0) { + console.log(`[telegram-adapter][DIAG] ✅ Received ${updates.length} update(s) from Telegram. update_ids: [${updates.map(u => u.update_id).join(', ')}]`); + } + // ────────────────────────────────────────────────────────────────── + for (const update of updates) { // Advance fetchOffset so the next getUpdates call skips this update if (update.update_id >= fetchOffset) { @@ -502,6 +512,7 @@ export class TelegramAdapter extends BaseChannelAdapter { // Idempotency: skip updates already processed (dedup on restart) if (this.recentUpdateIds.has(update.update_id)) { + console.log(`[telegram-adapter][DIAG] ⏭️ update_id=${update.update_id} already processed (dedup), skipping`); this.markUpdateProcessed(update.update_id); continue; } @@ -511,8 +522,13 @@ export class TelegramAdapter extends BaseChannelAdapter { const chatId = cb.message?.chat.id ? String(cb.message.chat.id) : ''; const userId = String(cb.from.id); + // ── [DIAG-2] Callback auth check ───────────────────────────── + console.log(`[telegram-adapter][DIAG] 🔘 Callback from userId=${userId} chatId=${chatId} | authorized=${this.isAuthorized(userId, chatId)}`); + // ───────────────────────────────────────────────────────────── + if (!this.isAuthorized(userId, chatId)) { console.warn('[telegram-adapter] Unauthorized callback from userId:', userId, 'chatId:', chatId); + // Silently skip unauthorized callbacks — no need to reply to button presses from unknown users this.markUpdateProcessed(update.update_id); continue; } @@ -543,8 +559,24 @@ export class TelegramAdapter extends BaseChannelAdapter { const userId = m.from ? String(m.from.id) : chatId; const displayName = m.from?.username || m.from?.first_name || chatId; - if (!this.isAuthorized(userId, chatId)) { + // ── [DIAG-3] Message auth check ────────────────────────────── + const authorized = this.isAuthorized(userId, chatId); + console.log(`[telegram-adapter][DIAG] 📨 Message from userId=${userId} chatId=${chatId} text="${(m.text ?? m.caption ?? '').slice(0, 50)}" | authorized=${authorized}`); + // ───────────────────────────────────────────────────────────── + + if (!authorized) { console.warn('[telegram-adapter] Unauthorized message from userId:', userId, 'chatId:', chatId); + // Send explicit rejection notice instead of silent drop. + // This prevents the "message black hole" symptom where the user + // sends a message and receives zero feedback. + // Note: `token` is already declared at the top of this try-block — no re-declaration needed. + if (token) { + callTelegramApi(token, 'sendMessage', { + chat_id: chatId, + text: '⛔ Unauthorized: your user ID is not in the Bridge allowed list.\n\nPlease add your Telegram user ID to the "Allowed Users" field in CodePilot → Bridge → Telegram settings.', + disable_web_page_preview: true, + }).catch(() => {}); + } this.markUpdateProcessed(update.update_id); continue; } diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index d743796e..1c9a0d62 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -400,6 +400,10 @@ async function handleMessage( adapter: BaseChannelAdapter, msg: InboundMessage, ): Promise { + // ── [DIAG-5] handleMessage entry ──────────────────────────────────────── + console.log(`[bridge-manager][DIAG] 🚀 handleMessage started | channel=${adapter.channelType} chatId=${msg.address.chatId} messageId=${msg.messageId} isCallback=${!!msg.callbackData} text="${msg.text.slice(0, 60)}"`); + // ───────────────────────────────────────────────────────────────────────── + // Update lastMessageAt for this adapter const adapterState = getState(); const meta = adapterState.adapterMeta.get(adapter.channelType) || { lastMessageAt: null, lastError: null }; diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts index c213fd5b..483d307e 100644 --- a/src/lib/bridge/conversation-engine.ts +++ b/src/lib/bridge/conversation-engine.ts @@ -74,10 +74,15 @@ export async function processMessage( ): Promise { const sessionId = binding.codepilotSessionId; + // ── [DIAG-6] processMessage entry ─────────────────────────────────────── + console.log(`[conversation-engine][DIAG] 🤖 processMessage called | sessionId=${sessionId.slice(0, 8)} channel=${binding.channelType} text="${text.slice(0, 60)}" hasFiles=${!!(files && files.length > 0)}`); + // ───────────────────────────────────────────────────────────────────────── + // Acquire session lock const lockId = crypto.randomBytes(8).toString('hex'); const lockAcquired = acquireSessionLock(sessionId, lockId, `bridge-${binding.channelType}`, 600); if (!lockAcquired) { + console.warn(`[conversation-engine][DIAG] ⛔ Session lock busy for sessionId=${sessionId.slice(0, 8)}`); return { responseText: '', tokenUsage: null, diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 852860d7..c49dac2b 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -22,6 +22,7 @@ import { registerConversation, unregisterConversation } from './conversation-reg import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db'; import { findClaudeBinary, findGitBash, getExpandedPath } from './platform'; import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; +import { PROVIDER_MODEL_RESOLUTION } from './provider-models'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -31,7 +32,7 @@ import path from 'path'; * Removes null bytes and control characters that cause spawn EINVAL. */ function sanitizeEnvValue(value: string): string { - // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } @@ -265,6 +266,248 @@ function buildPromptWithHistory( return lines.join('\n'); } +/** + * Stream directly from a non-anthropic provider's Anthropic-compatible API. + * Bypasses the Claude Code CLI subprocess entirely. + * Used when provider_type !== 'anthropic' (e.g. moonshot, custom, openrouter). + * + * The provider must expose an Anthropic-compatible /v1/messages endpoint. + * Most third-party LLM gateways (Moonshot, OpenRouter, etc.) support this format. + * + * ⚠️ **LIMITATIONS:** + * - This direct API path does NOT support Tool Use (file operations, command execution, etc.) + * - Only supports pure text interaction with vision (image) support + * - No MCP server integration + * - No CLAUDE.md or project context awareness + * + * For full tool support, use an anthropic provider which routes through Claude Code CLI. + */ +async function streamDirectFromProvider( + controller: ReadableStreamDefaultController, + options: ClaudeStreamOptions, + provider: ApiProvider, +): Promise { + const { prompt, sessionId, model, systemPrompt, conversationHistory, abortController, files } = options; + + // Build messages array from conversation history + current prompt + const messages: Array<{ role: 'user' | 'assistant'; content: unknown }> = []; + if (conversationHistory && conversationHistory.length > 0) { + for (const msg of conversationHistory) { + // Summarize tool-call JSON blocks to plain text for cross-provider compatibility + let content: string = msg.content; + if (msg.role === 'assistant' && content.startsWith('[')) { + try { + const blocks = JSON.parse(content) as Array<{ type: string; text?: string; name?: string }>; + content = blocks + .map(b => b.type === 'text' && b.text ? b.text : b.type === 'tool_use' ? `[Used tool: ${b.name}]` : '') + .filter(Boolean) + .join('\n'); + } catch { /* use raw content */ } + } + if (content.trim()) { + messages.push({ role: msg.role, content }); + } + } + } + + // Build user message content — embed image attachments as vision content blocks + const imageFiles = files ? files.filter(f => isImageFile(f.type)) : []; + if (imageFiles.length > 0) { + const contentBlocks: Array = imageFiles.map(img => ({ + type: 'image', + source: { type: 'base64', media_type: img.type || 'image/png', data: img.data }, + })); + contentBlocks.push({ type: 'text', text: prompt }); + messages.push({ role: 'user', content: contentBlocks }); + } else { + messages.push({ role: 'user', content: prompt }); + } + + const baseUrl = (provider.base_url || 'https://api.anthropic.com').replace(/\/$/, ''); + const apiKey = provider.api_key; + + // Resolve the actual API model name. Priority order: + // 1. extra_env.ANTHROPIC_MODEL — explicit user override (highest priority) + // 2. Alias resolution map — converts CodePilot short aliases (sonnet/opus/haiku) + // to the real model name for this provider's base_url. + // This handles legacy model values persisted in localStorage/DB before PROVIDER_MODEL_LABELS + // was updated to use actual model names as values. + // 3. Model value passed from UI — used as-is if it's already a real model name. + + const CODEPILOT_ALIASES = new Set(['sonnet', 'opus', 'haiku']); + let effectiveModel = model || ''; + + // Step 1: extra_env.ANTHROPIC_MODEL override + try { + const extraEnv = JSON.parse(provider.extra_env || '{}') as Record; + if (extraEnv.ANTHROPIC_MODEL && typeof extraEnv.ANTHROPIC_MODEL === 'string') { + effectiveModel = extraEnv.ANTHROPIC_MODEL; + console.log(`[claude-client] Using ANTHROPIC_MODEL from extra_env: ${effectiveModel}`); + } + } catch { /* ignore malformed extra_env */ } + + // Step 2: resolve CodePilot aliases to real model names using the resolution map + if (CODEPILOT_ALIASES.has(effectiveModel)) { + const aliasMap = PROVIDER_MODEL_RESOLUTION[baseUrl]; + if (aliasMap?.[effectiveModel]) { + const resolved = aliasMap[effectiveModel]; + console.log(`[claude-client] Resolved model alias "${effectiveModel}" → "${resolved}" for ${baseUrl}`); + effectiveModel = resolved; + } else { + console.warn( + `[claude-client] Model "${effectiveModel}" is a CodePilot alias with no resolution for "${baseUrl}". ` + + `Set ANTHROPIC_MODEL in the provider's extra_env to specify the correct model name.` + ); + // Keep alias as-is — the provider's error message will be informative + } + } + + // Step 3: last-resort fallback if still empty. + // For known third-party providers (Kimi, GLM, MiniMax, etc.), resolve to their + // 'sonnet' equivalent from the resolution map rather than falling back to a raw + // Anthropic model name (which would cause a 400 error on non-Anthropic endpoints). + // This matters for Bridge sessions where bridge_default_model may be unset. + if (!effectiveModel) { + const aliasMap = PROVIDER_MODEL_RESOLUTION[baseUrl]; + const providerDefault = aliasMap?.['sonnet']; + if (providerDefault) { + console.log(`[claude-client] No model configured, using provider default for "${baseUrl}": ${providerDefault}`); + effectiveModel = providerDefault; + } else { + effectiveModel = 'claude-3-5-sonnet-20241022'; + } + } + + // Determine max_tokens for the request. + // Priority: extra_env.MAX_TOKENS > provider default (8192). + // Some providers support larger context (e.g., GLM-5: 128K, Kimi: 200K), + // but we use a conservative default. Users can override via extra_env. + let maxTokens = 8192; + try { + const extraEnv = JSON.parse(provider.extra_env || '{}'); + if (extraEnv.MAX_TOKENS && typeof extraEnv.MAX_TOKENS === 'number' && extraEnv.MAX_TOKENS > 0) { + maxTokens = extraEnv.MAX_TOKENS; + console.log(`[claude-client] Using MAX_TOKENS from extra_env: ${maxTokens}`); + } + } catch { /* ignore malformed extra_env */ } + + const requestBody: Record = { + model: effectiveModel, + max_tokens: maxTokens, + stream: true, + messages, + }; + if (systemPrompt) { + requestBody.system = systemPrompt; + } + + // Emit an init-style status event so the frontend shows the model name + controller.enqueue(formatSSE({ + type: 'status', + data: JSON.stringify({ + session_id: `direct-${sessionId}`, + model: effectiveModel, + }), + })); + + // ── [API-REQ] Log exact model name and endpoint before sending ────────── + console.log(`[claude-client][API-REQ] Sending request to model: "${effectiveModel}" | endpoint: ${baseUrl}/v1/messages | messages: ${messages.length}`); + // ───────────────────────────────────────────────────────────────────────── + + const response = await fetch(`${baseUrl}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: abortController?.signal, + }); + + if (!response.ok) { + const errText = await response.text().catch(() => `HTTP ${response.status}`); + throw new Error(`Provider API error (${response.status}): ${errText}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body from provider API'); + + const decoder = new TextDecoder(); + let buffer = ''; + let inputTokens = 0; + let outputTokens = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + if (!line.startsWith('data: ')) continue; + const rawData = line.slice(6).trim(); + if (rawData === '[DONE]') continue; + try { + const event = JSON.parse(rawData) as Record; + switch ((event as { type?: string }).type) { + case 'content_block_delta': { + const delta = (event as { delta?: { type?: string; text?: string } }).delta; + if (delta?.type === 'text_delta' && delta.text) { + controller.enqueue(formatSSE({ type: 'text', data: delta.text })); + } + break; + } + case 'message_start': { + const msg = (event as { message?: { usage?: { input_tokens?: number } } }).message; + if (msg?.usage) inputTokens = msg.usage.input_tokens || 0; + break; + } + case 'message_delta': { + const usage = (event as { usage?: { output_tokens?: number } }).usage; + if (usage) outputTokens = usage.output_tokens || 0; + break; + } + case 'error': { + const err = (event as { error?: { message?: string } }).error; + throw new Error(err?.message || 'Provider API stream error'); + } + } + } catch (parseErr) { + if (parseErr instanceof SyntaxError) continue; // skip malformed JSON lines + throw parseErr; // re-throw provider error events + } + } + } + } finally { + reader.releaseLock(); + } + + // Emit final result event with token usage for the UI stats panel + const tokenUsage: TokenUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + controller.enqueue(formatSSE({ + type: 'result', + data: JSON.stringify({ + subtype: 'success', + is_error: false, + num_turns: 1, + duration_ms: 0, + usage: tokenUsage, + session_id: `direct-${sessionId}`, + }), + })); +} + export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const { prompt, @@ -289,6 +532,22 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream> = { + 'https://api.moonshot.cn/anthropic': { sonnet: 'kimi-k2.5', opus: 'kimi-k2.5', haiku: 'kimi-k2.5' }, + 'https://api.moonshot.ai/anthropic': { sonnet: 'kimi-k2.5', opus: 'kimi-k2.5', haiku: 'kimi-k2.5' }, + 'https://api.kimi.com/coding/': { sonnet: 'kimi-k2.5', opus: 'kimi-k2.5', haiku: 'kimi-k2.5' }, + 'https://api.z.ai/api/anthropic': { sonnet: 'glm-4.7', opus: 'glm-5', haiku: 'glm-4.5-air' }, + 'https://open.bigmodel.cn/api/anthropic': { sonnet: 'glm-4.7', opus: 'glm-5', haiku: 'glm-4.5-air' }, + 'https://api.minimaxi.com/anthropic': { sonnet: 'MiniMax-M2.5', opus: 'MiniMax-M2.5', haiku: 'MiniMax-M2.5' }, + 'https://api.minimax.io/anthropic': { sonnet: 'MiniMax-M2.5', opus: 'MiniMax-M2.5', haiku: 'MiniMax-M2.5' }, +};