void
+}
+
+export function HeicodeLoginPage({ onLoggedIn }: Props) {
+ const t = useTranslation()
+ const {
+ providers,
+ hasFetched,
+ isLoading,
+ error,
+ fetch: fetchAuth,
+ stopOAuthPolling,
+ } = useHeicodeAuthStore()
+
+ useEffect(() => {
+ if (!hasFetched) {
+ void fetchAuth()
+ }
+ return () => {
+ stopOAuthPolling()
+ }
+ }, [hasFetched, fetchAuth, stopOAuthPolling])
+
+ return (
+
+
+
+ HeiCode
+
+
+ {t('login.subtitle')}
+
+
+
+ {!hasFetched && isLoading ? (
+
+ {t('common.loading')}
+
+ ) : null}
+
+ {hasFetched && providers.length === 0 ? (
+
+ {t('login.errors.noProviders')}
+
+ ) : null}
+
+ {hasFetched && providers.length > 0 ? (
+
+ {providers.map((provider) => (
+
+ ))}
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {t('login.footnote')}
+
+
+ )
+}
diff --git a/desktop/src/components/login/ProviderLoginCard.tsx b/desktop/src/components/login/ProviderLoginCard.tsx
new file mode 100644
index 00000000..26b5e5d5
--- /dev/null
+++ b/desktop/src/components/login/ProviderLoginCard.tsx
@@ -0,0 +1,236 @@
+// desktop/src/components/login/ProviderLoginCard.tsx
+//
+// 单个登录入口卡片。两种登录方式同时呈现:
+// 1. 浏览器跳转 OAuth (推荐) —— 平台支持时启用
+// 2. 复制粘贴 API Key —— 兼容入口
+
+import { useState } from 'react'
+import { open as shellOpen } from '@tauri-apps/plugin-shell'
+import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth'
+import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
+import { useTranslation } from '../../i18n'
+
+type Props = {
+ provider: HeicodeLoginProviderInfo
+ onLoggedIn?: () => void
+}
+
+/**
+ * Treat any of the following as a "local override":
+ * - http:// (not https://) — typical for in-cluster gateways
+ * - localhost / 127.0.0.1
+ * - 10/172.16-31/192.168 RFC1918 ranges
+ * - any hostname without a dot (e.g. `new-api`, `heicode-server`) — Docker DNS aliases
+ */
+function isLocalBaseUrl(rawUrl: string): boolean {
+ if (!rawUrl) return false
+ let host: string
+ try {
+ const url = new URL(rawUrl)
+ if (url.protocol === 'http:') return true
+ host = url.hostname
+ } catch {
+ return false
+ }
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') return true
+ if (!host.includes('.')) return true
+ if (host.startsWith('10.')) return true
+ if (host.startsWith('192.168.')) return true
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true
+ return false
+}
+
+export function ProviderLoginCard({ provider, onLoggedIn }: Props) {
+ const t = useTranslation()
+ const { loginWithApiKey, startOAuth, startOAuthPolling, isLoggingIn } =
+ useHeicodeAuthStore()
+ const [apiKey, setApiKey] = useState('')
+ const [showKey, setShowKey] = useState(false)
+ const [localError, setLocalError] = useState
(null)
+ const [busy, setBusy] = useState<'oauth' | 'paste' | null>(null)
+
+ const handleOAuth = async () => {
+ if (!provider.oauthEnabled) return
+ setLocalError(null)
+ setBusy('oauth')
+ try {
+ const { authorizeUrl } = await startOAuth(provider.id)
+ try {
+ await shellOpen(authorizeUrl)
+ } catch {
+ setLocalError(t('login.errors.openBrowser'))
+ }
+ startOAuthPolling()
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : String(err))
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const handlePasteLogin = async () => {
+ if (!apiKey.trim()) {
+ setLocalError(t('login.errors.emptyKey'))
+ return
+ }
+ setLocalError(null)
+ setBusy('paste')
+ try {
+ await loginWithApiKey({ providerId: provider.id, apiKey: apiKey.trim() })
+ setApiKey('')
+ onLoggedIn?.()
+ } catch (err) {
+ setLocalError(err instanceof Error ? err.message : String(err))
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const handleOpenKeyPage = async () => {
+ if (!provider.apiKeyUrl) return
+ try {
+ await shellOpen(provider.apiKeyUrl)
+ } catch {
+ // ignore — desktop tauri only
+ }
+ }
+
+ const local = isLocalBaseUrl(provider.baseUrl)
+
+ return (
+
+
+
+ {provider.name}
+
+ {provider.oauthEnabled ? (
+
+ {t('login.tags.recommended')}
+
+ ) : (
+
+ {t('login.tags.comingSoon')}
+
+ )}
+
+
+ {/* Always-visible baseUrl indicator. Helps the user immediately tell
+ whether this card is pointing at the public TaijiAICloud endpoint
+ or has been overridden to a local new-api dev gateway. */}
+
+ {t('login.baseUrl.label')}
+
+ {provider.baseUrl}
+
+ {local ? (
+
+ {t('login.baseUrl.localTag')}
+
+ ) : null}
+
+
+ {provider.promoText ? (
+
+ {provider.promoText}
+
+ ) : null}
+
+ {local ? (
+
+ {t('login.baseUrl.localBanner', { id: provider.id.toUpperCase() })}
+
+ ) : null}
+
+
+
+ {busy === 'oauth'
+ ? t('login.oauth.opening')
+ : provider.oauthEnabled
+ ? t('login.oauth.button')
+ : t('login.oauth.disabled')}
+
+ {!provider.oauthEnabled ? (
+
+ {t('login.oauth.disabledHint')}
+
+ ) : null}
+
+
+
+
+ {t('login.divider.or')}
+
+
+
+
+
+ {t('login.paste.label')}
+
+
+ setApiKey(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ void handlePasteLogin()
+ }
+ }}
+ placeholder={t('login.paste.placeholder')}
+ className="flex-1 bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none"
+ autoComplete="off"
+ spellCheck={false}
+ data-testid={`heicode-login-key-input-${provider.id}`}
+ />
+ setShowKey((v) => !v)}
+ className="px-3 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
+ >
+ {showKey ? t('login.paste.hide') : t('login.paste.show')}
+
+
+
+ {busy === 'paste'
+ ? t('login.paste.submitting')
+ : t('login.paste.submit')}
+
+
+
+ {provider.apiKeyUrl ? (
+
+ {t('login.paste.getKey')} →
+
+ ) : null}
+
+ {localError ? (
+
+ {localError}
+
+ ) : null}
+
+ )
+}
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index ca59f1e5..cbce0458 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -952,6 +952,31 @@ export const en = {
'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress',
+ // ─── HeiCode Login ──────────────────────────────────────
+ 'login.subtitle': 'Choose how to sign in to start using HeiCode',
+ 'login.footnote': 'HeiCode talks directly to TaijiAICloud or ClawdRouter; your API key never leaves this machine.',
+ 'login.tags.recommended': 'Recommended',
+ 'login.tags.comingSoon': 'Coming soon',
+ 'login.oauth.button': 'Sign in with browser',
+ 'login.oauth.opening': 'Opening browser…',
+ 'login.oauth.disabled': 'Browser sign-in (coming soon)',
+ 'login.oauth.disabledHint': 'OAuth will become available once the platform exposes its authorize endpoint.',
+ 'login.divider.or': 'or',
+ 'login.paste.label': 'Paste API Key',
+ 'login.paste.placeholder': 'sk-…',
+ 'login.paste.show': 'Show',
+ 'login.paste.hide': 'Hide',
+ 'login.paste.submit': 'Sign in',
+ 'login.paste.submitting': 'Verifying…',
+ 'login.paste.getKey': 'Get API Key',
+ 'login.errors.openBrowser': 'Failed to open browser. Please copy the URL manually.',
+ 'login.errors.emptyKey': 'Please paste an API key first.',
+ 'login.errors.noProviders': 'No login providers configured. Check your HeiCode server.',
+ 'login.baseUrl.label': 'Endpoint',
+ 'login.baseUrl.localTag': 'Local',
+ 'login.baseUrl.localHint': 'Pointing at a private/local address — paste a token issued by THIS endpoint.',
+ 'login.baseUrl.localBanner': 'This {id} entry has been redirected to a local gateway via env override. Paste an API token from the LOCAL endpoint above (not the public platform).',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 8350dfbb..546db572 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -954,6 +954,31 @@ export const zh: Record = {
'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中',
+ // ─── HeiCode 登录 ──────────────────────────────────────
+ 'login.subtitle': '选择登录方式以开始使用 HeiCode',
+ 'login.footnote': 'HeiCode 直接连 TaijiAICloud 或 ClawdRouter,API Key 仅保存在你这台机器上。',
+ 'login.tags.recommended': '推荐',
+ 'login.tags.comingSoon': '即将开放',
+ 'login.oauth.button': '浏览器登录',
+ 'login.oauth.opening': '正在打开浏览器…',
+ 'login.oauth.disabled': '浏览器登录(即将开放)',
+ 'login.oauth.disabledHint': '等平台开放 OAuth 授权端点后,浏览器登录会立即可用。',
+ 'login.divider.or': '或',
+ 'login.paste.label': '粘贴 API Key',
+ 'login.paste.placeholder': 'sk-…',
+ 'login.paste.show': '显示',
+ 'login.paste.hide': '隐藏',
+ 'login.paste.submit': '登录',
+ 'login.paste.submitting': '校验中…',
+ 'login.paste.getKey': '获取 API Key',
+ 'login.errors.openBrowser': '无法打开浏览器,请手动复制链接。',
+ 'login.errors.emptyKey': '请先粘贴 API Key。',
+ 'login.errors.noProviders': '未配置登录入口,请检查 HeiCode 服务端。',
+ 'login.baseUrl.label': '接口地址',
+ 'login.baseUrl.localTag': '本地',
+ 'login.baseUrl.localHint': '当前指向私网/本地地址 — 请粘贴该地址签发的 Token,而不是公网平台的 Token。',
+ 'login.baseUrl.localBanner': '当前 {id} 入口已被环境变量重定向到本地网关。请粘贴这个本地地址签发的 API Token(不是公网平台的)。',
+
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他',
diff --git a/desktop/src/stores/heicodeAuthStore.ts b/desktop/src/stores/heicodeAuthStore.ts
new file mode 100644
index 00000000..5b46943e
--- /dev/null
+++ b/desktop/src/stores/heicodeAuthStore.ts
@@ -0,0 +1,144 @@
+// desktop/src/stores/heicodeAuthStore.ts
+//
+// HeiCode 全局登录态。AppShell 在启动时调 fetch();登录页里调 loginWithApiKey() / startOAuth()。
+
+import { create } from 'zustand'
+import {
+ heicodeAuthApi,
+ type HeicodeAuthStatus,
+ type HeicodeLoginInput,
+ type HeicodeLoginProviderInfo,
+ type HeicodeLoginResult,
+} from '../api/heicodeAuth'
+
+const OAUTH_POLL_INTERVAL_MS = 2_000
+const OAUTH_POLL_TIMEOUT_MS = 5 * 60_000
+
+type HeicodeAuthState = {
+ status: HeicodeAuthStatus | null
+ providers: HeicodeLoginProviderInfo[]
+ hasFetched: boolean
+ isLoading: boolean
+ isLoggingIn: boolean
+ error: string | null
+
+ fetch: () => Promise
+ refreshStatus: () => Promise
+ loginWithApiKey: (input: HeicodeLoginInput) => Promise
+ startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }>
+ startOAuthPolling: () => void
+ stopOAuthPolling: () => void
+ logout: () => Promise
+ clearError: () => void
+}
+
+export const useHeicodeAuthStore = create((set, get) => {
+ let pollTimer: ReturnType | null = null
+ let pollDeadline = 0
+
+ const stopPolling = () => {
+ if (pollTimer) {
+ clearTimeout(pollTimer)
+ pollTimer = null
+ }
+ }
+
+ return {
+ status: null,
+ providers: [],
+ hasFetched: false,
+ isLoading: false,
+ isLoggingIn: false,
+ error: null,
+
+ fetch: async () => {
+ set({ isLoading: true, error: null })
+ try {
+ const [{ providers }, status] = await Promise.all([
+ heicodeAuthApi.listProviders(),
+ heicodeAuthApi.status(),
+ ])
+ set({ providers, status, hasFetched: true, isLoading: false })
+ } catch (err) {
+ set({
+ isLoading: false,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ },
+
+ refreshStatus: async () => {
+ try {
+ const status = await heicodeAuthApi.status()
+ set({ status })
+ } catch (err) {
+ set({ error: err instanceof Error ? err.message : String(err) })
+ }
+ },
+
+ loginWithApiKey: async (input) => {
+ set({ isLoggingIn: true, error: null })
+ try {
+ const result = await heicodeAuthApi.loginWithApiKey(input)
+ // 登录成功后立即刷新状态。
+ const status = await heicodeAuthApi.status()
+ set({ isLoggingIn: false, status })
+ return result
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ set({ isLoggingIn: false, error: message })
+ throw err
+ }
+ },
+
+ startOAuth: async (providerId) => {
+ set({ isLoggingIn: true, error: null })
+ try {
+ const res = await heicodeAuthApi.startOAuth(providerId)
+ set({ isLoggingIn: false })
+ return { authorizeUrl: res.authorizeUrl }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ set({ isLoggingIn: false, error: message })
+ throw err
+ }
+ },
+
+ startOAuthPolling: () => {
+ stopPolling()
+ pollDeadline = Date.now() + OAUTH_POLL_TIMEOUT_MS
+ const tick = async () => {
+ if (Date.now() > pollDeadline) {
+ stopPolling()
+ return
+ }
+ await get().refreshStatus()
+ if (get().status?.loggedIn) {
+ stopPolling()
+ return
+ }
+ pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS)
+ }
+ pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS)
+ },
+
+ stopOAuthPolling: () => stopPolling(),
+
+ logout: async () => {
+ set({ isLoading: true, error: null })
+ try {
+ await heicodeAuthApi.logout()
+ const status = await heicodeAuthApi.status()
+ set({ isLoading: false, status })
+ } catch (err) {
+ set({
+ isLoading: false,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ throw err
+ }
+ },
+
+ clearError: () => set({ error: null }),
+ }
+})
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..d5b02d68
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,55 @@
+# HeiCode docker stack
+#
+# Usage:
+# docker compose up -d --build
+# curl http://127.0.0.1:3456/health
+# curl http://127.0.0.1:3456/api/heicode-auth/providers
+#
+# This compose joins the existing `new-api_new-api-network` so HeiCode can
+# reach the local new-api gateway directly via http://new-api:3000
+# (no need to expose new-api on the host LAN).
+
+services:
+ heicode-server:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: heicode-server:latest
+ container_name: heicode-server
+ restart: unless-stopped
+ ports:
+ - "3456:3456"
+ environment:
+ SERVER_PORT: "3456"
+ SERVER_HOST: "0.0.0.0"
+ # Auth on the *server* itself is off in dev so the desktop UI can talk
+ # to it without a bearer. Set to "1" in any non-trivial deployment.
+ SERVER_AUTH_REQUIRED: "0"
+ CLAUDE_CONFIG_DIR: "/data/.claude"
+ # When you want HeiCode to default to the local new-api gateway
+ # instead of TaijiAICloud / ClawdRouter, point ANTHROPIC_BASE_URL at
+ # the in-network address. The auth flow can override this at runtime.
+ # ANTHROPIC_BASE_URL: "http://new-api:3000"
+ # ANTHROPIC_AUTH_TOKEN: ""
+ volumes:
+ - heicode-data:/data
+ networks:
+ - default
+ - new-api-network
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3456/health"]
+ interval: 15s
+ timeout: 5s
+ retries: 6
+ start_period: 20s
+
+volumes:
+ heicode-data:
+
+networks:
+ # `default` is automatic; we declare it only so we can list `new-api-network`
+ # alongside it without accidentally dropping the default.
+ default:
+ new-api-network:
+ external: true
+ name: new-api_new-api-network
diff --git a/docs/HEICODE-PLAN.md b/docs/HEICODE-PLAN.md
new file mode 100644
index 00000000..c8be9eec
--- /dev/null
+++ b/docs/HEICODE-PLAN.md
@@ -0,0 +1,191 @@
+# HeiCode 产品化沉淀文档
+
+> 这份文档把"为什么这么改、改了什么、还差什么"沉淀下来,**作为后续开发与平台对接的唯一权威**。
+
+---
+
+## 一、产品定位
+
+**HeiCode = 开箱即用的 Claude Code**,用户登录平台账号后直接用,不接触 API Key。
+
+| 维度 | 设计 |
+|---|---|
+| 用户 | 个人开发者 |
+| 形态 | Claude Code 桌面客户端 + CLI |
+| 模型来源 | TaijiAICloud(自建网关,基于 new-api) + ClawdRouter(聚合网关) |
+| 计费 | 完全在平台侧(不在 HeiCode 内做任何计费) |
+| 中台 | **不需要**。客户端 → 平台直连。|
+| 私有化 | **不做**。HeiCode 只对外销售客户端,平台是你公司运营的 SaaS。|
+
+---
+
+## 二、用户登录流程(最终形态)
+
+```
+启动 HeiCode
+ ↓
+看到登录页(仅 2 个入口,无第三选项):
+ ┌───────────────────────┐ ┌──────────────────────┐
+ │ TaijiAICloud │ │ ClawdRouter │
+ │ • 浏览器跳转 OAuth │ │ • 浏览器跳转 OAuth │
+ │ • 粘贴 API Key │ │ • 粘贴 API Key │
+ └───────────────────────┘ └──────────────────────┘
+ ↓
+登录成功
+ ↓
+HeiCode 自动 /v1/models 拉模型列表 → 注入 ANTHROPIC_DEFAULT_*_MODEL
+ ↓
+正常使用 Claude Code TUI / 桌面端
+```
+
+---
+
+## 三、已完成(S0 阶段)
+
+### 1. 品牌剥离
+- [x] `package.json` `name` → `heicode` (`bin: heicode + claude-haha 兼容)
+- [x] `bin/heicode` 新可执行入口;`bin/claude-haha` 改为兼容 shim
+- [x] `desktop/package.json` `name` → `heicode-desktop`
+- [x] `desktop/src-tauri/Cargo.toml` `name` → `heicode-desktop`,`lib.name` → `heicode_desktop_lib`
+- [x] `desktop/src-tauri/src/main.rs` 调用更新
+- [x] `desktop/src-tauri/tauri.conf.json` `productName: HeiCode`,`identifier: com.heicode.desktop`,updater endpoint 清空(避免误连上游 release)
+- [x] `desktop/src-tauri/tauri.macos.conf.json` / `tauri.windows.conf.json` 窗口标题改为 HeiCode
+- [x] `desktop/src-tauri/windows-installer-hooks.nsh` 卸载钩子兼容 `heicode-desktop.exe`
+
+### 2. Provider 预设重构
+- [x] `src/server/config/providerPresets.json` **仅保留**:
+ - `official`(保留为内部占位,使原 `activateOfficial()` 调用不破)
+ - `taijiaicloud`(featured)
+ - `clawdrouter`(featured)
+- 第三方厂商预设(DeepSeek / Kimi / MiniMax 等)全部移除。
+
+### 3. 模型自动发现
+- [x] `ProviderService.fetchProviderModels()`:
+ - 优先 `GET /v1/models`
+ - 兼容 `GET /models`
+ - 同时带 `Authorization: Bearer` 和 `x-api-key`,兼容两种平台
+ - 智能解析 `data` / `models` / 数组三种返回结构
+- [x] `GET /api/providers/:id/models` — 已保存的 Provider 拉模型
+- [x] `POST /api/providers/models` — 临时 baseUrl + apiKey 拉模型(登录前用)
+
+### 4. 双 Provider 登录后端
+- [x] 新建 `src/server/api/heicode-auth.ts`,路由挂在 `/api/heicode-auth/*`
+- [x] `GET /api/heicode-auth/providers` — 列 2 个登录入口(含 OAuth 启用状态)
+- [x] `POST /api/heicode-auth/login` — 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活
+- [x] `GET /api/heicode-auth/status` — 当前登录态
+- [x] `POST /api/heicode-auth/logout` — 登出
+- [x] `POST /api/heicode-auth/oauth/start` — OAuth 启动(**占位**)
+- [x] `GET /api/heicode-auth/oauth/callback` — OAuth 回跳(**占位**)
+
+### 5. 文档
+- [x] 根 `README.md` 重写为 HeiCode 中文版
+- [x] 本文件(`docs/HEICODE-PLAN.md`)
+
+---
+
+## 四、待完成
+
+### S1 阶段(前端 UI 层)
+
+#### 桌面端
+- [ ] 替换现有 `desktop/src/pages/Settings.tsx` 的 Provider 区域为「双卡片登录」UI
+- [ ] 卡片内:① 浏览器登录按钮(disabled 直到 OAuth 就绪) ② 粘贴 API Key 表单(直接调 `/api/heicode-auth/login`)
+- [ ] 登录成功后:自动跳到模型选择页 → 调 `GET /api/providers/:id/models` 渲染下拉
+- [ ] 顶部状态栏显示当前 Provider + 模型 + (未来)配额
+
+#### CLI(Ink TUI)
+- [ ] 替换 `src/components/Onboarding.tsx` 中的 OAuth 步骤为 HeiCode 登录
+- [ ] 把 `ConsoleOAuthFlow` 替换为 `HeicodeProviderPicker` 组件(双卡片)
+- [ ] `src/commands/login/login.tsx` 同步替换
+
+> ⚠️ CLI 部分的限制:当前 `src/main.tsx` 是上游 805KB 预构建包,这块 UI 改动需要从源头改并重新走 build pipeline。**第一阶段建议把 CLI 的 onboarding 标记成"先用 desktop 完成登录,CLI 自动读取已登录态"**,等真正打 release 时再把 CLI UI 重 build。
+
+### S2 阶段(OAuth 真实接入)
+
+等 TaijiAICloud / ClawdRouter 平台开放 OAuth 端点后,把 `handleOAuthStart` / `handleOAuthCallback` 占位实装:
+
+需要平台提供(OAuth2 Authorization Code + PKCE):
+1. **Authorize endpoint**:`GET /oauth/authorize?response_type=code&client_id=heicode-desktop&redirect_uri=...&code_challenge=...&state=...&scope=models:read,messages:write`
+2. **Token endpoint**:`POST /oauth/token` 接受 `grant_type=authorization_code&code=...&code_verifier=...&client_id=...`
+3. **(可选) Refresh endpoint**:`grant_type=refresh_token`
+4. **Redirect URI 白名单**:允许 `http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId=` 中的 127.0.0.1 任意端口(参考 Claude Code / GitHub CLI 做法)
+
+实装位置:`src/server/api/heicode-auth.ts` 中的 `handleOAuthStart` / `handleOAuthCallback`,可以参考已存在的 `src/server/services/hahaOAuthService.ts`(Claude.ai OAuth 实现)抽取通用逻辑。
+
+### S3 阶段(差异化)
+
+- [ ] **配额状态栏**:拉平台 `/v1/usage` 或 new-api `/api/quota/`
+- [ ] **多模型快捷切换**:顶部下拉选模型(已具备 `/api/providers/:id/models`)
+- [ ] **Taiji Agent 工具融合**:通过 MCP 把 Taiji Agent 的工具挂载进 HeiCode
+
+---
+
+## 五、给 TaijiAICloud / ClawdRouter 的对接 SOW
+
+### TaijiAICloud(基于 new-api)
+
+| # | 资产 / 接口 | 状态 | 说明 |
+|---|---|---|---|
+| 1 | `/v1/messages` Anthropic 原生协议 | **必须确认** | new-api 配置项: 启用 Claude Messages 转发 |
+| 2 | `/v1/models` | 应已就绪 | new-api 默认提供 |
+| 3 | OAuth2 Authorization Code + PKCE | **待开发** | 见 S2 阶段 |
+| 4 | 子 Token 管理(限定 model 白名单 / 每日额度 / 过期) | new-api 自带 | 平台直接复用 |
+| 5 | 调用日志 / 审计(≥180天) | new-api 自带 | `/api/log/` |
+| 6 | (可选) Webhook 额度告警 | 待开发 | new-api 原生没有 |
+
+### ClawdRouter
+
+| # | 资产 / 接口 | 状态 | 说明 |
+|---|---|---|---|
+| 1 | `/v1/messages` Anthropic 原生协议 | ✅ | [文档](https://www.clawdrouter.com/docs/) |
+| 2 | `/v1/chat/completions` OpenAI 协议 | ✅ | 同上 |
+| 3 | `/v1/models` | **待确认** | 文档未明示,需要平台确认或补 |
+| 4 | OAuth2 端点 | **待开发** | 见 S2 阶段 |
+| 5 | 管理面 API(创建/吊销子 Key、用量查询) | 待开发 | 用于 HeiCode 自动颁发 token |
+| 6 | `Request-Id` 自定义头 | ✅ | HeiCode 调用时带 `Request-Id: ::` 做归属审计 |
+
+---
+
+## 六、目录与命令速查
+
+```
+# 启动桌面端联调
+cd cc-haha
+bun install
+SERVER_PORT=3456 bun run src/server/index.ts &
+cd desktop && bun run dev --host 127.0.0.1 --port 2024
+
+# 测登录后端 (粘贴 token 模式)
+curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \
+ -H 'Content-Type: application/json' \
+ -d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}'
+
+curl http://127.0.0.1:3456/api/heicode-auth/status
+```
+
+---
+
+## 七、变更清单(git 友好)
+
+```
+新增:
+ src/server/api/heicode-auth.ts ← 双 Provider 登录后端
+ bin/heicode ← 新 CLI 入口
+ README.md ← 完全重写
+ docs/HEICODE-PLAN.md ← 本文件
+
+修改:
+ package.json ← name: heicode
+ bin/claude-haha ← 改为兼容 shim
+ src/server/api/providers.ts ← 加 /models 端点
+ src/server/services/providerService.ts ← 加 fetchProviderModels
+ src/server/router.ts ← 注册 heicode-auth 路由
+ src/server/config/providerPresets.json ← 重写为 2 个 preset
+ desktop/package.json ← name: heicode-desktop
+ desktop/src-tauri/Cargo.toml ← name + lib.name
+ desktop/src-tauri/src/main.rs ← lib 名同步
+ desktop/src-tauri/tauri.conf.json ← productName + identifier + updater 清空
+ desktop/src-tauri/tauri.macos.conf.json ← 窗口标题
+ desktop/src-tauri/tauri.windows.conf.json ← 窗口标题
+ desktop/src-tauri/windows-installer-hooks.nsh ← 进程名兼容
+```
diff --git a/package.json b/package.json
index 575fff06..2ccbb67f 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,16 @@
{
- "name": "claude-code-local",
- "version": "999.0.0-local",
+ "name": "heicode",
+ "version": "0.1.0",
"private": true,
"type": "module",
"bin": {
- "claude-haha": "./bin/claude-haha"
+ "heicode": "./bin/heicode",
+ "claude-haha": "./bin/heicode"
},
"scripts": {
- "claude-haha": "bun run ./bin/claude-haha",
- "start": "bun run ./bin/claude-haha",
+ "heicode": "bun run ./bin/heicode",
+ "claude-haha": "bun run ./bin/heicode",
+ "start": "bun run ./bin/heicode",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
diff --git a/src/server/api/heicode-auth.ts b/src/server/api/heicode-auth.ts
new file mode 100644
index 00000000..aa4b14b6
--- /dev/null
+++ b/src/server/api/heicode-auth.ts
@@ -0,0 +1,290 @@
+/**
+ * HeiCode Auth API — TaijiAICloud / ClawdRouter 双 Provider 登录入口。
+ *
+ * 设计目标:
+ * - 用户启动 HeiCode 后看到 2 个登录卡片 (TaijiAICloud / ClawdRouter)。
+ * - 每张卡片提供两种登录方式:
+ * A. 浏览器跳转 OAuth (推荐) —— 平台支持时启用,本端零改动。
+ * B. 粘贴 API Key (兼容入口) —— 立即可用,不依赖平台改造。
+ * - 登录成功后, HeiCode 自动调用 /v1/models 拉模型列表, 让用户挑默认模型。
+ *
+ * 路由:
+ * GET /api/heicode-auth/providers
+ * — 返回 2 个登录入口的元数据 + 当前 OAuth 是否就绪。
+ * POST /api/heicode-auth/login
+ * — { providerId, apiKey, displayName? } → 校验 → 拉模型 → 保存为 SavedProvider → 激活。
+ * POST /api/heicode-auth/oauth/start
+ * — { providerId } → 返回 authorize URL (调起本端 callback listener)。 [STUB]
+ * GET /api/heicode-auth/oauth/callback
+ * — 平台回跳: code + state → 换 token → 保存为 SavedProvider → 激活。 [STUB]
+ * GET /api/heicode-auth/status
+ * — 当前是否登录 / 哪个 Provider / 模型预览。
+ * POST /api/heicode-auth/logout
+ * — 清掉当前 active provider (不删除 saved 列表里的记录)。
+ *
+ * NOTE: OAuth 部分目前是占位实现,等 TaijiAICloud / ClawdRouter 平台开放
+ * OAuth2 Authorization Code + PKCE 端点之后再补。
+ * 平台需要提供:
+ * 1. authorize endpoint (浏览器登录页面)
+ * 2. token endpoint (code → access_token)
+ * 3. (可选) refresh endpoint
+ * 约定 redirect_uri = http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId=
+ */
+
+import { z } from 'zod'
+import { ProviderService } from '../services/providerService.js'
+import { PROVIDER_PRESETS } from '../config/providerPresets.js'
+import { ApiError, errorResponse } from '../middleware/errorHandler.js'
+
+const providerService = new ProviderService()
+
+const SUPPORTED_LOGIN_PROVIDER_IDS = ['taijiaicloud', 'clawdrouter'] as const
+type SupportedLoginProviderId = (typeof SUPPORTED_LOGIN_PROVIDER_IDS)[number]
+
+function isSupportedLoginProvider(id: string): id is SupportedLoginProviderId {
+ return (SUPPORTED_LOGIN_PROVIDER_IDS as readonly string[]).includes(id)
+}
+
+const LoginRequestSchema = z.object({
+ providerId: z.enum(['taijiaicloud', 'clawdrouter']),
+ apiKey: z.string().min(8, 'API Key 长度过短'),
+ displayName: z.string().min(1).optional(),
+})
+
+const OAuthStartSchema = z.object({
+ providerId: z.enum(['taijiaicloud', 'clawdrouter']),
+})
+
+export async function handleHeicodeAuthApi(
+ req: Request,
+ url: URL,
+ segments: string[],
+): Promise {
+ try {
+ const action = segments[2]
+ const subAction = segments[3]
+
+ // GET /api/heicode-auth/providers
+ if (action === 'providers' && req.method === 'GET') {
+ return Response.json({ providers: listLoginProviders() })
+ }
+
+ // POST /api/heicode-auth/login
+ if (action === 'login' && req.method === 'POST') {
+ return await handleLoginWithApiKey(req)
+ }
+
+ // GET /api/heicode-auth/status
+ if (action === 'status' && req.method === 'GET') {
+ const status = await providerService.checkAuthStatus()
+ const { providers, activeId } = await providerService.listProviders()
+ const active = activeId ? providers.find(p => p.id === activeId) : null
+ return Response.json({
+ loggedIn: status.hasAuth,
+ source: status.source,
+ activeProvider: active
+ ? {
+ id: active.id,
+ presetId: active.presetId,
+ name: active.name,
+ baseUrl: active.baseUrl,
+ models: active.models,
+ }
+ : null,
+ })
+ }
+
+ // POST /api/heicode-auth/logout
+ if (action === 'logout' && req.method === 'POST') {
+ await providerService.activateOfficial() // clears active provider
+ return Response.json({ ok: true })
+ }
+
+ // /api/heicode-auth/oauth/*
+ if (action === 'oauth') {
+ if (subAction === 'start' && req.method === 'POST') {
+ return await handleOAuthStart(req)
+ }
+ if (subAction === 'callback' && req.method === 'GET') {
+ return await handleOAuthCallback(url)
+ }
+ throw notFound()
+ }
+
+ throw notFound()
+ } catch (err) {
+ return errorResponse(err)
+ }
+}
+
+// ─── Login helpers ─────────────────────────────────────────────
+
+type LoginProviderInfo = {
+ id: SupportedLoginProviderId
+ name: string
+ baseUrl: string
+ websiteUrl: string
+ apiKeyUrl?: string
+ promoText?: string
+ defaultModels: { main: string; haiku: string; sonnet: string; opus: string }
+ /** 是否已配置 OAuth(占位,等平台支持后改为 true) */
+ oauthEnabled: boolean
+ /** OAuth 启动入口(前端按它跳就行);oauthEnabled=false 时为 null */
+ oauthStartUrl: string | null
+}
+
+function listLoginProviders(): LoginProviderInfo[] {
+ return SUPPORTED_LOGIN_PROVIDER_IDS.map(id => {
+ const preset = PROVIDER_PRESETS.find(p => p.id === id)
+ if (!preset) {
+ throw ApiError.internal(`Missing preset for login provider: ${id}`)
+ }
+ return {
+ id,
+ name: preset.name,
+ baseUrl: preset.baseUrl,
+ websiteUrl: preset.websiteUrl,
+ apiKeyUrl: preset.apiKeyUrl,
+ promoText: preset.promoText,
+ defaultModels: preset.defaultModels,
+ // TODO: 等平台支持 OAuth 后改为 true 并提供 oauthStartUrl
+ oauthEnabled: false,
+ oauthStartUrl: null,
+ }
+ })
+}
+
+async function handleLoginWithApiKey(req: Request): Promise {
+ const body = await parseJsonBody(req)
+ const parsed = LoginRequestSchema.safeParse(body)
+ if (!parsed.success) {
+ throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; '))
+ }
+ const { providerId, apiKey, displayName } = parsed.data
+
+ if (!isSupportedLoginProvider(providerId)) {
+ throw ApiError.badRequest(`Unsupported provider: ${providerId}`)
+ }
+
+ const preset = PROVIDER_PRESETS.find(p => p.id === providerId)
+ if (!preset) {
+ throw ApiError.internal(`Missing preset for provider: ${providerId}`)
+ }
+
+ // 1. 用 /v1/models 探活 + 拉模型列表
+ const probe = await providerService.fetchProviderModels({
+ baseUrl: preset.baseUrl,
+ apiKey,
+ apiFormat: preset.apiFormat,
+ })
+
+ if (probe.models.length === 0) {
+ throw ApiError.badRequest(
+ `校验失败: ${probe.error ?? '该 API Key 在 ' + preset.name + ' 上无可用模型'}`,
+ )
+ }
+
+ // 2. 把当前用户已经存过的同 preset provider 找出来,有则更新,没有则创建
+ const existing = await providerService.listProviders()
+ const sameProvider = existing.providers.find(p => p.presetId === preset.id)
+
+ // 选择默认模型: 优先用 preset 里指定的; 如果不在返回列表里,退回到第一个返回的模型
+ const availableIds = new Set(probe.models.map(m => m.id))
+ const pick = (preferred: string): string => {
+ if (preferred && availableIds.has(preferred)) return preferred
+ return probe.models[0]?.id ?? preferred
+ }
+ const models = {
+ main: pick(preset.defaultModels.main),
+ haiku: pick(preset.defaultModels.haiku),
+ sonnet: pick(preset.defaultModels.sonnet),
+ opus: pick(preset.defaultModels.opus),
+ }
+
+ let saved
+ if (sameProvider) {
+ saved = await providerService.updateProvider(sameProvider.id, {
+ name: displayName ?? sameProvider.name,
+ apiKey,
+ baseUrl: preset.baseUrl,
+ apiFormat: preset.apiFormat,
+ models,
+ })
+ } else {
+ saved = await providerService.addProvider({
+ presetId: preset.id,
+ name: displayName ?? preset.name,
+ apiKey,
+ baseUrl: preset.baseUrl,
+ apiFormat: preset.apiFormat,
+ models,
+ })
+ }
+
+ // 3. 激活成 active provider
+ await providerService.activateProvider(saved.id)
+
+ return Response.json({
+ ok: true,
+ provider: {
+ id: saved.id,
+ presetId: saved.presetId,
+ name: saved.name,
+ baseUrl: saved.baseUrl,
+ apiFormat: saved.apiFormat,
+ models: saved.models,
+ },
+ availableModels: probe.models,
+ })
+}
+
+// ─── OAuth scaffold (TODO: 平台支持后实装) ──────────────────────
+
+async function handleOAuthStart(req: Request): Promise {
+ const body = await parseJsonBody(req)
+ const parsed = OAuthStartSchema.safeParse(body)
+ if (!parsed.success) {
+ throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; '))
+ }
+ // 占位 —— 等 TaijiAICloud / ClawdRouter 提供 OAuth2 Authorization endpoint 后实装。
+ // 实装思路:
+ // 1. 生成 PKCE codeVerifier + state, 缓存到 in-memory session map
+ // 2. 拼出 authorizeUrl = `${platform_authorize_endpoint}?response_type=code&client_id=...
+ // &redirect_uri=http://127.0.0.1:/api/heicode-auth/oauth/callback?providerId=${providerId}
+ // &code_challenge=...&code_challenge_method=S256&state=...`
+ // 3. 返回 { authorizeUrl, state }
+ // 4. 前端用 shell::open 打开 authorizeUrl
+ throw ApiError.badRequest(
+ `${parsed.data.providerId} 暂未启用 OAuth,请先用「粘贴 API Key」登录。OAuth 功能将在平台支持后开放。`,
+ )
+}
+
+async function handleOAuthCallback(_url: URL): Promise {
+ // 占位 —— 配合 handleOAuthStart 使用。流程:
+ // 1. 校验 state → 取出 codeVerifier
+ // 2. 调平台 token endpoint: code + codeVerifier → access_token + refresh_token
+ // 3. 用拿到的 access_token 调 /v1/models 验证 + 保存为 SavedProvider + 激活
+ // 4. 返回成功页面给浏览器, 关闭 tab
+ return new Response(
+ `
+ OAuth 回跳通道未启用
+ 请先在 HeiCode 中使用「粘贴 API Key」方式登录。
+ OAuth 功能将在 TaijiAICloud / ClawdRouter 平台支持后开放。
+ `,
+ { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
+ )
+}
+
+// ─── Helpers ───────────────────────────────────────────────────
+
+async function parseJsonBody(req: Request): Promise> {
+ try {
+ return (await req.json()) as Record
+ } catch {
+ throw ApiError.badRequest('Invalid JSON body')
+ }
+}
+
+function notFound(): ApiError {
+ return new ApiError(404, 'Not Found', 'NOT_FOUND')
+}
diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts
index 2c574f5b..b36f3f63 100644
--- a/src/server/api/providers.ts
+++ b/src/server/api/providers.ts
@@ -13,6 +13,8 @@
* POST /api/providers/official — activate official (clear env)
* POST /api/providers/:id/test — test a saved provider
* POST /api/providers/test — test unsaved config
+ * GET /api/providers/:id/models — fetch /v1/models for a saved provider
+ * POST /api/providers/models — fetch /v1/models for ad-hoc baseUrl + apiKey (used during login flow)
*/
import { z } from 'zod'
@@ -41,6 +43,11 @@ export async function handleProvidersApi(
return await handleTestUnsaved(req)
}
+ // POST /api/providers/models — fetch models given baseUrl + apiKey (used by login flow)
+ if (id === 'models' && req.method === 'POST') {
+ return await handleFetchModelsUnsaved(req)
+ }
+
// GET /api/providers/presets
if (id === 'presets' && req.method === 'GET') {
return Response.json({ presets: PROVIDER_PRESETS })
@@ -102,6 +109,18 @@ export async function handleProvidersApi(
return Response.json({ result })
}
+ // GET /api/providers/:id/models — fetch /v1/models using saved credentials
+ if (action === 'models') {
+ if (req.method !== 'GET') throw methodNotAllowed(req.method)
+ const provider = await providerService.getProvider(id)
+ const result = await providerService.fetchProviderModels({
+ baseUrl: provider.baseUrl,
+ apiKey: provider.apiKey,
+ apiFormat: provider.apiFormat,
+ })
+ return Response.json(result)
+ }
+
// /api/providers/:id
if (req.method === 'GET') {
const provider = await providerService.getProvider(id)
@@ -157,6 +176,24 @@ async function handleTestUnsaved(req: Request): Promise {
}
}
+const FetchModelsSchema = z.object({
+ baseUrl: z.string().url(),
+ apiKey: z.string().min(1),
+ apiFormat: z.enum(['anthropic', 'openai_chat', 'openai_responses']).optional(),
+})
+
+async function handleFetchModelsUnsaved(req: Request): Promise {
+ const body = await parseJsonBody(req)
+ try {
+ const input = FetchModelsSchema.parse(body)
+ const result = await providerService.fetchProviderModels(input)
+ return Response.json(result)
+ } catch (err) {
+ if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
+ throw err
+ }
+}
+
async function parseJsonBody(req: Request): Promise> {
try {
return (await req.json()) as Record
diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json
index f47c2395..f495d4f1 100644
--- a/src/server/config/providerPresets.json
+++ b/src/server/config/providerPresets.json
@@ -14,156 +14,45 @@
"websiteUrl": "https://www.anthropic.com/claude-code"
},
{
- "id": "deepseek",
- "name": "DeepSeek",
- "baseUrl": "https://api.deepseek.com/anthropic",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "deepseek-v4-pro",
- "haiku": "deepseek-v4-flash",
- "sonnet": "deepseek-v4-pro",
- "opus": "deepseek-v4-pro"
- },
- "needsApiKey": true,
- "websiteUrl": "https://platform.deepseek.com",
- "apiKeyUrl": "https://platform.deepseek.com/api_keys"
- },
- {
- "id": "zhipuglm",
- "name": "Zhipu GLM",
- "baseUrl": "https://open.bigmodel.cn/api/anthropic",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "glm-5.1",
- "haiku": "glm-4.5-air",
- "sonnet": "glm-5-turbo",
- "opus": "glm-5.1"
- },
- "needsApiKey": true,
- "websiteUrl": "https://open.bigmodel.cn",
- "apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D",
- "promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。"
- },
- {
- "id": "kimi",
- "name": "Kimi",
- "baseUrl": "https://api.kimi.com/coding",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "kimi-k2.6",
- "haiku": "kimi-k2.6",
- "sonnet": "kimi-k2.6",
- "opus": "kimi-k2.6"
- },
- "needsApiKey": true,
- "websiteUrl": "https://platform.moonshot.cn",
- "apiKeyUrl": "https://platform.kimi.com/console/api-keys"
- },
- {
- "id": "minimax",
- "name": "MiniMax",
- "baseUrl": "https://api.minimaxi.com/anthropic",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "MiniMax-M2.7",
- "haiku": "MiniMax-M2.7",
- "sonnet": "MiniMax-M2.7",
- "opus": "MiniMax-M2.7"
- },
- "needsApiKey": true,
- "websiteUrl": "https://platform.minimaxi.com",
- "apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link"
- },
- {
- "id": "jiekouai",
- "name": "接口AI",
- "baseUrl": "https://api.jiekou.ai/anthropic",
+ "id": "taijiaicloud",
+ "name": "TaijiAICloud",
+ "baseUrl": "https://api.taijiaicloud.com",
"apiFormat": "anthropic",
"defaultModels": {
"main": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
"sonnet": "claude-sonnet-4-6",
- "opus": "claude-opus-4-7"
+ "opus": "claude-opus-4-6"
},
"needsApiKey": true,
- "websiteUrl": "https://jiekou.ai",
- "apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K",
- "promoText": "接口AI为 cc-haha 的用户提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。",
- "featured": true,
- "defaultEnv": {
- "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
- }
- },
- {
- "id": "shengsuanyun",
- "name": "胜算云",
- "baseUrl": "https://router.shengsuanyun.com/api",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "anthropic/claude-sonnet-4.6",
- "haiku": "anthropic/claude-haiku-4.5:thinking",
- "sonnet": "anthropic/claude-sonnet-4.6",
- "opus": "anthropic/claude-opus-4.7"
- },
- "needsApiKey": true,
- "websiteUrl": "https://www.shengsuanyun.com",
- "apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR",
- "promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
+ "websiteUrl": "https://api.taijiaicloud.com",
+ "apiKeyUrl": "https://api.taijiaicloud.com/dashboard/keys",
+ "promoText": "TaijiAICloud 是 HeiCode 推荐的国内 LLM 网关,原生支持 Anthropic /v1/messages 协议,覆盖 GPT / Claude / Gemini 等主流模型。",
"featured": true,
"defaultEnv": {
"API_TIMEOUT_MS": "3000000",
- "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
- "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
},
{
- "id": "lmstudio",
- "name": "LM Studio",
- "baseUrl": "http://localhost:1234",
+ "id": "clawdrouter",
+ "name": "ClawdRouter",
+ "baseUrl": "https://api.clawdrouter.com",
"apiFormat": "anthropic",
"defaultModels": {
- "main": "qwen/qwen3.6-27b",
- "haiku": "qwen/qwen3.6-27b",
- "sonnet": "qwen/qwen3.6-27b",
- "opus": "qwen/qwen3.6-27b"
- },
- "needsApiKey": false,
- "websiteUrl": "https://lmstudio.ai/docs/integrations/claude-code",
- "promoText": "LM Studio 使用 Anthropic 兼容协议,Base URL 填 http://localhost:1234,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
- "defaultEnv": {
- "ANTHROPIC_AUTH_TOKEN": "lmstudio"
- }
- },
- {
- "id": "ollama",
- "name": "Ollama",
- "baseUrl": "http://localhost:11434",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "qwen3.6:27b",
- "haiku": "qwen3.6:27b",
- "sonnet": "qwen3.6:27b",
- "opus": "qwen3.6:27b"
- },
- "needsApiKey": false,
- "websiteUrl": "https://docs.ollama.com/integrations/claude-code",
- "promoText": "Ollama 使用 Anthropic 兼容协议,Base URL 填 http://localhost:11434,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
- "defaultEnv": {
- "ANTHROPIC_AUTH_TOKEN": "ollama"
- }
- },
- {
- "id": "custom",
- "name": "Custom",
- "baseUrl": "",
- "apiFormat": "anthropic",
- "defaultModels": {
- "main": "",
- "haiku": "",
- "sonnet": "",
- "opus": ""
+ "main": "claude-sonnet-4-6",
+ "haiku": "claude-haiku-4-5-20251001",
+ "sonnet": "claude-sonnet-4-6",
+ "opus": "claude-opus-4-6"
},
"needsApiKey": true,
- "websiteUrl": ""
+ "websiteUrl": "https://www.clawdrouter.com",
+ "apiKeyUrl": "https://www.clawdrouter.com/dashboard/keys",
+ "promoText": "ClawdRouter 双协议聚合网关,同时支持 Anthropic /v1/messages 与 OpenAI /v1/chat/completions,覆盖 OpenAI / Anthropic / Google 全家桶。",
+ "featured": true,
+ "defaultEnv": {
+ "API_TIMEOUT_MS": "3000000",
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
+ }
}
]
diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts
index efe3ed7e..5518e299 100644
--- a/src/server/config/providerPresets.ts
+++ b/src/server/config/providerPresets.ts
@@ -32,4 +32,29 @@ const ProviderPresetsSchema = z.array(ProviderPresetSchema)
export type ModelMapping = z.infer
export type ProviderPreset = z.infer
-export const PROVIDER_PRESETS = ProviderPresetsSchema.parse(providerPresetsJson)
+/**
+ * Per-provider base URL overrides via env. This lets a developer point
+ * HeiCode at a *local* gateway (e.g. a self-hosted new-api on Docker)
+ * without editing the preset JSON file.
+ *
+ * Examples:
+ * HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000
+ * HEICODE_CLAWDROUTER_BASE_URL=http://localhost:4000
+ *
+ * The override is applied at module load time. Restart the server after
+ * changing these.
+ */
+const PROVIDER_BASE_URL_ENV_MAP: Record = {
+ taijiaicloud: 'HEICODE_TAIJIAICLOUD_BASE_URL',
+ clawdrouter: 'HEICODE_CLAWDROUTER_BASE_URL',
+}
+
+const parsedPresets = ProviderPresetsSchema.parse(providerPresetsJson)
+
+export const PROVIDER_PRESETS = parsedPresets.map((preset) => {
+ const envKey = PROVIDER_BASE_URL_ENV_MAP[preset.id]
+ if (!envKey) return preset
+ const override = process.env[envKey]?.trim()
+ if (!override) return preset
+ return { ...preset, baseUrl: override.replace(/\/+$/, '') }
+})
diff --git a/src/server/index.ts b/src/server/index.ts
index 5c53c0da..7e6816af 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -56,12 +56,17 @@ export function startServer(port = PORT, host = HOST) {
* Auth is required when explicitly opted in or when bound to a non-localhost address.
* - Default localhost dev: no auth needed (tests pass as-is).
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
- * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
+ * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
+ * - Explicit opt-out: SERVER_AUTH_REQUIRED=0 disables auth even on 0.0.0.0
+ * (use only in trusted environments — e.g. a Docker container whose port
+ * is published to host loopback only). Takes precedence over the auto rule.
*/
- const authRequired =
- SERVER_OPTIONS.authRequired ||
- process.env.SERVER_AUTH_REQUIRED === '1' ||
- host !== '127.0.0.1'
+ const authForcedOff = process.env.SERVER_AUTH_REQUIRED === '0'
+ const authRequired = authForcedOff
+ ? false
+ : SERVER_OPTIONS.authRequired ||
+ process.env.SERVER_AUTH_REQUIRED === '1' ||
+ host !== '127.0.0.1'
const server = Bun.serve({
port,
diff --git a/src/server/router.ts b/src/server/router.ts
index 87853152..6c17143b 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -18,6 +18,7 @@ import { handlePluginsApi } from './api/plugins.js'
import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
+import { handleHeicodeAuthApi } from './api/heicode-auth.js'
import { handleMcpApi } from './api/mcp.js'
export async function handleApiRequest(req: Request, url: URL): Promise {
@@ -72,6 +73,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise/v1/models` (OpenAI-compatible — what TaijiAICloud (new-api) and ClawdRouter both expose).
+ * 2. Fall back to `GET /models` (some Anthropic-flavoured proxies put it there).
+ * 3. If neither works, return an empty list — the UI should let the user enter a model id manually.
+ *
+ * Auth header is chosen based on `apiFormat`:
+ * - 'anthropic' → both `Authorization: Bearer` AND `x-api-key` (covers TaijiAICloud + ClawdRouter, harmless extras).
+ * - other → `Authorization: Bearer`.
+ */
+ async fetchProviderModels(input: {
+ baseUrl: string
+ apiKey: string
+ apiFormat?: ApiFormat
+ }): Promise<{ models: Array<{ id: string; owned_by?: string }>; source: string; error?: string }> {
+ const base = input.baseUrl.replace(/\/+$/, '')
+ if (!base) {
+ return { models: [], source: '', error: 'Missing baseUrl' }
+ }
+
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (input.apiKey) {
+ headers['Authorization'] = `Bearer ${input.apiKey}`
+ headers['x-api-key'] = input.apiKey
+ headers['anthropic-version'] = '2023-06-01'
+ }
+
+ const candidates = [`${base}/v1/models`, `${base}/models`]
+ let lastError: string | undefined
+
+ for (const url of candidates) {
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers,
+ signal: AbortSignal.timeout(15000),
+ })
+ if (!response.ok) {
+ lastError = `HTTP ${response.status} from ${url}`
+ continue
+ }
+ const body = (await response.json().catch(() => null)) as Record | null
+ const list = extractModelList(body)
+ if (list.length > 0) {
+ return { models: list, source: url }
+ }
+ lastError = `Empty/unrecognised response from ${url}`
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
+ lastError = `Timeout calling ${url}`
+ } else {
+ lastError = err instanceof Error ? err.message : String(err)
+ }
+ }
+ }
+
+ return { models: [], source: '', error: lastError ?? 'Unable to fetch models' }
+ }
+
async testProviderConfig(input: TestProviderInput): Promise {
const format: ApiFormat = input.apiFormat ?? 'anthropic'
const base = input.baseUrl.replace(/\/+$/, '')
@@ -571,6 +634,39 @@ function buildDirectTestRequest(
}
}
+/**
+ * Normalise responses from /v1/models (OpenAI-style { data: [{ id }] })
+ * and other shapes into a flat list of { id, owned_by? }.
+ */
+function extractModelList(body: unknown): Array<{ id: string; owned_by?: string }> {
+ if (!body || typeof body !== 'object') return []
+
+ const entries: unknown[] = Array.isArray((body as { data?: unknown }).data)
+ ? ((body as { data: unknown[] }).data)
+ : Array.isArray((body as { models?: unknown }).models)
+ ? ((body as { models: unknown[] }).models)
+ : Array.isArray(body)
+ ? (body as unknown[])
+ : []
+
+ const result: Array<{ id: string; owned_by?: string }> = []
+ for (const entry of entries) {
+ if (typeof entry === 'string') {
+ result.push({ id: entry })
+ continue
+ }
+ if (entry && typeof entry === 'object') {
+ const rec = entry as Record
+ const id = (rec.id ?? rec.model ?? rec.name) as string | undefined
+ if (typeof id === 'string' && id.length > 0) {
+ const owned = rec.owned_by as string | undefined
+ result.push(owned ? { id, owned_by: owned } : { id })
+ }
+ }
+ }
+ return result
+}
+
function validateResponseBody(
body: Record | null,
format: ApiFormat,