diff --git a/docs/codex-config-proxy-plan.md b/docs/codex-config-proxy-plan.md new file mode 100644 index 0000000..d504b11 --- /dev/null +++ b/docs/codex-config-proxy-plan.md @@ -0,0 +1,158 @@ +# Codex Config And Proxy Plan + +## Goals + +- Launch Codex from Hermes without mutating the user's real `~/.codex`. +- Keep model/provider config isolated by Hermes profile and provider. +- Support OpenAI Responses providers directly. +- Add a local adapter for providers that only expose OpenAI Chat Completions. +- Keep Codex resume/history stable by using a consistent model provider id. + +## Directory Layout + +All generated Codex state should live under the Web UI home: + +```text +~/.hermes-web-ui/coding-agent/ + model/{profile}/{provider}/codex/ + config.toml + auth.json + AGENTS.md + workspace/{profile}/{provider}/ +``` + +Launch command shape: + +```bash +cd ~/.hermes-web-ui/coding-agent/workspace/{profile}/{provider} \ + && CODEX_HOME=~/.hermes-web-ui/coding-agent/model/{profile}/{provider}/codex \ + codex --model {model} +``` + +## Current MVP Config + +For Responses-compatible providers, generate: + +```toml +model_provider = "custom" +model = "provider-model-id" +disable_response_storage = true + +[model_providers.custom] +name = "provider-id" +base_url = "https://provider.example/v1" +wire_api = "responses" +requires_openai_auth = false +experimental_bearer_token = "provider-api-key" +``` + +Keep `auth.json` empty for third-party providers: + +```json +{} +``` + +Reason: avoid overwriting the user's official Codex / ChatGPT login cache. + +## Stable Provider Id + +Use `model_provider = "custom"` for third-party providers. + +Codex history and resume behavior can depend on provider identity. Keeping a stable provider id avoids making history appear to move between provider-specific ids. + +Provider identity remains visible in: + +- `[model_providers.custom].name` +- the generated directory path +- the UI launch result + +## Local Proxy Plan + +Some providers expose only OpenAI Chat Completions: + +```text +/v1/chat/completions +``` + +Codex prefers Responses: + +```text +/v1/responses +``` + +Add a local proxy endpoint: + +```text +/api/codex-proxy/{routeKey}/v1/responses +``` + +The `routeKey` should encode: + +```text +profile + "\0" + provider + "\0" + model +``` + +Authentication should use a generated `hwui_...` token, not the upstream provider key. + +## Responses To Chat Mapping + +When the upstream provider is Chat Completions only: + +- Convert Responses `input` items to Chat `messages`. +- Convert Responses `tools` to Chat `tools`. +- Convert `max_output_tokens` to `max_tokens`. +- Preserve `stream: true`. +- Map function calls and function outputs both ways. + +Response event mapping for streaming: + +```text +chat delta.content -> response.output_text.delta +chat delta.tool_calls -> response.function_call_arguments.delta +finish -> response.completed +``` + +Non-streaming mapping: + +```text +chat.choices[0].message.content -> output message/content +chat.choices[0].message.tool_calls -> output function_call items +chat.usage -> response.usage +``` + +## Config Generation With Proxy + +For Chat-only providers, generated Codex config should point at Hermes: + +```toml +model_provider = "custom" +model = "provider-model-id" +disable_response_storage = true + +[model_providers.custom] +name = "provider-id" +base_url = "http://127.0.0.1:{serverPort}/api/codex-proxy/{routeKey}/v1" +wire_api = "responses" +requires_openai_auth = false +experimental_bearer_token = "hwui_generated_route_token" +``` + +The proxy then forwards to the real provider with the real provider key. + +## Implementation Tasks + +1. Add a Codex proxy service parallel to the Claude Code proxy service. +2. Register route targets in memory for launch-time provider/model selection. +3. Add `/api/codex-proxy/:key/v1/responses`. +4. Implement Responses to Chat conversion. +5. Implement Chat to Responses conversion for streaming and non-streaming. +6. Add launch-time api mode selection for Codex providers. +7. Generate Codex `base_url` against the local proxy when api mode is Chat Completions. +8. Add server tests for config generation, auth rejection, streaming conversion, tool call conversion, and error passthrough. + +## Open Questions + +- Whether to expose Codex protocol selection in the UI immediately or infer it from provider preset metadata. +- Whether to persist proxy targets or require relaunch after server restart. +- Whether model catalog generation is needed for the first Codex MVP. +- Whether MCP and `AGENTS.md` should be copied from a template or edited only through the advanced config editor. diff --git a/packages/client/public/coding-agents/claude-code.svg b/packages/client/public/coding-agents/claude-code.svg new file mode 100644 index 0000000..b4a2131 --- /dev/null +++ b/packages/client/public/coding-agents/claude-code.svg @@ -0,0 +1,7 @@ + + Claude Code + + \ No newline at end of file diff --git a/packages/client/public/coding-agents/codex-openai.png b/packages/client/public/coding-agents/codex-openai.png new file mode 100644 index 0000000..44fc39c Binary files /dev/null and b/packages/client/public/coding-agents/codex-openai.png differ diff --git a/packages/client/src/api/coding-agents.ts b/packages/client/src/api/coding-agents.ts new file mode 100644 index 0000000..acb41a5 --- /dev/null +++ b/packages/client/src/api/coding-agents.ts @@ -0,0 +1,133 @@ +import { request } from './client' + +export type CodingAgentId = 'claude-code' | 'codex' +export type CodingAgentApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' +export type CodingAgentLaunchMode = 'scoped' | 'global' + +export interface CodingAgentToolStatus { + id: CodingAgentId + name: string + provider: string + command: string + packageName: string + installed: boolean + version: string + rawVersion: string + error?: string +} + +export interface CodingAgentsStatus { + tools: CodingAgentToolStatus[] +} + +export interface CodingAgentMutationResult extends CodingAgentsStatus { + success: boolean + tool: CodingAgentToolStatus + message?: string +} + +export interface CodingAgentConfigFileContent { + key: string + path: string + absolutePath: string + language: string + content: string + exists: boolean + size: number + profile: string + provider: string + rootDir: string +} + +export interface CodingAgentConfigScope { + profile?: string | null + provider?: string | null +} + +export interface CodingAgentLaunchRequest { + mode?: CodingAgentLaunchMode + profile?: string | null + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: CodingAgentApiMode +} + +export interface CodingAgentLaunchResult { + agentId: CodingAgentId + mode: CodingAgentLaunchMode + profile: string + provider: string + model: string + rootDir: string + workspaceDir: string + command: string + args: string[] + env: Record + shellCommand: string + files: Array<{ key: string; path: string; absolutePath: string }> +} + +export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { + nativeTerminal: true + terminal: string +} + +export async function fetchCodingAgentsStatus(): Promise { + return request('/api/coding-agents') +} + +export async function installCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}/install`, { method: 'POST' }) +} + +export async function deleteCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}`, { method: 'DELETE' }) +} + +export async function readCodingAgentConfigFile( + id: CodingAgentId, + key: string, + scope: CodingAgentConfigScope = {}, +): Promise { + const params = new URLSearchParams() + if (scope.profile) params.set('profile', scope.profile) + if (scope.provider) params.set('provider', scope.provider) + const query = params.toString() + return request( + `/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`, + ) +} + +export async function writeCodingAgentConfigFile( + id: CodingAgentId, + key: string, + content: string, + scope: CodingAgentConfigScope = {}, +): Promise { + return request(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }), + }) +} + +export async function prepareCodingAgentLaunch( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/prepare`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function launchCodingAgentNativeTerminal( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/native`, { + method: 'POST', + body: JSON.stringify(data), + }) +} diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue index 9747c94..f2fdb80 100644 --- a/packages/client/src/components/hermes/chat/TerminalPanel.vue +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -12,7 +12,7 @@ import type { ITheme } from "@xterm/xterm"; const { t } = useI18n(); const message = useMessage(); -const props = defineProps<{ visible?: boolean }>(); +const props = defineProps<{ visible?: boolean; initialCommand?: string }>(); // ─── Terminal themes ──────────────────────────────────────────── @@ -106,6 +106,7 @@ const MAX_RECONNECT_ATTEMPTS = 3; let touchScrollLastY: number | null = null; let touchScrollRemainder = 0; const TOUCH_SCROLL_LINE_PX = 18; +const initialCommandSent = ref(false); // ─── Computed ────────────────────────────────────────────────── @@ -224,6 +225,7 @@ function handleControl(msg: any) { exited: false, }); switchSession(msg.id); + runInitialCommand(); break; case "exited": { @@ -251,6 +253,15 @@ function createSession() { send({ type: "create" }); } +function runInitialCommand() { + const command = props.initialCommand?.trim(); + if (!command || initialCommandSent.value) return; + initialCommandSent.value = true; + setTimeout(() => { + send(`${command}\r`); + }, 100); +} + function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } { let entry = termMap.get(id); if (!entry) { @@ -570,8 +581,11 @@ onUnmounted(() => { .terminal-panel-drawer { display: flex; height: 100%; + width: 100%; min-height: 0; + min-width: 0; position: relative; + overflow: hidden; } .sidebar-overlay { @@ -764,9 +778,11 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: space-between; + gap: 10px; padding: 12px 16px; border-bottom: 1px solid $border-color; flex-shrink: 0; + min-width: 0; } .header-session-title { @@ -783,6 +799,7 @@ onUnmounted(() => { align-items: center; gap: 8px; flex-shrink: 0; + min-width: 0; } .theme-select { @@ -800,12 +817,15 @@ onUnmounted(() => { margin: 8px; overflow: hidden; min-height: 0; + min-width: 0; display: flex; flex-direction: column; } .terminal-xterm { flex: 1; + min-height: 0; + min-width: 0; border-radius: $radius-md; overflow: hidden; border: 1px solid $border-color; @@ -842,19 +862,46 @@ onUnmounted(() => { @media (max-width: $breakpoint-mobile) { .terminal-panel-drawer { - height: calc(100 * var(--vh)); - max-height: calc(100 * var(--vh)); + height: 100%; + max-height: 100%; } .terminal-main { min-height: 0; + min-width: 0; + } + + .terminal-header { + padding: 8px; + gap: 6px; + } + + .header-session-title { + display: none; + } + + .header-actions { + width: 100%; + justify-content: flex-end; + gap: 6px; + } + + .theme-select { + width: 96px; } .terminal-container { - margin-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); + margin: 6px; + margin-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); } .terminal-xterm { + border-radius: $radius-sm; + + :deep(.xterm) { + padding: 6px; + } + :deep(.xterm-viewport), :deep(.xterm-scrollable-element) { touch-action: pan-y; diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 13e128a..27a8262 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -263,6 +263,14 @@ function openChangelog() { + + + + + + + {{ t("sidebar.codingAgents") }} + diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 754973e..0210749 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -138,6 +138,7 @@ export default { groupMonitoringShort: 'Mon', groupTools: 'Tools', groupToolsShort: "Tools", + codingAgents: "Coding Agents", versionPreview: "Version Preview", settings: 'Settings', connected: 'Connected', @@ -1065,6 +1066,82 @@ export default { stopSuccess: "Preview stopped", }, + codingAgents: { + title: "Coding Agents", + notice: "Unified setup checklist for coding CLIs that Hermes can delegate to through terminal or provider flows.", + claudeDescription: "Anthropic CLI for one-shot print mode and interactive coding sessions.", + codexDescription: "OpenAI CLI and Hermes openai-codex provider flow for repository tasks.", + copyCommand: "Copy", + commandCopied: "Command copied", + commandCopyFailed: "Copy failed", + refresh: "Refresh", + checking: "Checking", + installStatus: "Install status", + installed: "Installed", + notInstalled: "Not installed", + installNow: "Install", + installing: "Installing", + installSuccess: "Installed", + installFailed: "Install failed", + deleteNow: "Delete", + deleting: "Deleting", + deleteSuccess: "Deleted", + deleteFailed: "Delete failed", + configFiles: "Config files", + profileScope: "Profile", + providerScope: "Provider", + providerPlaceholder: "e.g. custom:glm", + modelScope: "Model", + modelPlaceholder: "Select model", + launchModeScope: "Launch mode", + launchModeGlobal: "Global config", + launchModeScoped: "Provider and model", + protocolScope: "Protocol", + protocolOpenAiChat: "OpenAI Chat Completions (/v1/chat/completions)", + protocolOpenAiResponses: "OpenAI Responses (/v1/responses)", + protocolAnthropicMessages: "Anthropic Messages (/v1/messages)", + reloadConfig: "Reload config", + configFileNotCreated: "Not created", + configLoadFailed: "Failed to read config file", + loadFailed: "Failed to inspect coding agents", + launch: "Launch", + launchTitle: "Launch Coding Agent", + nativeTerminal: "Native Terminal", + builtInTerminal: "Built-in Terminal", + launchPrepared: "Launch config prepared", + launchPrepareFailed: "Failed to prepare launch config", + nativeLaunchStarted: "Native terminal opened", + nativeLaunchFailed: "Failed to open native terminal", + terminalTitle: "Coding Agent Terminal", + loadProvidersFailed: "Failed to load providers for the current profile", + selectProviderModel: "Select a provider and model", + launchConfigDir: "Launch config directory", + launchCommand: "Launch command", + table: { + tool: "Tool", + kind: "Step", + command: "Command", + note: "Note", + action: "Action", + }, + kinds: { + install: "Install", + auth: "Auth", + health: "Health", + run: "Run", + }, + notes: { + claudeInstall: "Installs the Claude Code CLI globally.", + codexInstall: "Installs the Codex CLI globally.", + claudeAuth: "Checks Claude Code login state; run claude once if login is missing.", + codexAuth: "Adds Hermes-managed OpenAI Codex OAuth credentials.", + claudeHealth: "Checks updater and local CLI health.", + codexHealth: "Confirms the Codex CLI is available on PATH.", + claudeRun: "Print mode is the cleanest path for API-driven one-shot tasks.", + codexRun: "Codex one-shot tasks must run inside a git repository.", + }, + }, + // Platform channel settings platform: { requireMention: "Require {'@'}Mention", diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 4235ce9..d006d39 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -138,6 +138,7 @@ export default { groupMonitoringShort: '监控', groupTools: '工具', groupToolsShort: "工具", + codingAgents: "编程工具", versionPreview: "版本预览", settings: '设置', connected: '已连接', @@ -1057,6 +1058,82 @@ export default { stopSuccess: "预览已停止", }, + codingAgents: { + title: "编程工具", + notice: "统一展示 Hermes 可通过终端或 provider 流程委托的编程 CLI 配置清单。", + claudeDescription: "Anthropic CLI,适合 print mode 单次任务和交互式编程会话。", + codexDescription: "OpenAI CLI,以及 Hermes openai-codex provider 的仓库任务流程。", + copyCommand: "复制", + commandCopied: "命令已复制", + commandCopyFailed: "复制失败", + refresh: "刷新", + checking: "检测中", + installStatus: "安装情况", + installed: "已安装", + notInstalled: "未安装", + installNow: "一键安装", + installing: "安装中", + installSuccess: "安装完成", + installFailed: "安装失败", + deleteNow: "删除", + deleting: "删除中", + deleteSuccess: "删除完成", + deleteFailed: "删除失败", + configFiles: "配置文件", + profileScope: "Profile", + providerScope: "Provider", + providerPlaceholder: "例如 custom:glm", + modelScope: "模型", + modelPlaceholder: "选择模型", + launchModeScope: "启动方式", + launchModeGlobal: "全局默认配置", + launchModeScoped: "选择 Provider 和模型", + protocolScope: "协议", + protocolOpenAiChat: "OpenAI Chat Completions (/v1/chat/completions)", + protocolOpenAiResponses: "OpenAI Responses (/v1/responses)", + protocolAnthropicMessages: "Anthropic Messages (/v1/messages)", + reloadConfig: "重新读取配置", + configFileNotCreated: "未创建", + configLoadFailed: "读取配置文件失败", + loadFailed: "检测编程工具失败", + launch: "启动", + launchTitle: "启动编程工具", + nativeTerminal: "原生终端", + builtInTerminal: "内置终端", + launchPrepared: "启动配置已生成", + launchPrepareFailed: "生成启动配置失败", + nativeLaunchStarted: "已打开原生终端", + nativeLaunchFailed: "打开原生终端失败", + terminalTitle: "编程工具终端", + loadProvidersFailed: "读取当前 Profile 的 Provider 失败", + selectProviderModel: "请选择 Provider 和模型", + launchConfigDir: "启动配置目录", + launchCommand: "启动命令", + table: { + tool: "工具", + kind: "步骤", + command: "命令", + note: "说明", + action: "操作", + }, + kinds: { + install: "安装", + auth: "认证", + health: "检查", + run: "运行", + }, + notes: { + claudeInstall: "全局安装 Claude Code CLI。", + codexInstall: "全局安装 Codex CLI。", + claudeAuth: "检查 Claude Code 登录状态;未登录时先运行 claude。", + codexAuth: "添加 Hermes 管理的 OpenAI Codex OAuth 凭证。", + claudeHealth: "检查自动更新器和本地 CLI 健康状态。", + codexHealth: "确认 Codex CLI 已在 PATH 中可用。", + claudeRun: "Print mode 最适合 API 驱动的单次任务。", + codexRun: "Codex 单次任务需要在 git 仓库中运行。", + }, + }, + // 平台频道设置 platform: { requireMention: "需要 {'@'}提及", diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 5353fbb..6a04dab 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -117,6 +117,11 @@ const router = createRouter({ name: 'hermes.files', component: () => import('@/views/hermes/FilesView.vue'), }, + { + path: '/hermes/coding-agents', + name: 'hermes.codingAgents', + component: () => import('@/views/hermes/CodingAgentsView.vue'), + }, { path: '/hermes/version-preview', name: 'hermes.versionPreview', diff --git a/packages/client/src/views/hermes/CodingAgentsView.vue b/packages/client/src/views/hermes/CodingAgentsView.vue new file mode 100644 index 0000000..12bc0b7 --- /dev/null +++ b/packages/client/src/views/hermes/CodingAgentsView.vue @@ -0,0 +1,953 @@ + + + + + + {{ t('codingAgents.title') }} + + {{ t('codingAgents.refresh') }} + + + + + + {{ loadError }} + + + + + + + + {{ block.tool }} + {{ block.provider }} + + + + + + {{ t('codingAgents.installStatus') }} + + + {{ t('codingAgents.checking') }} + + + {{ t('codingAgents.installed') }} + + {{ statusFor(block.id)?.version || statusFor(block.id)?.rawVersion }} + + + {{ t('codingAgents.notInstalled') }} + + + + {{ deleting[block.id] ? t('codingAgents.deleting') : t('codingAgents.deleteNow') }} + + + {{ installing[block.id] ? t('codingAgents.installing') : t('codingAgents.installNow') }} + + + + + {{ t('codingAgents.configFiles') }} + + + {{ file.path }} + + + + + + + + {{ selectedConfigFile(block.id)?.path }} + + + {{ t('codingAgents.configFileNotCreated') }} + + + + + + + + + {{ t('files.saveFile') }} + + + {{ t('codingAgents.launch') }} + + + + + + + + + + + + + {{ activeProfileName }} + + + + + {{ option.label }} + + + + + + + + + + + + + + + + + + {{ t('codingAgents.builtInTerminal') }} + + + {{ t('codingAgents.nativeTerminal') }} + + + + + + + + + + + + {{ t('codingAgents.terminalTitle') }} + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/server/src/controllers/coding-agents.ts b/packages/server/src/controllers/coding-agents.ts new file mode 100644 index 0000000..1ae5d64 --- /dev/null +++ b/packages/server/src/controllers/coding-agents.ts @@ -0,0 +1,119 @@ +import type { Context } from 'koa' +import { + deleteCodingAgent, + getCodingAgentsStatus, + installCodingAgent, + openCodingAgentNativeTerminal, + prepareCodingAgentLaunch, + readCodingAgentConfigFile, + writeCodingAgentConfigFile, + type CodingAgentConfigScope, +} from '../services/coding-agents' + +function configScope(ctx: Context): CodingAgentConfigScope { + const body = ctx.request.body as { profile?: unknown; provider?: unknown } | undefined + return { + profile: ctx.state.profile?.name || (typeof ctx.query.profile === 'string' ? ctx.query.profile : '') || (typeof body?.profile === 'string' ? body.profile : ''), + provider: (typeof ctx.query.provider === 'string' ? ctx.query.provider : '') || (typeof body?.provider === 'string' ? body.provider : ''), + } +} + +export async function status(ctx: Context) { + try { + ctx.body = await getCodingAgentsStatus() + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message || 'Failed to inspect coding agents' } + } +} + +export async function install(ctx: Context) { + try { + const result = await installCodingAgent(ctx.params.id) + ctx.body = result + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to install coding agent' } + } +} + +export async function remove(ctx: Context) { + try { + const result = await deleteCodingAgent(ctx.params.id) + ctx.body = result + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to delete coding agent' } + } +} + +export async function readConfigFile(ctx: Context) { + try { + ctx.body = await readCodingAgentConfigFile(ctx.params.id, ctx.params.key, configScope(ctx)) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to read coding agent config file' } + } +} + +export async function writeConfigFile(ctx: Context) { + try { + const { content } = ctx.request.body as { content?: string } + ctx.body = await writeCodingAgentConfigFile(ctx.params.id, ctx.params.key, content || '', configScope(ctx)) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to write coding agent config file' } + } +} + +export async function prepareLaunch(ctx: Context) { + try { + const body = ctx.request.body as { + mode?: any + profile?: string + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: any + } + ctx.body = await prepareCodingAgentLaunch(ctx.params.id, { + mode: body.mode, + profile: ctx.state.profile?.name || body.profile, + provider: body.provider, + model: body.model, + baseUrl: body.baseUrl, + apiKey: body.apiKey, + apiMode: body.apiMode, + }) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to prepare coding agent launch' } + } +} + +export async function nativeLaunch(ctx: Context) { + try { + const body = ctx.request.body as { + mode?: any + profile?: string + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: any + } + ctx.body = await openCodingAgentNativeTerminal(ctx.params.id, { + mode: body.mode, + profile: ctx.state.profile?.name || body.profile, + provider: body.provider, + model: body.model, + baseUrl: body.baseUrl, + apiKey: body.apiKey, + apiMode: body.apiMode, + }) + } catch (err: any) { + ctx.status = err.status || 500 + ctx.body = { error: err.message || 'Failed to launch native terminal' } + } +} diff --git a/packages/server/src/routes/claude-code-proxy.ts b/packages/server/src/routes/claude-code-proxy.ts new file mode 100644 index 0000000..f8a815e --- /dev/null +++ b/packages/server/src/routes/claude-code-proxy.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import { claudeProxyMessages, claudeProxyModels } from '../services/claude-code-proxy' + +export const claudeCodeProxyRoutes = new Router() + +claudeCodeProxyRoutes.get('/api/claude-code-proxy/:key/v1/models', claudeProxyModels) +claudeCodeProxyRoutes.post('/api/claude-code-proxy/:key/v1/messages', claudeProxyMessages) diff --git a/packages/server/src/routes/codex-proxy.ts b/packages/server/src/routes/codex-proxy.ts new file mode 100644 index 0000000..f425884 --- /dev/null +++ b/packages/server/src/routes/codex-proxy.ts @@ -0,0 +1,7 @@ +import Router from '@koa/router' +import { codexProxyModels, codexProxyResponses } from '../services/codex-proxy' + +export const codexProxyRoutes = new Router() + +codexProxyRoutes.get('/api/codex-proxy/:key/v1/models', codexProxyModels) +codexProxyRoutes.post('/api/codex-proxy/:key/v1/responses', codexProxyResponses) diff --git a/packages/server/src/routes/coding-agents.ts b/packages/server/src/routes/coding-agents.ts new file mode 100644 index 0000000..3c86826 --- /dev/null +++ b/packages/server/src/routes/coding-agents.ts @@ -0,0 +1,12 @@ +import Router from '@koa/router' +import * as ctrl from '../controllers/coding-agents' + +export const codingAgentRoutes = new Router() + +codingAgentRoutes.get('/api/coding-agents', ctrl.status) +codingAgentRoutes.post('/api/coding-agents/:id/install', ctrl.install) +codingAgentRoutes.post('/api/coding-agents/:id/launch/prepare', ctrl.prepareLaunch) +codingAgentRoutes.post('/api/coding-agents/:id/launch/native', ctrl.nativeLaunch) +codingAgentRoutes.delete('/api/coding-agents/:id', ctrl.remove) +codingAgentRoutes.get('/api/coding-agents/:id/config-files/:key', ctrl.readConfigFile) +codingAgentRoutes.put('/api/coding-agents/:id/config-files/:key', ctrl.writeConfigFile) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 8e0e563..664852a 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -6,6 +6,9 @@ import { webhookRoutes } from './webhook' import { uploadRoutes } from './upload' import { updateRoutes } from './update' import { authPublicRoutes, authProtectedRoutes } from './auth' +import { codingAgentRoutes } from './coding-agents' +import { claudeCodeProxyRoutes } from './claude-code-proxy' +import { codexProxyRoutes } from './codex-proxy' // Hermes route modules import { sessionRoutes } from './hermes/sessions' @@ -43,6 +46,8 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(healthRoutes.routes()) app.use(webhookRoutes.routes()) app.use(authPublicRoutes.routes()) + app.use(claudeCodeProxyRoutes.routes()) + app.use(codexProxyRoutes.routes()) // --- Auth middleware: all routes below require authentication --- authMiddleware.forEach((middleware) => app.use(middleware)) @@ -52,6 +57,7 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(ttsRoutes.routes()) app.use(uploadRoutes.routes()) app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) + app.use(codingAgentRoutes.routes()) app.use(sessionRoutes.routes()) app.use(profileRoutes.routes()) app.use(skillRoutes.routes()) diff --git a/packages/server/src/services/claude-code-proxy.ts b/packages/server/src/services/claude-code-proxy.ts new file mode 100644 index 0000000..cedc3bc --- /dev/null +++ b/packages/server/src/services/claude-code-proxy.ts @@ -0,0 +1,995 @@ +import { randomBytes } from 'crypto' +import { Readable } from 'stream' +import type { Context } from 'koa' +import { config } from '../config' + +export type ApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server' + +export interface ClaudeCodeProxyTargetInput { + provider: string + model: string + baseUrl: string + apiKey: string + apiMode?: ApiMode +} + +interface ClaudeCodeProxyTarget extends ClaudeCodeProxyTargetInput { + key: string + routeKey: string + token: string + updatedAt: number +} + +const targets = new Map() +const CLAUDE_PROXY_VISIBLE_MODELS = [ + 'claude-haiku-4-5', + 'claude-sonnet-4-6', + 'claude-opus-4-7', +] + +function targetKey(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return `${provider}\0${model}\0${apiMode}\0${baseUrl}` +} + +function routeKeyFor(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return Buffer.from(targetKey(provider, model, apiMode, baseUrl), 'utf-8').toString('base64url') +} + +function localProxyBaseUrl(routeKey: string): string { + return `http://127.0.0.1:${config.port}/api/claude-code-proxy/${routeKey}` +} + +export function registerClaudeCodeProxyTarget(input: ClaudeCodeProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { + const provider = input.provider.trim() + const model = input.model.trim() + const baseUrl = input.baseUrl.replace(/\/+$/, '') + const apiMode = input.apiMode || 'chat_completions' + const key = targetKey(provider, model, apiMode, baseUrl) + const existing = targets.get(key) + const routeKey = existing?.routeKey || routeKeyFor(provider, model, apiMode, baseUrl) + const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}` + + targets.set(key, { + ...input, + provider, + model, + baseUrl, + apiMode, + key, + routeKey, + token, + updatedAt: Date.now(), + }) + + return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey } +} + +function findTarget(routeKey: string): ClaudeCodeProxyTarget | null { + for (const target of targets.values()) { + if (target.routeKey === routeKey) return target + } + return null +} + +function authToken(ctx: Context): string { + const apiKey = ctx.get('x-api-key').trim() + if (apiKey) return apiKey + const auth = ctx.get('authorization').trim() + const match = auth.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || '' +} + +function requireTarget(ctx: Context): ClaudeCodeProxyTarget | null { + const target = findTarget(String(ctx.params.key || '')) + if (!target) { + ctx.status = 404 + ctx.body = { type: 'error', error: { type: 'not_found_error', message: 'Claude proxy target not found' } } + return null + } + if (authToken(ctx) !== target.token) { + ctx.status = 401 + ctx.body = { type: 'error', error: { type: 'authentication_error', message: 'Invalid Claude proxy token' } } + return null + } + return target +} + +function stringifyContent(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === 'string') return item + if (item && typeof item === 'object' && 'text' in item) return String((item as any).text || '') + return JSON.stringify(item) + }).filter(Boolean).join('\n') + } + if (value == null) return '' + return JSON.stringify(value) +} + +function shouldPreserveReasoningContent(target: ClaudeCodeProxyTarget): boolean { + const identifier = `${target.provider} ${target.model} ${target.baseUrl}`.toLowerCase() + return [ + 'deepseek', + 'moonshot', + 'kimi', + 'mimo', + 'xiaomimimo', + ].some(part => identifier.includes(part)) +} + +function anthropicContentToOpenAiMessages(message: any, preserveReasoningContent = false): any[] { + const content = message?.content + if (!Array.isArray(content)) { + return [{ role: message.role, content: stringifyContent(content) }] + } + + if (message.role === 'assistant') { + const textParts: string[] = [] + const reasoningParts: string[] = [] + const toolCalls: any[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking)) + if (block?.type === 'redacted_thinking' && preserveReasoningContent) reasoningParts.push('[redacted thinking]') + if (block?.type === 'tool_use') { + toolCalls.push({ + id: String(block.id || `tool_${toolCalls.length}`), + type: 'function', + function: { + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }, + }) + } + } + const openAiMessage: any = { + role: 'assistant', + content: textParts.join('\n') || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + } + if (preserveReasoningContent && (reasoningParts.length || toolCalls.length)) { + openAiMessage.reasoning_content = reasoningParts.join('\n') || 'tool call' + } + return [openAiMessage] + } + + const messages: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_result') { + if (textParts.length) { + messages.push({ role: 'user', content: textParts.splice(0).join('\n') }) + } + messages.push({ + role: 'tool', + tool_call_id: String(block.tool_use_id || ''), + content: stringifyContent(block.content), + }) + } + } + if (textParts.length) messages.push({ role: message.role || 'user', content: textParts.join('\n') }) + return messages.length ? messages : [{ role: message.role || 'user', content: '' }] +} + +function anthropicToOpenAiChat(body: any, target: ClaudeCodeProxyTarget, stream = false): any { + const messages: any[] = [] + const preserveReasoningContent = shouldPreserveReasoningContent(target) + const system = body?.system + if (system) messages.push({ role: 'system', content: stringifyContent(system) }) + for (const message of Array.isArray(body?.messages) ? body.messages : []) { + messages.push(...anthropicContentToOpenAiMessages(message, preserveReasoningContent)) + } + + const tools = Array.isArray(body?.tools) + ? body.tools.map((tool: any) => ({ + type: 'function', + function: { + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.input_schema || { type: 'object', properties: {} }, + }, + })).filter((tool: any) => tool.function.name) + : undefined + + return { + model: target.model, + messages, + ...(typeof body?.max_tokens === 'number' ? { max_tokens: body.max_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function anthropicToOpenAiResponsesInput(message: any): any[] { + const content = Array.isArray(message?.content) ? message.content : [{ type: 'text', text: stringifyContent(message?.content) }] + + if (message.role === 'assistant') { + const items: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_use') { + if (textParts.length) { + items.push({ role: 'assistant', content: textParts.splice(0).join('\n') }) + } + items.push({ + type: 'function_call', + call_id: String(block.id || `tool_${items.length}`), + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }) + } + } + if (textParts.length) items.push({ role: 'assistant', content: textParts.join('\n') }) + return items + } + + const items: any[] = [] + const textParts: string[] = [] + for (const block of content) { + if (block?.type === 'text') textParts.push(String(block.text || '')) + if (block?.type === 'tool_result') { + if (textParts.length) { + items.push({ role: 'user', content: textParts.splice(0).join('\n') }) + } + items.push({ + type: 'function_call_output', + call_id: String(block.tool_use_id || ''), + output: stringifyContent(block.content), + }) + } + } + if (textParts.length) items.push({ role: message.role || 'user', content: textParts.join('\n') }) + return items.length ? items : [{ role: message.role || 'user', content: '' }] +} + +function anthropicToOpenAiResponses(body: any, target: ClaudeCodeProxyTarget, stream = false): any { + const input: any[] = [] + for (const message of Array.isArray(body?.messages) ? body.messages : []) { + input.push(...anthropicToOpenAiResponsesInput(message)) + } + + const tools = Array.isArray(body?.tools) + ? body.tools.map((tool: any) => ({ + type: 'function', + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.input_schema || { type: 'object', properties: {} }, + })).filter((tool: any) => tool.name) + : undefined + + return { + model: target.model, + input, + ...(body?.system ? { instructions: stringifyContent(body.system) } : {}), + ...(typeof body?.max_tokens === 'number' ? { max_output_tokens: body.max_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(tools?.length ? { tools } : {}), + stream, + store: false, + } +} + +function safeJsonParse(value: string): any { + try { + return JSON.parse(value) + } catch { + return {} + } +} + +function mapStopReason(reason: string | null | undefined, hasTools: boolean): string { + if (hasTools) return 'tool_use' + if (reason === 'length') return 'max_tokens' + if (reason === 'content_filter') return 'stop_sequence' + return 'end_turn' +} + +function openAiToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any { + const choice = data?.choices?.[0] || {} + const message = choice.message || {} + const content: any[] = [] + if (shouldPreserveReasoningContent(target) && message.reasoning_content) { + content.push({ type: 'thinking', thinking: String(message.reasoning_content) }) + } + if (message.content) content.push({ type: 'text', text: String(message.content) }) + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + content.push({ + type: 'tool_use', + id: String(call.id || `toolu_${content.length}`), + name: String(call.function?.name || 'tool'), + input: safeJsonParse(String(call.function?.arguments || '{}')), + }) + } + + const hasTools = content.some(block => block.type === 'tool_use') + return { + id: String(data?.id || `msg_${Date.now()}`), + type: 'message', + role: 'assistant', + model: target.model, + content, + stop_reason: mapStopReason(choice.finish_reason, hasTools), + stop_sequence: null, + usage: { + input_tokens: Number(data?.usage?.prompt_tokens || 0), + output_tokens: Number(data?.usage?.completion_tokens || 0), + }, + } +} + +function sseEvent(event: string, data: any): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +} + +function anthropicMessageToSse(message: any): string { + let output = '' + output += sseEvent('message_start', { + type: 'message_start', + message: { ...message, content: [], stop_reason: null, usage: { input_tokens: message.usage.input_tokens, output_tokens: 0 } }, + }) + + message.content.forEach((block: any, index: number) => { + if (block.type === 'text') { + output += sseEvent('content_block_start', { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }) + if (block.text) output += sseEvent('content_block_delta', { type: 'content_block_delta', index, delta: { type: 'text_delta', text: block.text } }) + output += sseEvent('content_block_stop', { type: 'content_block_stop', index }) + } else if (block.type === 'tool_use') { + output += sseEvent('content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + output += sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input || {}) }, + }) + output += sseEvent('content_block_stop', { type: 'content_block_stop', index }) + } + }) + + output += sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: message.stop_reason, stop_sequence: null }, + usage: { output_tokens: message.usage.output_tokens }, + }) + output += sseEvent('message_stop', { type: 'message_stop' }) + return output +} + +function anthropicMessagesUrl(target: ClaudeCodeProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages` + return `${target.baseUrl}/v1/messages` +} + +async function readProviderJson(res: Response): Promise { + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { error: { message: text || `Provider returned HTTP ${res.status}` } } + } +} + +function throwProviderError(res: Response, data: any): never { + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err +} + +function anthropicRequestBody(body: any, target: ClaudeCodeProxyTarget): any { + return { + ...body, + model: target.model, + } +} + +async function callAnthropicMessages(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicRequestBody(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callOpenAiChat(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(`${target.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiChat(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callOpenAiResponses(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'codex_responses') { + const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(`${target.baseUrl}/responses`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiResponses(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +function responseOutputText(item: any): string { + if (item?.type === 'output_text') return String(item.text || '') + if (item?.type === 'message' && Array.isArray(item.content)) { + return item.content + .map((part: any) => { + if (part?.type === 'output_text' || part?.type === 'text') return String(part.text || '') + return '' + }) + .filter(Boolean) + .join('') + } + return '' +} + +function openAiResponsesToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any { + const content: any[] = [] + const output = Array.isArray(data?.output) ? data.output : [] + + for (const item of output) { + const text = responseOutputText(item) + if (text) content.push({ type: 'text', text }) + if (item?.type === 'function_call') { + content.push({ + type: 'tool_use', + id: String(item.call_id || item.id || `toolu_${content.length}`), + name: String(item.name || 'tool'), + input: safeJsonParse(String(item.arguments || '{}')), + }) + } + } + + if (!content.length && data?.output_text) { + content.push({ type: 'text', text: String(data.output_text) }) + } + + const hasTools = content.some(block => block.type === 'tool_use') + return { + id: String(data?.id || `msg_${Date.now()}`), + type: 'message', + role: 'assistant', + model: target.model, + content, + stop_reason: hasTools ? 'tool_use' : (data?.status === 'incomplete' ? 'max_tokens' : 'end_turn'), + stop_sequence: null, + usage: { + input_tokens: Number(data?.usage?.input_tokens || 0), + output_tokens: Number(data?.usage?.output_tokens || 0), + }, + } +} + +function getReadableStream(res: Response): AsyncIterable { + const body = res.body + if (!body) throw new Error('Provider returned an empty stream') + return body as any +} + +function parseOpenAiSse(buffer: string): { events: string[]; rest: string } { + const events: string[] = [] + let cursor = 0 + while (true) { + const index = buffer.indexOf('\n\n', cursor) + if (index < 0) break + events.push(buffer.slice(cursor, index)) + cursor = index + 2 + } + return { events, rest: buffer.slice(cursor) } +} + +function extractSseData(event: string): string[] { + return event + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) +} + +function openAiFinishToAnthropic(finishReason: string | null | undefined, sawTool: boolean): string { + return mapStopReason(finishReason, sawTool) +} + +async function openAiChatToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(`${target.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiChat(body, target, true)), + }) + if (!res.ok) { + let data: any + const text = await res.text() + try { + data = JSON.parse(text) + } catch { + data = { error: { message: text || `Provider returned HTTP ${res.status}` } } + } + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + const messageId = `msg_${Date.now()}` + let buffer = '' + let thinkingBlockIndex: number | null = null + let thinkingBlockStopped = false + let textBlockStarted = false + let textBlockStopped = false + let textBlockIndex: number | null = null + let nextIndex = 0 + let stopReason: string | null = null + let outputTokens = 0 + const toolBlocks = new Map() + + yield sseEvent('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: target.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + + const ensureThinkingBlock = function* () { + if (thinkingBlockIndex == null) { + thinkingBlockIndex = nextIndex++ + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: thinkingBlockIndex, + content_block: { type: 'thinking', thinking: '' }, + }) + } + return thinkingBlockIndex + } + + const stopThinkingBlock = function* () { + if (thinkingBlockIndex != null && !thinkingBlockStopped) { + thinkingBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingBlockIndex }) + } + } + + const ensureTextBlock = function* () { + if (!textBlockStarted) { + textBlockStarted = true + textBlockIndex = nextIndex + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: textBlockIndex, + content_block: { type: 'text', text: '' }, + }) + nextIndex += 1 + } + return textBlockIndex ?? 0 + } + + const ensureToolBlock = function* (toolIndex: number, id?: string, name?: string) { + let block = toolBlocks.get(toolIndex) + if (!block) { + block = { + blockIndex: nextIndex++, + id: id || `toolu_${toolIndex}`, + name: name || 'tool', + started: false, + } + toolBlocks.set(toolIndex, block) + } else { + if (id) block.id = id + if (name) block.name = name + } + if (!block.started && block.name) { + block.started = true + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: block.blockIndex, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const choice = data?.choices?.[0] + if (!choice) continue + + const delta = choice.delta || {} + if (shouldPreserveReasoningContent(target) && typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + const index = yield* ensureThinkingBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: delta.reasoning_content }, + }) + } + + if (typeof delta.content === 'string' && delta.content) { + yield* stopThinkingBlock() + const index = yield* ensureTextBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: delta.content }, + }) + } + + for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { + yield* stopThinkingBlock() + if (textBlockStarted && !textBlockStopped) { + textBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 }) + } + const toolIndex = Number(toolCall.index || 0) + const block = yield* ensureToolBlock( + toolIndex, + toolCall.id ? String(toolCall.id) : undefined, + toolCall.function?.name ? String(toolCall.function.name) : undefined, + ) + const argsDelta = toolCall.function?.arguments + if (typeof argsDelta === 'string' && argsDelta) { + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: argsDelta }, + }) + } + } + + if (choice.finish_reason) stopReason = String(choice.finish_reason) + if (data?.usage?.completion_tokens) outputTokens = Number(data.usage.completion_tokens) + } + } + } + + yield* stopThinkingBlock() + if (textBlockStarted && !textBlockStopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 }) + } + for (const block of toolBlocks.values()) { + if (block.started) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + yield sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }) + yield sseEvent('message_stop', { type: 'message_stop' }) + } + + return Readable.from(generate()) +} + +async function anthropicMessagesSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicRequestBody(body, target)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + return Readable.from(getReadableStream(res)) +} + +async function openAiResponsesToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { + if (target.apiMode !== 'codex_responses') { + const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(`${target.baseUrl}/responses`, { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(anthropicToOpenAiResponses(body, target, true)), + }) + if (!res.ok) { + let data: any + const text = await res.text() + try { + data = JSON.parse(text) + } catch { + data = { error: { message: text || `Provider returned HTTP ${res.status}` } } + } + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + let messageId = `msg_${Date.now()}` + let buffer = '' + let textBlockIndex: number | null = null + let textBlockStopped = false + let nextIndex = 0 + let stopReason: string | null = null + let outputTokens = 0 + const toolBlocks = new Map() + + yield sseEvent('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: target.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }) + + const ensureTextBlock = function* () { + if (textBlockIndex == null) { + textBlockIndex = nextIndex++ + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: textBlockIndex, + content_block: { type: 'text', text: '' }, + }) + } + return textBlockIndex + } + + const ensureToolBlock = function* (key: string, id?: string, name?: string) { + let block = toolBlocks.get(key) + if (!block) { + block = { + blockIndex: nextIndex++, + id: id || key || `toolu_${toolBlocks.size}`, + name: name || 'tool', + argsDeltaSeen: false, + stopped: false, + } + toolBlocks.set(key, block) + yield sseEvent('content_block_start', { + type: 'content_block_start', + index: block.blockIndex, + content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, + }) + } else { + if (id) block.id = id + if (name && block.name === 'tool') block.name = name + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const eventType = data?.type + + if (eventType === 'response.created') { + messageId = String(data?.response?.id || messageId) + } + + if (eventType === 'response.output_text.delta') { + const deltaText = String(data?.delta || data?.text || '') + if (deltaText) { + const index = yield* ensureTextBlock() + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: deltaText }, + }) + } + } + + if (eventType === 'response.output_text.done' && textBlockIndex != null && !textBlockStopped) { + textBlockStopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex }) + } + + if (eventType === 'response.output_item.added') { + const item = data?.item || data?.output_item + if (item?.type === 'function_call') { + const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) + yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) + } + } + + if (eventType === 'response.function_call_arguments.delta') { + const key = String(data.call_id || data.item_id || data.output_index || toolBlocks.size) + const block = yield* ensureToolBlock(key) + const argsDelta = String(data.delta || '') + if (argsDelta) { + block.argsDeltaSeen = true + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: argsDelta }, + }) + } + } + + if (eventType === 'response.output_item.done') { + const item = data?.item || data?.output_item + if (item?.type === 'function_call') { + const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) + const block = yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) + const args = String(item.arguments || '') + if (args && !block.argsDeltaSeen) { + yield sseEvent('content_block_delta', { + type: 'content_block_delta', + index: block.blockIndex, + delta: { type: 'input_json_delta', partial_json: args }, + }) + } + if (!block.stopped) { + block.stopped = true + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + } + + if (eventType === 'response.completed') { + const response = data?.response || data + outputTokens = Number(response?.usage?.output_tokens || outputTokens) + stopReason = response?.status === 'incomplete' ? 'length' : 'stop' + } + } + } + } + + if (textBlockIndex != null && !textBlockStopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex }) + } + for (const block of toolBlocks.values()) { + if (!block.stopped) { + yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex }) + } + } + yield sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }) + yield sseEvent('message_stop', { type: 'message_stop' }) + } + + return Readable.from(generate()) +} + +export async function claudeProxyModels(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + const ids = [...new Set([...CLAUDE_PROXY_VISIBLE_MODELS, target.model])] + ctx.body = { + data: ids.map(id => ({ + type: 'model', + id, + display_name: id, + created_at: '2026-01-01T00:00:00Z', + })), + has_more: false, + first_id: ids[0], + last_id: ids[ids.length - 1], + } +} + +export async function claudeProxyMessages(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + try { + const requestBody = ctx.request.body || {} + if ((requestBody as any).stream === true) { + const stream = target.apiMode === 'anthropic_messages' + ? await anthropicMessagesSseStream(target, requestBody) + : target.apiMode === 'codex_responses' + ? await openAiResponsesToAnthropicSseStream(target, requestBody) + : await openAiChatToAnthropicSseStream(target, requestBody) + ctx.set('Content-Type', 'text/event-stream; charset=utf-8') + ctx.set('Cache-Control', 'no-cache') + ctx.body = stream + } else { + const message = target.apiMode === 'anthropic_messages' + ? await callAnthropicMessages(target, requestBody) + : target.apiMode === 'codex_responses' + ? openAiResponsesToAnthropicMessage(await callOpenAiResponses(target, requestBody), target) + : openAiToAnthropicMessage(await callOpenAiChat(target, requestBody), target) + ctx.body = message + } + } catch (err: any) { + ctx.status = err.status || 502 + ctx.body = { + type: 'error', + error: { + type: 'api_error', + message: err?.message || 'Claude proxy request failed', + provider_error: err?.providerError, + }, + } + } +} diff --git a/packages/server/src/services/codex-proxy.ts b/packages/server/src/services/codex-proxy.ts new file mode 100644 index 0000000..bb5cb9e --- /dev/null +++ b/packages/server/src/services/codex-proxy.ts @@ -0,0 +1,908 @@ +import { randomBytes } from 'crypto' +import { Readable } from 'stream' +import type { Context } from 'koa' +import { config } from '../config' +import type { ApiMode } from './claude-code-proxy' + +export interface CodexProxyTargetInput { + profile: string + provider: string + model: string + baseUrl: string + apiKey: string + apiMode?: ApiMode +} + +interface CodexProxyTarget extends CodexProxyTargetInput { + key: string + routeKey: string + token: string + updatedAt: number +} + +const targets = new Map() + +function targetKey(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return `${profile}\0${provider}\0${model}\0${apiMode}\0${baseUrl}` +} + +function routeKeyFor(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string { + return Buffer.from(targetKey(profile, provider, model, apiMode, baseUrl), 'utf-8').toString('base64url') +} + +function localProxyBaseUrl(routeKey: string): string { + return `http://127.0.0.1:${config.port}/api/codex-proxy/${routeKey}/v1` +} + +export function registerCodexProxyTarget(input: CodexProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { + const profile = input.profile.trim() + const provider = input.provider.trim() + const model = input.model.trim() + const baseUrl = input.baseUrl.replace(/\/+$/, '') + const apiMode = input.apiMode || 'chat_completions' + const key = targetKey(profile, provider, model, apiMode, baseUrl) + const existing = targets.get(key) + const routeKey = existing?.routeKey || routeKeyFor(profile, provider, model, apiMode, baseUrl) + const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}` + + targets.set(key, { + ...input, + profile, + provider, + model, + baseUrl, + apiMode, + key, + routeKey, + token, + updatedAt: Date.now(), + }) + + return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey } +} + +function findTarget(routeKey: string): CodexProxyTarget | null { + for (const target of targets.values()) { + if (target.routeKey === routeKey) return target + } + return null +} + +function authToken(ctx: Context): string { + const apiKey = ctx.get('x-api-key').trim() + if (apiKey) return apiKey + const auth = ctx.get('authorization').trim() + const match = auth.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() || '' +} + +function requireTarget(ctx: Context): CodexProxyTarget | null { + const target = findTarget(String(ctx.params.key || '')) + if (!target) { + ctx.status = 404 + ctx.body = { error: { type: 'not_found_error', message: 'Codex proxy target not found' } } + return null + } + if (authToken(ctx) !== target.token) { + ctx.status = 401 + ctx.body = { error: { type: 'authentication_error', message: 'Invalid Codex proxy token' } } + return null + } + return target +} + +function stringifyContent(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === 'string') return item + if (item && typeof item === 'object') { + const block = item as any + if (typeof block.text === 'string') return block.text + if (typeof block.output === 'string') return block.output + } + return JSON.stringify(item) + }).filter(Boolean).join('\n') + } + if (value == null) return '' + return JSON.stringify(value) +} + +function responseContentToText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return stringifyContent(content) + return content.map((part: any) => { + if (typeof part === 'string') return part + if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { + return String(part.text || '') + } + return stringifyContent(part) + }).filter(Boolean).join('\n') +} + +function responsesInputToChatMessages(body: any): any[] { + const messages: any[] = [] + if (body?.instructions) { + messages.push({ role: 'system', content: stringifyContent(body.instructions) }) + } + + const input = body?.input + if (typeof input === 'string') { + messages.push({ role: 'user', content: input }) + return messages + } + + for (const item of Array.isArray(input) ? input : []) { + if (!item || typeof item !== 'object') continue + if (item.type === 'function_call') { + const callId = String(item.call_id || item.id || `call_${messages.length}`) + messages.push({ + role: 'assistant', + content: null, + tool_calls: [{ + id: callId, + type: 'function', + function: { + name: String(item.name || 'tool'), + arguments: String(item.arguments || '{}'), + }, + }], + }) + continue + } + if (item.type === 'function_call_output') { + messages.push({ + role: 'tool', + tool_call_id: String(item.call_id || ''), + content: stringifyContent(item.output), + }) + continue + } + if (item.role) { + messages.push({ + role: chatRoleForResponsesRole(item.role), + content: responseContentToText(item.content), + }) + } + } + + return messages.length ? messages : [{ role: 'user', content: '' }] +} + +function chatRoleForResponsesRole(role: unknown): string { + const value = String(role || '').trim() + if (value === 'developer') return 'system' + if (value === 'system' || value === 'user' || value === 'assistant' || value === 'tool') return value + return 'user' +} + +function responsesToolsToChatTools(tools: unknown): any[] | undefined { + if (!Array.isArray(tools)) return undefined + const mapped = tools.map((tool: any) => { + if (tool?.type !== 'function') return null + return { + type: 'function', + function: { + name: String(tool.name || ''), + description: String(tool.description || ''), + parameters: tool.parameters || { type: 'object', properties: {} }, + }, + } + }).filter((tool: any) => tool?.function?.name) + return mapped.length ? mapped : undefined +} + +function responsesToOpenAiChat(body: any, target: CodexProxyTarget, stream = false): any { + const tools = responsesToolsToChatTools(body?.tools) + return { + model: target.model, + messages: responsesInputToChatMessages(body), + ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : {}), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function responsesRoleToAnthropicRole(role: unknown): 'user' | 'assistant' { + return String(role || '') === 'assistant' ? 'assistant' : 'user' +} + +function responsesContentToAnthropicContent(content: unknown, role: 'user' | 'assistant'): any[] { + const parts = Array.isArray(content) ? content : [{ type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyContent(content) }] + const mapped = parts.map((part: any) => { + if (typeof part === 'string') return { type: 'text', text: part } + if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { + return { type: 'text', text: String(part.text || '') } + } + return null + }).filter(Boolean) + return mapped.length ? mapped : [{ type: 'text', text: '' }] +} + +function responsesInputToAnthropicMessages(body: any): any[] { + const messages: any[] = [] + const input = body?.input + if (typeof input === 'string') return [{ role: 'user', content: [{ type: 'text', text: input }] }] + + for (const item of Array.isArray(input) ? input : []) { + if (!item || typeof item !== 'object') continue + if (item.type === 'function_call') { + messages.push({ + role: 'assistant', + content: [{ + type: 'tool_use', + id: String(item.call_id || item.id || `toolu_${messages.length}`), + name: String(item.name || 'tool'), + input: safeJsonParse(String(item.arguments || '{}')), + }], + }) + continue + } + if (item.type === 'function_call_output') { + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: String(item.call_id || ''), + content: stringifyContent(item.output), + }], + }) + continue + } + if (item.role) { + const role = responsesRoleToAnthropicRole(item.role) + messages.push({ + role, + content: responsesContentToAnthropicContent(item.content, role), + }) + } + } + + return messages.length ? messages : [{ role: 'user', content: [{ type: 'text', text: '' }] }] +} + +function responsesToolsToAnthropicTools(tools: unknown): any[] | undefined { + if (!Array.isArray(tools)) return undefined + const mapped = tools.map((tool: any) => { + if (tool?.type !== 'function') return null + return { + name: String(tool.name || ''), + description: String(tool.description || ''), + input_schema: tool.parameters || { type: 'object', properties: {} }, + } + }).filter((tool: any) => tool?.name) + return mapped.length ? mapped : undefined +} + +function responsesToAnthropicMessages(body: any, target: CodexProxyTarget, stream = false): any { + const tools = responsesToolsToAnthropicTools(body?.tools) + return { + model: target.model, + messages: responsesInputToAnthropicMessages(body), + ...(body?.instructions ? { system: stringifyContent(body.instructions) } : {}), + ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : { max_tokens: 4096 }), + ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), + ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), + ...(tools?.length ? { tools } : {}), + stream, + } +} + +function chatCompletionsUrl(target: CodexProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/chat/completions` + return `${target.baseUrl}/v1/chat/completions` +} + +function anthropicMessagesUrl(target: CodexProxyTarget): string { + if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages` + return `${target.baseUrl}/v1/messages` +} + +async function readProviderJson(res: Response): Promise { + const text = await res.text() + try { + return JSON.parse(text) + } catch { + return { error: { message: text || `Provider returned HTTP ${res.status}` } } + } +} + +function throwProviderError(res: Response, data: any): never { + const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`) + ;(err as any).status = res.status + ;(err as any).providerError = data + throw err +} + +function responseId(data: any): string { + return String(data?.id || `resp_${Date.now()}`) +} + +function usageFromChat(data: any) { + return { + input_tokens: Number(data?.usage?.prompt_tokens || 0), + output_tokens: Number(data?.usage?.completion_tokens || 0), + total_tokens: Number(data?.usage?.total_tokens || 0), + } +} + +function usageFromAnthropic(data: any) { + const inputTokens = Number(data?.usage?.input_tokens || 0) + const outputTokens = Number(data?.usage?.output_tokens || 0) + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + } +} + +function openAiChatToResponses(data: any, target: CodexProxyTarget): any { + const choice = data?.choices?.[0] || {} + const message = choice.message || {} + const output: any[] = [] + + if (message.content) { + output.push({ + type: 'message', + id: `msg_${responseId(data)}`, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: String(message.content), annotations: [] }], + }) + } + + for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { + output.push({ + type: 'function_call', + id: String(call.id || `fc_${output.length}`), + call_id: String(call.id || `call_${output.length}`), + name: String(call.function?.name || 'tool'), + arguments: String(call.function?.arguments || '{}'), + }) + } + + return { + id: responseId(data), + object: 'response', + created_at: Number(data?.created || Math.floor(Date.now() / 1000)), + status: 'completed', + model: target.model, + output, + usage: usageFromChat(data), + } +} + +function anthropicMessageToResponses(data: any, target: CodexProxyTarget): any { + const output: any[] = [] + const textParts: string[] = [] + for (const block of Array.isArray(data?.content) ? data.content : []) { + if (block?.type === 'text' && block.text) textParts.push(String(block.text)) + if (block?.type === 'tool_use') { + output.push({ + type: 'function_call', + id: String(block.id || `fc_${output.length}`), + call_id: String(block.id || `call_${output.length}`), + name: String(block.name || 'tool'), + arguments: JSON.stringify(block.input || {}), + }) + } + } + if (textParts.length) { + output.unshift({ + type: 'message', + id: `msg_${responseId(data)}`, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: textParts.join('\n'), annotations: [] }], + }) + } + + return { + id: responseId(data), + object: 'response', + created_at: Math.floor(Date.now() / 1000), + status: 'completed', + model: target.model, + output, + usage: usageFromAnthropic(data), + } +} + +async function callOpenAiChat(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(chatCompletionsUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToOpenAiChat(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +async function callAnthropicMessages(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToAnthropicMessages(body, target)), + }) + const data = await readProviderJson(res) + if (!res.ok) throwProviderError(res, data) + return data +} + +function sseEvent(event: string, data: any): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +} + +function safeJsonParse(value: string): any { + try { + return JSON.parse(value) + } catch { + return {} + } +} + +function getReadableStream(res: Response): AsyncIterable { + const body = res.body + if (!body) throw new Error('Provider returned an empty stream') + return body as any +} + +function parseOpenAiSse(buffer: string): { events: string[]; rest: string } { + const events: string[] = [] + let cursor = 0 + while (true) { + const index = buffer.indexOf('\n\n', cursor) + if (index < 0) break + events.push(buffer.slice(cursor, index)) + cursor = index + 2 + } + return { events, rest: buffer.slice(cursor) } +} + +function extractSseData(event: string): string[] { + return event + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()) +} + +async function openAiChatToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'chat_completions') { + const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(chatCompletionsUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToOpenAiChat(body, target, true)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + const id = `resp_${Date.now()}` + const messageId = `msg_${id}` + let buffer = '' + let textStarted = false + let text = '' + const toolCalls = new Map() + + yield sseEvent('response.created', { + type: 'response.created', + response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, + }) + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + const choice = data?.choices?.[0] + if (!choice) continue + + const delta = choice.delta || {} + if (typeof delta.content === 'string' && delta.content) { + if (!textStarted) { + textStarted = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'message', + id: messageId, + status: 'in_progress', + role: 'assistant', + content: [], + }, + }) + yield sseEvent('response.content_part.added', { + type: 'response.content_part.added', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '', annotations: [] }, + }) + } + text += delta.content + yield sseEvent('response.output_text.delta', { + type: 'response.output_text.delta', + item_id: messageId, + output_index: 0, + content_index: 0, + delta: delta.content, + }) + } + + for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { + const index = Number(toolCall.index || 0) + let call = toolCalls.get(index) + if (!call) { + call = { + id: String(toolCall.id || `call_${index}`), + name: String(toolCall.function?.name || 'tool'), + arguments: '', + added: false, + } + toolCalls.set(index, call) + } + if (toolCall.id) call.id = String(toolCall.id) + if (toolCall.function?.name) call.name = String(toolCall.function.name) + if (!call.added && call.name) { + call.added = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: textStarted ? index + 1 : index, + item: { + type: 'function_call', + id: call.id, + call_id: call.id, + name: call.name, + arguments: '', + }, + }) + } + const argsDelta = toolCall.function?.arguments + if (typeof argsDelta === 'string' && argsDelta) { + call.arguments += argsDelta + yield sseEvent('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + item_id: call.id, + output_index: textStarted ? index + 1 : index, + delta: argsDelta, + }) + } + } + } + } + } + + const output: any[] = [] + if (textStarted) { + const messageItem = { + type: 'message', + id: messageId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + } + output.push(messageItem) + yield sseEvent('response.output_text.done', { + type: 'response.output_text.done', + item_id: messageId, + output_index: 0, + content_index: 0, + text, + }) + yield sseEvent('response.content_part.done', { + type: 'response.content_part.done', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text, annotations: [] }, + }) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: 0, + item: messageItem, + }) + } + + for (const [index, call] of toolCalls.entries()) { + const outputIndex = textStarted ? index + 1 : index + const callItem = { + type: 'function_call', + id: call.id, + call_id: call.id, + name: call.name, + arguments: call.arguments || '{}', + } + output.push(callItem) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: callItem, + }) + } + yield sseEvent('response.completed', { + type: 'response.completed', + response: { + id, + object: 'response', + status: 'completed', + model: target.model, + output, + }, + }) + } + + return Readable.from(generate()) +} + +function extractSseEventName(event: string): string { + return event + .split(/\r?\n/) + .find(line => line.startsWith('event:')) + ?.slice(6) + .trim() || '' +} + +async function anthropicMessagesToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { + if (target.apiMode !== 'anthropic_messages') { + const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) + ;(err as any).status = 501 + throw err + } + + const res = await fetch(anthropicMessagesUrl(target), { + method: 'POST', + headers: { + Authorization: `Bearer ${target.apiKey}`, + 'x-api-key': target.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(responsesToAnthropicMessages(body, target, true)), + }) + if (!res.ok) { + const data = await readProviderJson(res) + throwProviderError(res, data) + } + + const stream = getReadableStream(res) + const decoder = new TextDecoder() + + async function* generate() { + let id = `resp_${Date.now()}` + let messageId = `msg_${id}` + let buffer = '' + let textStarted = false + let text = '' + const toolBlocks = new Map() + + yield sseEvent('response.created', { + type: 'response.created', + response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, + }) + + const ensureText = function* () { + if (!textStarted) { + textStarted = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'message', id: messageId, status: 'in_progress', role: 'assistant', content: [] }, + }) + yield sseEvent('response.content_part.added', { + type: 'response.content_part.added', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '', annotations: [] }, + }) + } + } + + const ensureTool = function* (index: number, idValue?: string, name?: string) { + let block = toolBlocks.get(index) + if (!block) { + block = { id: idValue || `toolu_${index}`, name: name || 'tool', arguments: '', added: false } + toolBlocks.set(index, block) + } + if (idValue) block.id = idValue + if (name) block.name = name + if (!block.added) { + block.added = true + yield sseEvent('response.output_item.added', { + type: 'response.output_item.added', + output_index: textStarted ? index + 1 : index, + item: { type: 'function_call', id: block.id, call_id: block.id, name: block.name, arguments: '' }, + }) + } + return block + } + + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + const parsed = parseOpenAiSse(buffer) + buffer = parsed.rest + + for (const event of parsed.events) { + const eventName = extractSseEventName(event) + for (const dataLine of extractSseData(event)) { + if (!dataLine || dataLine === '[DONE]') continue + const data = safeJsonParse(dataLine) + + if (eventName === 'message_start' || data?.type === 'message_start') { + id = String(data?.message?.id || id) + messageId = `msg_${id}` + } + + if (eventName === 'content_block_start' || data?.type === 'content_block_start') { + const contentBlock = data?.content_block || {} + if (contentBlock.type === 'tool_use') { + yield* ensureTool(Number(data.index || 0), String(contentBlock.id || ''), String(contentBlock.name || 'tool')) + } + } + + if (eventName === 'content_block_delta' || data?.type === 'content_block_delta') { + const delta = data?.delta || {} + if (delta.type === 'text_delta' && delta.text) { + yield* ensureText() + text += String(delta.text) + yield sseEvent('response.output_text.delta', { + type: 'response.output_text.delta', + item_id: messageId, + output_index: 0, + content_index: 0, + delta: String(delta.text), + }) + } + if (delta.type === 'input_json_delta' && delta.partial_json) { + const index = Number(data.index || 0) + const block = yield* ensureTool(index) + const argsDelta = String(delta.partial_json) + block.arguments += argsDelta + yield sseEvent('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + item_id: block.id, + output_index: textStarted ? index + 1 : index, + delta: argsDelta, + }) + } + } + } + } + } + + const output: any[] = [] + if (textStarted) { + const messageItem = { + type: 'message', + id: messageId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text, annotations: [] }], + } + output.push(messageItem) + yield sseEvent('response.output_text.done', { + type: 'response.output_text.done', + item_id: messageId, + output_index: 0, + content_index: 0, + text, + }) + yield sseEvent('response.content_part.done', { + type: 'response.content_part.done', + item_id: messageId, + output_index: 0, + content_index: 0, + part: { type: 'output_text', text, annotations: [] }, + }) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: 0, + item: messageItem, + }) + } + for (const [index, block] of toolBlocks.entries()) { + const outputIndex = textStarted ? index + 1 : index + const item = { + type: 'function_call', + id: block.id, + call_id: block.id, + name: block.name, + arguments: block.arguments || '{}', + } + output.push(item) + yield sseEvent('response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item, + }) + } + yield sseEvent('response.completed', { + type: 'response.completed', + response: { id, object: 'response', status: 'completed', model: target.model, output }, + }) + } + + return Readable.from(generate()) +} + +export async function codexProxyResponses(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + try { + const requestBody = ctx.request.body || {} + if ((requestBody as any).stream === true) { + const stream = target.apiMode === 'anthropic_messages' + ? await anthropicMessagesToResponsesSseStream(target, requestBody) + : await openAiChatToResponsesSseStream(target, requestBody) + ctx.set('Content-Type', 'text/event-stream; charset=utf-8') + ctx.set('Cache-Control', 'no-cache') + ctx.body = stream + } else { + ctx.body = target.apiMode === 'anthropic_messages' + ? anthropicMessageToResponses(await callAnthropicMessages(target, requestBody), target) + : openAiChatToResponses(await callOpenAiChat(target, requestBody), target) + } + } catch (err: any) { + ctx.status = err.status || 502 + ctx.body = { + error: { + type: 'api_error', + message: err?.message || 'Codex proxy request failed', + provider_error: err?.providerError, + }, + } + } +} + +export async function codexProxyModels(ctx: Context) { + const target = requireTarget(ctx) + if (!target) return + ctx.body = { + object: 'list', + data: [{ + id: target.model, + object: 'model', + created: 0, + owned_by: target.provider, + }], + } +} diff --git a/packages/server/src/services/coding-agents.ts b/packages/server/src/services/coding-agents.ts new file mode 100644 index 0000000..14eb0bf --- /dev/null +++ b/packages/server/src/services/coding-agents.ts @@ -0,0 +1,958 @@ +import { execFile } from 'child_process' +import { existsSync, realpathSync } from 'fs' +import { mkdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { delimiter, dirname, join } from 'path' +import { promisify } from 'util' +import { getWebUiHome } from '../config' +import { registerClaudeCodeProxyTarget, type ApiMode } from './claude-code-proxy' +import { registerCodexProxyTarget } from './codex-proxy' +import { PROVIDER_PRESETS } from '../shared/providers' +import { getModelContextLength } from './hermes/model-context' + +const execFileAsync = promisify(execFile) +const LAUNCH_API_MODES = new Set(['chat_completions', 'codex_responses', 'anthropic_messages']) +const CLAUDE_PROXY_HAIKU_MODEL = 'claude-haiku-4-5' +const CLAUDE_PROXY_SONNET_MODEL = 'claude-sonnet-4-6' +const CLAUDE_PROXY_OPUS_MODEL = 'claude-opus-4-7' +const CODING_AGENT_HOME_DIR = 'coding-agent' +const CODEX_MODEL_CATALOG_FILE = 'codex-model-catalog.json' +const CODEX_CATALOG_BASE_INSTRUCTIONS = 'You are Codex, a coding agent. Be precise, safe, and helpful.' + +export type CodingAgentId = 'claude-code' | 'codex' + +export interface CodingAgentDefinition { + id: CodingAgentId + name: string + provider: string + command: string + packageName: string +} + +export interface CodingAgentToolStatus extends CodingAgentDefinition { + installed: boolean + version: string + rawVersion: string + error?: string +} + +export interface CodingAgentsStatus { + tools: CodingAgentToolStatus[] +} + +export interface CodingAgentMutationResult extends CodingAgentsStatus { + success: boolean + tool: CodingAgentToolStatus + message?: string +} + +export interface CodingAgentConfigFileDefinition { + key: string + path: string + absolutePath: string + language: string +} + +export interface CodingAgentConfigScope { + profile?: string + provider?: string +} + +export interface CodingAgentConfigFileContent extends CodingAgentConfigFileDefinition { + content: string + exists: boolean + size: number + profile: string + provider: string + rootDir: string +} + +export interface CodingAgentLaunchInput extends CodingAgentConfigScope { + mode?: 'scoped' | 'global' + model?: string + baseUrl?: string + apiKey?: string + apiMode?: ApiMode +} + +export interface CodingAgentLaunchResult { + agentId: CodingAgentId + mode: 'scoped' | 'global' + profile: string + provider: string + model: string + rootDir: string + workspaceDir: string + command: string + args: string[] + env: Record + shellCommand: string + files: Array<{ key: string; path: string; absolutePath: string }> +} + +export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { + nativeTerminal: true + terminal: string +} + +const TOOL_DEFINITIONS: CodingAgentDefinition[] = [ + { + id: 'claude-code', + name: 'Claude Code', + provider: 'Anthropic', + command: 'claude', + packageName: '@anthropic-ai/claude-code', + }, + { + id: 'codex', + name: 'Codex', + provider: 'OpenAI', + command: 'codex', + packageName: '@openai/codex', + }, +] + +const CONFIG_FILE_DEFINITIONS: Record & { scopedPath: string }>> = { + 'claude-code': [ + { key: 'settings', path: '~/.claude/settings.json', scopedPath: 'settings.json', language: 'json' }, + { key: 'mcp', path: '~/.claude.json', scopedPath: 'mcp.json', language: 'json' }, + { key: 'prompt', path: '~/.claude/CLAUDE.md', scopedPath: 'CLAUDE.md', language: 'markdown' }, + ], + codex: [ + { key: 'auth', path: '~/.codex/auth.json', scopedPath: 'auth.json', language: 'json' }, + { key: 'config', path: '~/.codex/config.toml', scopedPath: 'config.toml', language: 'ini' }, + { key: 'agents', path: '~/.codex/AGENTS.md', scopedPath: 'AGENTS.md', language: 'markdown' }, + ], +} + +const installingTools = new Set() +const deletingTools = new Set() +let cachedGlobalNpmBin: string | null | undefined +const MAX_CONFIG_FILE_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024 + +function getNodeBinDir() { + return dirname(process.execPath) +} + +function getNodePrefix() { + return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir()) +} + +function getHomebrewPrefix() { + const match = process.execPath.match(/^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/) + return match?.[1] || null +} + +function getNpmCliCandidates() { + const prefix = getNodePrefix() + const homebrewPrefix = getHomebrewPrefix() + + return process.platform === 'win32' + ? [ + join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ] + : [ + join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ...(homebrewPrefix ? [join(homebrewPrefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')] : []), + ] +} + +function getNpmCliPath() { + return getNpmCliCandidates().find(existsSync) || null +} + +function getNpmBin() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +function normalizeScopeSegment(value: string | undefined, fallback: string, label: string): string { + const segment = String(value || '').trim() || fallback + if ( + segment === '.' || + segment === '..' || + segment.includes('/') || + segment.includes('\\') || + segment.includes('\0') + ) { + const err = new Error(`Invalid ${label}`) + ;(err as any).status = 400 + throw err + } + if (segment.length > 128) { + const err = new Error(`${label} is too long`) + ;(err as any).status = 400 + throw err + } + return segment +} + +function normalizeConfigScope(scope: CodingAgentConfigScope = {}): Required { + return { + profile: normalizeScopeSegment(scope.profile, 'default', 'profile'), + provider: normalizeScopeSegment(scope.provider, 'default', 'provider'), + } +} + +function normalizeLaunchApiMode(value: unknown, fallback: ApiMode): ApiMode { + if (!value) return fallback + const mode = String(value).trim() as ApiMode + if (!LAUNCH_API_MODES.has(mode)) { + const err = new Error('Invalid API protocol') + ;(err as any).status = 400 + throw err + } + return mode +} + +function getScopedConfigRoot(id: CodingAgentId, scope: Required): string { + return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'model', scope.profile, scope.provider, id) +} + +function getScopedWorkspaceRoot(scope: Required): string { + return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'workspace', scope.profile, scope.provider) +} + +function displayNameForModel(model: string): string { + const trimmed = model.trim() + if (!trimmed) return 'Model' + const leaf = trimmed.split('/').filter(Boolean).pop() || trimmed + return leaf + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, char => char.toUpperCase()) +} + +function codexCatalogEntry(input: { + model: string + displayName: string + contextWindow: number + priority: number +}) { + return { + slug: input.model, + display_name: input.displayName, + description: input.displayName, + default_reasoning_level: 'medium', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses with lighter reasoning' }, + { effort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, + { effort: 'high', description: 'Greater reasoning depth for complex problems' }, + { effort: 'xhigh', description: 'Extra high reasoning depth for complex problems' }, + ], + shell_type: 'shell_command', + visibility: 'list', + supported_in_api: true, + priority: 1000 + input.priority, + additional_speed_tiers: [], + service_tiers: [], + default_service_tier: null, + availability_nux: null, + upgrade: null, + base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, + model_messages: { + instructions_template: '{{ base_instructions }}\n\n{{ personality }}', + instructions_variables: { + base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, + personality: '', + personality_default: '', + personality_friendly: '', + personality_pragmatic: '', + }, + }, + supports_reasoning_summaries: true, + default_reasoning_summary: 'none', + support_verbosity: true, + default_verbosity: 'low', + apply_patch_tool_type: 'freeform', + web_search_tool_type: 'text_and_image', + truncation_policy: { mode: 'tokens', limit: 10_000 }, + supports_parallel_tool_calls: true, + supports_image_detail_original: true, + context_window: input.contextWindow, + max_context_window: input.contextWindow, + effective_context_window_percent: 95, + experimental_supported_tools: [], + input_modalities: ['text'], + supports_search_tool: true, + } +} + +function buildCodexModelCatalog(input: { + profile: string + provider: string + model: string + presetModels: string[] +}) { + const models = [...new Set([input.model, ...input.presetModels].map(item => item.trim()).filter(Boolean))] + return { + models: models.map((model, index) => codexCatalogEntry({ + model, + displayName: displayNameForModel(model), + contextWindow: getModelContextLength({ profile: input.profile, provider: input.provider, model }), + priority: index, + })), + } +} + +function expandHomePath(path: string): string { + if (path === '~') return homedir() + if (path.startsWith('~/')) return join(homedir(), path.slice(2)) + return path +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function powerShellQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function buildLaunchShellCommand(input: { + workspaceDir: string + env: Record + command: string + args: string[] +}): string { + if (process.platform === 'win32') { + const envAssignments = Object.entries(input.env) + .map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`) + return [ + `Set-Location -LiteralPath ${powerShellQuote(input.workspaceDir)}`, + ...envAssignments, + `& ${powerShellQuote(input.command)} ${input.args.map(powerShellQuote).join(' ')}`.trim(), + ].join('; ') + } + + const envPrefix = Object.entries(input.env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ') + const runCommand = [ + envPrefix, + input.command, + ...input.args.map(shellQuote), + ].filter(Boolean).join(' ') + return `cd ${shellQuote(input.workspaceDir)} && ${runCommand}` +} + +function appleScriptString(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +async function commandExists(command: string): Promise { + try { + await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [command], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + }) + return true + } catch { + return false + } +} + +function isDockerRuntime(): boolean { + return existsSync('/.dockerenv') || process.env.container === 'docker' +} + +async function openNativeTerminal(shellCommand: string): Promise { + if (process.platform === 'win32') { + await execFileAsync('powershell.exe', [ + '-NoProfile', + '-Command', + "Start-Process -FilePath powershell.exe -ArgumentList @('-NoExit', '-Command', $args[0])", + shellCommand, + ], { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return 'PowerShell' + } + + if (process.platform === 'darwin') { + await execFileAsync('osascript', [ + '-e', + `tell application "Terminal" to do script ${appleScriptString(shellCommand)}`, + '-e', + 'tell application "Terminal" to activate', + ], { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return 'Terminal.app' + } + + if (process.platform === 'linux') { + if (isDockerRuntime()) { + const err = new Error('Native terminal is not available inside Docker') + ;(err as any).status = 400 + throw err + } + if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + const err = new Error('Native terminal requires a Linux desktop session') + ;(err as any).status = 400 + throw err + } + + const candidates: Array<{ command: string; args: string[] }> = [ + { command: 'xdg-terminal-exec', args: ['bash', '-lc', shellCommand] }, + { command: 'gnome-terminal', args: ['--', 'bash', '-lc', shellCommand] }, + { command: 'konsole', args: ['-e', 'bash', '-lc', shellCommand] }, + { command: 'xfce4-terminal', args: ['--command', `bash -lc ${shellQuote(shellCommand)}`] }, + { command: 'kitty', args: ['bash', '-lc', shellCommand] }, + { command: 'alacritty', args: ['-e', 'bash', '-lc', shellCommand] }, + { command: 'xterm', args: ['-e', 'bash', '-lc', shellCommand] }, + ] + + const errors: string[] = [] + for (const candidate of candidates) { + if (!(await commandExists(candidate.command))) continue + try { + await execFileAsync(candidate.command, candidate.args, { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + }) + return candidate.command + } catch (err: any) { + errors.push(`${candidate.command}: ${normalizeError(err)}`) + } + } + + const err = new Error(errors[0] || 'No supported Linux terminal command was found') + ;(err as any).status = 400 + throw err + } + + const err = new Error('Native terminal launch is not supported on this platform') + ;(err as any).status = 400 + throw err +} + +function getLiveConfigFileDefinition(id: string, key: string): CodingAgentConfigFileDefinition | null { + const tool = getCodingAgentDefinition(id) + if (!tool) return null + const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) + if (!definition) return null + return { + key: definition.key, + path: definition.path, + language: definition.language, + absolutePath: expandHomePath(definition.path), + } +} + +function getScopedConfigFileDefinition(id: string, key: string, scopeInput: CodingAgentConfigScope = {}): (CodingAgentConfigFileDefinition & Required & { rootDir: string }) | null { + const tool = getCodingAgentDefinition(id) + if (!tool) return null + const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) + if (!definition) return null + const scope = normalizeConfigScope(scopeInput) + const rootDir = getScopedConfigRoot(tool.id, scope) + return { + key: definition.key, + path: definition.path, + language: definition.language, + ...scope, + rootDir, + absolutePath: join(rootDir, definition.scopedPath), + } +} + +function getCurrentNodeEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter), + npm_node_execpath: process.execPath, + } +} + +async function runNpm(args: string[], options: { timeout?: number; env?: NodeJS.ProcessEnv } = {}) { + const npmCli = getNpmCliPath() + const command = npmCli ? process.execPath : getNpmBin() + const commandArgs = npmCli ? [npmCli, ...args] : args + return execFileAsync(command, commandArgs, { + encoding: 'utf-8', + timeout: options.timeout, + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + env: { + ...getCurrentNodeEnv(), + ...options.env, + }, + }) +} + +function normalizeError(err: any): string { + const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' + const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' + const message = stderr || stdout || err?.message || String(err) + return message.split(/\r?\n/).filter(Boolean).slice(0, 4).join('\n') +} + +async function findCommandPaths(command: string, env: NodeJS.ProcessEnv): Promise { + try { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which' + const lookupArgs = process.platform === 'win32' ? [command] : ['-a', command] + const { stdout } = await execFileAsync(lookupCommand, lookupArgs, { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env, + }) + return stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean) + } catch { + return [] + } +} + +function packageParts(packageName: string): string[] { + return packageName.split('/').filter(Boolean) +} + +function getPrefixFromPackagePath(path: string, packageName: string): string | null { + const normalized = path.replace(/\\/g, '/') + const parts = normalized.split('/').filter(Boolean) + const nodeModulesIndex = parts.lastIndexOf('node_modules') + const packageNameParts = packageParts(packageName) + + if (nodeModulesIndex <= 0) return null + for (let i = 0; i < packageNameParts.length; i += 1) { + if (parts[nodeModulesIndex + 1 + i] !== packageNameParts[i]) return null + } + + const libIndex = nodeModulesIndex - 1 + if (parts[libIndex] !== 'lib') return null + const prefixParts = parts.slice(0, libIndex) + if (prefixParts.length === 0) return process.platform === 'win32' ? null : '/' + return `${normalized.startsWith('/') ? '/' : ''}${prefixParts.join('/')}` +} + +async function getCommandPackagePrefixes(definition: CodingAgentDefinition, env: NodeJS.ProcessEnv): Promise { + const commandPaths = await findCommandPaths(definition.command, env) + const prefixes = new Set() + + for (const commandPath of commandPaths) { + const candidates = [commandPath] + try { + candidates.push(realpathSync(commandPath)) + } catch { + // Keep the unresolved command path as the fallback candidate. + } + + for (const candidate of candidates) { + const prefix = getPrefixFromPackagePath(candidate, definition.packageName) + if (prefix) prefixes.add(prefix) + } + } + return [...prefixes] +} + +function extractVersion(raw: string): string { + const trimmed = raw.trim() + return trimmed.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] || trimmed.split(/\s+/)[0] || '' +} + +async function getGlobalNpmBin(): Promise { + if (typeof cachedGlobalNpmBin !== 'undefined') return cachedGlobalNpmBin + try { + const { stdout } = await runNpm(['prefix', '-g'], { timeout: 5000 }) + const prefix = stdout.trim() + cachedGlobalNpmBin = prefix ? (process.platform === 'win32' ? prefix : join(prefix, 'bin')) : null + } catch { + cachedGlobalNpmBin = null + } + return cachedGlobalNpmBin +} + +async function commandEnv(): Promise { + const env = getCurrentNodeEnv() + const npmBin = await getGlobalNpmBin() + if (npmBin) { + const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH' + const currentPath = env[pathKey] || '' + if (!currentPath.split(delimiter).includes(npmBin)) { + env[pathKey] = currentPath ? `${npmBin}${delimiter}${currentPath}` : npmBin + } + } + return env +} + +export function getCodingAgentDefinitions(): CodingAgentDefinition[] { + return TOOL_DEFINITIONS.map(tool => ({ ...tool })) +} + +export function getCodingAgentDefinition(id: string): CodingAgentDefinition | null { + return TOOL_DEFINITIONS.find(tool => tool.id === id) || null +} + +export function getCodingAgentConfigFileDefinitions(id: string): CodingAgentConfigFileDefinition[] { + const tool = getCodingAgentDefinition(id) + if (!tool) return [] + return CONFIG_FILE_DEFINITIONS[tool.id].map(file => ({ + key: file.key, + path: file.path, + language: file.language, + absolutePath: expandHomePath(file.path), + })) +} + +export async function getCodingAgentStatus(definition: CodingAgentDefinition): Promise { + try { + const { stdout, stderr } = await execFileAsync(definition.command, ['--version'], { + encoding: 'utf-8', + timeout: 8000, + windowsHide: true, + env: await commandEnv(), + }) + const rawVersion = `${stdout || ''}${stderr || ''}`.trim() + return { + ...definition, + installed: true, + version: extractVersion(rawVersion), + rawVersion, + } + } catch (err: any) { + return { + ...definition, + installed: false, + version: '', + rawVersion: '', + error: normalizeError(err), + } + } +} + +export async function getCodingAgentsStatus(): Promise { + return { + tools: await Promise.all(TOOL_DEFINITIONS.map(tool => getCodingAgentStatus(tool))), + } +} + +export async function installCodingAgent(id: string): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + if (installingTools.has(tool.id)) { + const err = new Error('Install is already running') + ;(err as any).status = 409 + throw err + } + + installingTools.add(tool.id) + try { + const env = await commandEnv() + await runNpm(['install', '-g', tool.packageName], { + timeout: 10 * 60 * 1000, + env, + }) + cachedGlobalNpmBin = undefined + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: status.installed, + tool: status, + tools: allStatus.tools, + message: status.installed ? 'Installed' : status.error || 'Install completed but the command was not found', + } + } catch (err: any) { + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: false, + tool: status, + tools: allStatus.tools, + message: normalizeError(err), + } + } finally { + installingTools.delete(tool.id) + } +} + +export async function deleteCodingAgent(id: string): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + if (deletingTools.has(tool.id)) { + const err = new Error('Delete is already running') + ;(err as any).status = 409 + throw err + } + + deletingTools.add(tool.id) + try { + const env = await commandEnv() + const packagePrefixes = await getCommandPackagePrefixes(tool, env) + const uninstallArgsList = packagePrefixes.length > 0 + ? packagePrefixes.map(prefix => ['uninstall', '-g', '--prefix', prefix, tool.packageName]) + : [['uninstall', '-g', tool.packageName]] + for (const uninstallArgs of uninstallArgsList) { + await runNpm(uninstallArgs, { + timeout: 10 * 60 * 1000, + env, + }) + } + cachedGlobalNpmBin = undefined + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: !status.installed, + tool: status, + tools: allStatus.tools, + message: !status.installed ? 'Deleted' : 'Delete completed but the command is still available', + } + } catch (err: any) { + const status = await getCodingAgentStatus(tool) + const allStatus = await getCodingAgentsStatus() + return { + success: false, + tool: status, + tools: allStatus.tools, + message: normalizeError(err), + } + } finally { + deletingTools.delete(tool.id) + } +} + +export async function readCodingAgentConfigFile(id: string, key: string, scope: CodingAgentConfigScope = {}): Promise { + const definition = getLiveConfigFileDefinition(id, key) + if (!definition) { + const err = new Error('Unknown coding agent config file') + ;(err as any).status = 404 + throw err + } + const normalizedScope = normalizeConfigScope(scope) + + try { + const info = await stat(definition.absolutePath) + if (!info.isFile()) { + const err = new Error('Config path is not a file') + ;(err as any).status = 400 + throw err + } + if (info.size > MAX_CONFIG_FILE_SIZE) { + const err = new Error('Config file is too large to edit') + ;(err as any).status = 413 + throw err + } + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content: await readFile(definition.absolutePath, 'utf-8'), + exists: true, + size: info.size, + } + } catch (err: any) { + if (err?.code !== 'ENOENT') throw err + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content: '', + exists: false, + size: 0, + } + } +} + +export async function writeCodingAgentConfigFile(id: string, key: string, content: string, scope: CodingAgentConfigScope = {}): Promise { + const definition = getLiveConfigFileDefinition(id, key) + if (!definition) { + const err = new Error('Unknown coding agent config file') + ;(err as any).status = 404 + throw err + } + const normalizedScope = normalizeConfigScope(scope) + + const buffer = Buffer.from(content || '', 'utf-8') + if (buffer.length > MAX_CONFIG_FILE_SIZE) { + const err = new Error('Config file content is too large') + ;(err as any).status = 413 + throw err + } + + await mkdir(dirname(definition.absolutePath), { recursive: true }) + await writeFile(definition.absolutePath, buffer) + return { + ...definition, + ...normalizedScope, + rootDir: dirname(definition.absolutePath), + content, + exists: true, + size: buffer.length, + } +} + +export async function prepareCodingAgentLaunch(id: string, input: CodingAgentLaunchInput): Promise { + const tool = getCodingAgentDefinition(id) + if (!tool) { + const err = new Error('Unknown coding agent') + ;(err as any).status = 400 + throw err + } + + const mode = input.mode === 'global' ? 'global' : 'scoped' + if (mode === 'global') { + const scope = normalizeConfigScope({ profile: input.profile, provider: 'global' }) + const workspaceDir = getScopedWorkspaceRoot(scope) + await mkdir(workspaceDir, { recursive: true }) + const shellCommand = buildLaunchShellCommand({ + workspaceDir, + env: {}, + command: tool.command, + args: [], + }) + return { + agentId: tool.id, + mode, + profile: scope.profile, + provider: scope.provider, + model: '', + rootDir: workspaceDir, + workspaceDir, + command: tool.command, + args: [], + env: {}, + shellCommand, + files: [], + } + } + + const provider = normalizeScopeSegment(input.provider, 'default', 'provider') + const scope = normalizeConfigScope({ profile: input.profile, provider }) + const model = String(input.model || '').trim() + if (!model) { + const err = new Error('Model is required') + ;(err as any).status = 400 + throw err + } + + const baseUrl = String(input.baseUrl || '').trim() + const apiKey = String(input.apiKey || '').trim() + const preset = PROVIDER_PRESETS.find(item => item.value === provider) + const apiMode = normalizeLaunchApiMode(input.apiMode, preset?.api_mode || 'chat_completions') + const rootDir = getScopedConfigRoot(tool.id, scope) + const workspaceDir = getScopedWorkspaceRoot(scope) + await mkdir(rootDir, { recursive: true }) + await mkdir(workspaceDir, { recursive: true }) + + const files: Array<{ key: string; path: string; absolutePath: string }> = [] + const writeScopedFile = async (key: string, content: string) => { + const definition = getScopedConfigFileDefinition(tool.id, key, scope) + if (!definition) return + await mkdir(dirname(definition.absolutePath), { recursive: true }) + await writeFile(definition.absolutePath, content, 'utf-8') + files.push({ key, path: definition.path, absolutePath: definition.absolutePath }) + } + + let args: string[] = [] + let env: Record = {} + + if (tool.id === 'claude-code') { + const proxyTarget = baseUrl && apiKey + ? registerClaudeCodeProxyTarget({ provider, model, baseUrl, apiKey, apiMode }) + : null + const claudeBaseUrl = proxyTarget?.baseUrl || baseUrl + const claudeApiKey = proxyTarget?.token || apiKey + const settings = { + env: { + ...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {}), + ...(claudeBaseUrl ? { ANTHROPIC_BASE_URL: claudeBaseUrl } : {}), + ANTHROPIC_DEFAULT_HAIKU_MODEL: CLAUDE_PROXY_HAIKU_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: CLAUDE_PROXY_SONNET_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: CLAUDE_PROXY_OPUS_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: model, + }, + } + await writeScopedFile('settings', `${JSON.stringify(settings, null, 2)}\n`) + await writeScopedFile('mcp', `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`) + + const settingsPath = join(rootDir, 'settings.json') + const mcpPath = join(rootDir, 'mcp.json') + args = ['--settings', settingsPath, '--mcp-config', mcpPath] + } else { + if (apiMode !== 'chat_completions' && apiMode !== 'codex_responses' && apiMode !== 'anthropic_messages') { + const err = new Error('Codex launch only supports OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages providers') + ;(err as any).status = 400 + throw err + } + const proxyTarget = apiMode !== 'codex_responses' && baseUrl && apiKey + ? registerCodexProxyTarget({ profile: scope.profile, provider, model, baseUrl, apiKey, apiMode }) + : null + const codexBaseUrl = proxyTarget?.baseUrl || baseUrl + const codexApiKey = proxyTarget?.token || apiKey + const providerId = 'custom' + const catalogPath = join(rootDir, CODEX_MODEL_CATALOG_FILE) + const configToml = [ + `model_catalog_json = ${JSON.stringify(catalogPath)}`, + `model_provider = ${JSON.stringify(providerId)}`, + `model = ${JSON.stringify(model)}`, + 'disable_response_storage = true', + '', + `[model_providers.${providerId}]`, + `name = ${JSON.stringify(provider)}`, + ...(codexBaseUrl ? [`base_url = ${JSON.stringify(codexBaseUrl)}`] : []), + 'wire_api = "responses"', + 'requires_openai_auth = false', + ...(codexApiKey ? [`experimental_bearer_token = ${JSON.stringify(codexApiKey)}`] : []), + '', + ].join('\n') + const catalog = buildCodexModelCatalog({ + profile: scope.profile, + provider, + model, + presetModels: Array.isArray(preset?.models) ? preset.models : [], + }) + await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf-8') + files.push({ key: 'model_catalog', path: CODEX_MODEL_CATALOG_FILE, absolutePath: catalogPath }) + await writeScopedFile('config', configToml) + await writeScopedFile('auth', `${JSON.stringify({}, null, 2)}\n`) + + env = { CODEX_HOME: rootDir } + args = ['--model', model] + } + + const shellCommand = buildLaunchShellCommand({ + workspaceDir, + env, + command: tool.command, + args, + }) + + return { + agentId: tool.id, + mode, + profile: scope.profile, + provider: scope.provider, + model, + rootDir, + workspaceDir, + command: tool.command, + args, + env, + shellCommand, + files, + } +} + +export async function openCodingAgentNativeTerminal(id: string, input: CodingAgentLaunchInput): Promise { + const launch = await prepareCodingAgentLaunch(id, input) + const terminal = await openNativeTerminal(launch.shellCommand) + return { + ...launch, + nativeTerminal: true, + terminal, + } +} diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index f1dbe79..e92208e 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -54,6 +54,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ builtin: true, base_url: 'https://api.anthropic.com', models: [ + 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', diff --git a/tests/server/coding-agents-launch.test.ts b/tests/server/coding-agents-launch.test.ts new file mode 100644 index 0000000..08263c6 --- /dev/null +++ b/tests/server/coding-agents-launch.test.ts @@ -0,0 +1,676 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { claudeProxyMessages, claudeProxyModels, registerClaudeCodeProxyTarget } from '../../packages/server/src/services/claude-code-proxy' +import { codexProxyModels, codexProxyResponses, registerCodexProxyTarget } from '../../packages/server/src/services/codex-proxy' +import { prepareCodingAgentLaunch } from '../../packages/server/src/services/coding-agents' + +const homes: string[] = [] + +function makeHome() { + const home = mkdtempSync(join(tmpdir(), 'hermes-coding-agent-launch-')) + homes.push(home) + process.env.HERMES_WEB_UI_HOME = home + return home +} + +afterEach(() => { + delete process.env.HERMES_WEB_UI_HOME + vi.unstubAllGlobals() + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) +}) + +function makeProxyContext(routeKey: string, token: string, body: any): any { + return { + params: { key: routeKey }, + request: { body }, + responseHeaders: {} as Record, + get(name: string) { + if (name.toLowerCase() === 'authorization') return `Bearer ${token}` + return '' + }, + set(name: string, value: string) { + this.responseHeaders[name] = value + }, + } +} + +describe('coding agent launch preparation', () => { + it('launches Claude Code with the global config when requested', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('claude-code', { + mode: 'global', + profile: 'default', + }) + + expect(result).toMatchObject({ + agentId: 'claude-code', + mode: 'global', + profile: 'default', + provider: 'global', + model: '', + rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), + workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), + command: 'claude', + args: [], + env: {}, + shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && claude`, + files: [], + }) + }) + + it('launches Codex with the global config when requested', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('codex', { + mode: 'global', + profile: 'default', + }) + + expect(result).toMatchObject({ + agentId: 'codex', + mode: 'global', + profile: 'default', + provider: 'global', + model: '', + rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), + workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), + command: 'codex', + args: [], + env: {}, + shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && codex`, + files: [], + }) + }) + + it('launches Claude Code with scoped settings instead of a CLI --model override', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('claude-code', { + profile: 'default', + provider: 'openrouter', + model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', + baseUrl: 'https://openrouter.ai/api/v1', + apiKey: 'sk-test', + }) + + expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code')) + expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter')) + expect(result.args).toEqual([ + '--settings', + join(result.rootDir, 'settings.json'), + '--mcp-config', + join(result.rootDir, 'mcp.json'), + ]) + expect(result.shellCommand).toContain(`cd ${join(home, 'coding-agent', 'workspace', 'default', 'openrouter')} && claude`) + expect(result.shellCommand).not.toContain('--model') + + const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8')) + expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) + expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) + expect(settings.env).toMatchObject({ + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5', + ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7', + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', + }) + expect(settings.env).not.toHaveProperty('ANTHROPIC_MODEL') + }) + + it('keeps Claude Code protocol overrides behind the local proxy', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('claude-code', { + profile: 'default', + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4.6', + baseUrl: 'https://openrouter.ai/api/v1', + apiKey: 'sk-test', + apiMode: 'anthropic_messages', + }) + + const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8')) + expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) + expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) + }) + + it('keeps Codex model selection on the CLI while isolating CODEX_HOME', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'openrouter', + model: 'openai/gpt-oss-20b:free', + baseUrl: 'https://openrouter.ai/api/v1', + apiKey: 'sk-test', + }) + + expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex')) + expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter')) + expect(result.args).toEqual(['--model', 'openai/gpt-oss-20b:free']) + expect(result.env).toEqual({ CODEX_HOME: result.rootDir }) + + const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') + expect(config).toContain('requires_openai_auth = false') + expect(config).toContain(`model_catalog_json = "${join(result.rootDir, 'codex-model-catalog.json')}"`) + + const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8')) + expect(catalog.models.some((entry: any) => entry.slug === 'openai/gpt-oss-20b:free')).toBe(true) + expect(catalog.models[0]).toHaveProperty('base_instructions') + expect(catalog.models[0]).toHaveProperty('model_messages') + }) + + it('points Codex Chat Completions providers at the local Responses proxy', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'deepseek', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-upstream', + apiMode: 'chat_completions', + }) + + const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') + expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`) + expect(config).toContain('wire_api = "responses"') + expect(config).toContain('requires_openai_auth = false') + expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) + expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'deepseek', 'codex')) + + const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8')) + const deepseekModel = catalog.models.find((entry: any) => entry.slug === 'deepseek-v4-pro') + expect(deepseekModel).toMatchObject({ + display_name: 'Deepseek V4 Pro', + }) + expect(deepseekModel.context_window).toBeGreaterThan(0) + expect(deepseekModel.max_context_window).toBe(deepseekModel.context_window) + expect(deepseekModel.model_messages.instructions_template).toContain('{{ base_instructions }}') + }) + + it('points Codex Anthropic Messages providers at the local Responses proxy', async () => { + const home = makeHome() + + const result = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'anthropic-compatible', + model: 'claude-sonnet-4-6', + baseUrl: 'https://api.example.com', + apiKey: 'sk-upstream', + apiMode: 'anthropic_messages', + }) + + const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') + expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`) + expect(config).toContain('wire_api = "responses"') + expect(config).toContain('requires_openai_auth = false') + expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) + expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'anthropic-compatible', 'codex')) + }) + + it('adapts Codex Responses requests to OpenAI Chat Completions', async () => { + makeHome() + const launch = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'deepseek', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-upstream', + apiMode: 'chat_completions', + }) + const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') + const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' + const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + id: 'chatcmpl_test', + choices: [{ + finish_reason: 'stop', + message: { role: 'assistant', content: 'ok' }, + }], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(routeKey, token, { + max_output_tokens: 16, + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, + { role: 'developer', content: [{ type: 'input_text', text: 'be terse' }] }, + ], + }) + + await codexProxyResponses(ctx) + + expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/v1/chat/completions', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), + })) + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(requestBody).toMatchObject({ + model: 'deepseek-v4-pro', + max_tokens: 16, + messages: [ + { role: 'user', content: 'hello' }, + { role: 'system', content: 'be terse' }, + ], + }) + expect(ctx.body.output[0].content[0].text).toBe('ok') + expect(ctx.body.usage).toMatchObject({ input_tokens: 3, output_tokens: 1, total_tokens: 4 }) + }) + + it('adapts Codex Responses requests to Anthropic Messages', async () => { + makeHome() + const launch = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'anthropic-compatible', + model: 'claude-sonnet-4-6', + baseUrl: 'https://api.example.com', + apiKey: 'sk-upstream', + apiMode: 'anthropic_messages', + }) + const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') + const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' + const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [ + { type: 'text', text: 'ok' }, + { type: 'tool_use', id: 'toolu_1', name: 'search', input: { query: 'repo' } }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 5, output_tokens: 2 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(routeKey, token, { + instructions: 'be terse', + max_output_tokens: 64, + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, + { type: 'function_call_output', call_id: 'call_0', output: 'done' }, + ], + tools: [{ + type: 'function', + name: 'search', + description: 'Search files', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + }], + }) + + await codexProxyResponses(ctx) + + expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer sk-upstream', + 'x-api-key': 'sk-upstream', + 'anthropic-version': '2023-06-01', + }), + })) + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(requestBody).toMatchObject({ + model: 'claude-sonnet-4-6', + system: 'be terse', + max_tokens: 64, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_0', content: 'done' }] }, + ], + tools: [{ + name: 'search', + description: 'Search files', + input_schema: { type: 'object', properties: { query: { type: 'string' } } }, + }], + }) + expect(ctx.body.output[0].content[0].text).toBe('ok') + expect(ctx.body.output[1]).toMatchObject({ + type: 'function_call', + call_id: 'toolu_1', + name: 'search', + arguments: '{"query":"repo"}', + }) + expect(ctx.body.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 }) + }) + + it('streams Codex proxy text as complete Responses message events', async () => { + makeHome() + const launch = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'deepseek', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-upstream', + apiMode: 'chat_completions', + }) + const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') + const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' + const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' + const encoder = new TextEncoder() + const fetchMock = vi.fn(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"p"}}]}\n\n')) + controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"ong"}}]}\n\n')) + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(routeKey, token, { + stream: true, + input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }], + }) + + await codexProxyResponses(ctx) + + const chunks: string[] = [] + for await (const chunk of ctx.body) chunks.push(String(chunk)) + const sse = chunks.join('') + expect(sse).toContain('event: response.output_item.added') + expect(sse).toContain('event: response.content_part.added') + expect(sse).toContain('"delta":"p"') + expect(sse).toContain('"delta":"ong"') + expect(sse).toContain('event: response.output_text.done') + expect(sse).toContain('"text":"pong"') + expect(sse).toContain('event: response.output_item.done') + expect(sse).toContain('"output":[{"type":"message"') + }) + + it('streams Codex proxy Anthropic text as Responses message events', async () => { + makeHome() + const launch = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'anthropic-compatible', + model: 'claude-sonnet-4-6', + baseUrl: 'https://api.example.com', + apiKey: 'sk-upstream', + apiMode: 'anthropic_messages', + }) + const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') + const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' + const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' + const encoder = new TextEncoder() + const fetchMock = vi.fn(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","usage":{"input_tokens":3,"output_tokens":0}}}\n\n')) + controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n')) + controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"he"}}\n\n')) + controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"llo"}}\n\n')) + controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n')) + controller.close() + }, + }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(routeKey, token, { + stream: true, + input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }], + }) + + await codexProxyResponses(ctx) + + const chunks: string[] = [] + for await (const chunk of ctx.body) chunks.push(String(chunk)) + const sse = chunks.join('') + expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'anthropic-version': '2023-06-01' }), + })) + expect(sse).toContain('event: response.output_item.added') + expect(sse).toContain('"delta":"he"') + expect(sse).toContain('"delta":"llo"') + expect(sse).toContain('event: response.output_text.done') + expect(sse).toContain('"text":"hello"') + expect(sse).toContain('event: response.completed') + }) + + it('exposes Codex proxy models with route-token authentication', async () => { + makeHome() + const launch = await prepareCodingAgentLaunch('codex', { + profile: 'default', + provider: 'deepseek', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-upstream', + apiMode: 'chat_completions', + }) + const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') + const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' + const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' + const ctx = makeProxyContext(routeKey, token, {}) + + await codexProxyModels(ctx) + + expect(ctx.body).toMatchObject({ + object: 'list', + data: [{ id: 'deepseek-v4-pro', object: 'model', owned_by: 'deepseek' }], + }) + }) + + it('adapts Claude Code streaming requests to the Responses API for codex_responses providers', async () => { + const target = registerClaudeCodeProxyTarget({ + provider: 'fun-codex', + model: 'gpt-5.5', + baseUrl: 'https://api.apikey.fun/v1', + apiKey: 'sk-upstream', + apiMode: 'codex_responses', + }) + const encoder = new TextEncoder() + const fetchMock = vi.fn(async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"response.output_text.delta","delta":"hi"}\n\n')) + controller.enqueue(encoder.encode('data: {"type":"response.completed","response":{"status":"completed","usage":{"output_tokens":1}}}\n\n')) + controller.close() + }, + }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(target.routeKey, target.token, { + stream: true, + max_tokens: 32, + messages: [{ role: 'user', content: 'hello' }], + }) + + await claudeProxyMessages(ctx) + + expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/responses', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), + })) + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(requestBody).toMatchObject({ + model: 'gpt-5.5', + stream: true, + store: false, + max_output_tokens: 32, + input: [{ role: 'user', content: 'hello' }], + }) + + const chunks: string[] = [] + for await (const chunk of ctx.body) chunks.push(String(chunk)) + const sse = chunks.join('') + expect(ctx.responseHeaders['Content-Type']).toContain('text/event-stream') + expect(sse).toContain('event: message_start') + expect(sse).toContain('"type":"text_delta","text":"hi"') + expect(sse).toContain('event: message_stop') + }) + + it('round-trips reasoning_content for DeepSeek-style OpenAI Chat tool calls', async () => { + const target = registerClaudeCodeProxyTarget({ + provider: 'deepseek', + model: 'deepseek-reasoner', + baseUrl: 'https://api.deepseek.com/v1', + apiKey: 'sk-upstream', + apiMode: 'chat_completions', + }) + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + id: 'chatcmpl_test', + choices: [{ + finish_reason: 'tool_calls', + message: { + role: 'assistant', + reasoning_content: 'Need to inspect the repository first.', + content: null, + tool_calls: [{ + id: 'call_2', + type: 'function', + function: { name: 'search', arguments: '{"query":"proxy"}' }, + }], + }, + }], + usage: { prompt_tokens: 12, completion_tokens: 8 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(target.routeKey, target.token, { + max_tokens: 32, + messages: [ + { role: 'user', content: 'check it' }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Need the current repo files.' }, + { type: 'tool_use', id: 'call_1', name: 'search', input: { query: 'reasoning_content' } }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call_1', content: 'found one file' }, + ], + }, + ], + }) + + await claudeProxyMessages(ctx) + + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(requestBody.messages[1]).toMatchObject({ + role: 'assistant', + reasoning_content: 'Need the current repo files.', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: '{"query":"reasoning_content"}' }, + }], + }) + expect(ctx.body.content[0]).toEqual({ + type: 'thinking', + thinking: 'Need to inspect the repository first.', + }) + expect(ctx.body.content[1]).toMatchObject({ + type: 'tool_use', + id: 'call_2', + name: 'search', + input: { query: 'proxy' }, + }) + }) + + it('passes Anthropic Messages providers through the local proxy without exposing upstream credentials', async () => { + const target = registerClaudeCodeProxyTarget({ + provider: 'fun-claude', + model: 'claude-sonnet-4-6', + baseUrl: 'https://api.apikey.fun', + apiKey: 'sk-upstream', + apiMode: 'anthropic_messages', + }) + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'hi' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 1 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + + const ctx = makeProxyContext(target.routeKey, target.token, { + model: 'ignored-client-model', + max_tokens: 32, + messages: [{ role: 'user', content: 'hello' }], + }) + + await claudeProxyMessages(ctx) + + expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/messages', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer sk-upstream', + 'x-api-key': 'sk-upstream', + }), + })) + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) + expect(requestBody.model).toBe('claude-sonnet-4-6') + expect(ctx.body.content[0].text).toBe('hi') + }) + + it('keeps Claude proxy routes separate for the same model with different protocols', () => { + const chat = registerClaudeCodeProxyTarget({ + provider: 'same-provider', + model: 'same-model', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-chat', + apiMode: 'chat_completions', + }) + const anthropic = registerClaudeCodeProxyTarget({ + provider: 'same-provider', + model: 'same-model', + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-anthropic', + apiMode: 'anthropic_messages', + }) + + expect(chat.routeKey).not.toBe(anthropic.routeKey) + expect(chat.token).not.toBe(anthropic.token) + }) + + it('keeps Codex proxy routes separate for the same model with different upstream URLs', () => { + const first = registerCodexProxyTarget({ + profile: 'default', + provider: 'same-provider', + model: 'same-model', + baseUrl: 'https://api-one.example.com/v1', + apiKey: 'sk-one', + apiMode: 'chat_completions', + }) + const second = registerCodexProxyTarget({ + profile: 'default', + provider: 'same-provider', + model: 'same-model', + baseUrl: 'https://api-two.example.com/v1', + apiKey: 'sk-two', + apiMode: 'chat_completions', + }) + + expect(first.routeKey).not.toBe(second.routeKey) + expect(first.token).not.toBe(second.token) + }) + + it('exposes Claude-visible alias models from the local proxy models endpoint', async () => { + const target = registerClaudeCodeProxyTarget({ + provider: 'openrouter', + model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', + baseUrl: 'https://openrouter.ai/api/v1', + apiKey: 'sk-upstream', + apiMode: 'codex_responses', + }) + const ctx = makeProxyContext(target.routeKey, target.token, {}) + + await claudeProxyModels(ctx) + + const ids = ctx.body.data.map((model: any) => model.id) + expect(ids).toContain('claude-haiku-4-5') + expect(ids).toContain('claude-sonnet-4-6') + expect(ids).toContain('claude-opus-4-7') + expect(ids).toContain('cognitivecomputations/dolphin-mistral-24b-venice-edition:free') + }) +})