feat: add costrict login provider
This commit is contained in:
parent
ca0c3265e6
commit
d77963fb3f
|
|
@ -16,7 +16,8 @@ import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settin
|
|||
import { Select } from './CustomSelect/select.js'
|
||||
import { Spinner } from './Spinner.js'
|
||||
import TextInput from './TextInput.js'
|
||||
import { fi } from 'zod/v4/locales'
|
||||
import { ModelPicker } from './ModelPicker.js'
|
||||
import { useSetAppState } from '../state/AppState.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
|
|
@ -28,6 +29,14 @@ type Props = {
|
|||
type OAuthStatus =
|
||||
| { state: 'idle' } // Initial state, waiting to select login method
|
||||
| { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry)
|
||||
| {
|
||||
state: 'costrict_waiting'
|
||||
url: string
|
||||
} // CoStrict OAuth: browser opened, waiting for user to login
|
||||
| {
|
||||
state: 'costrict_model_select'
|
||||
models: Array<{ id: string; name?: string }>
|
||||
} // CoStrict: login done, select a model
|
||||
| {
|
||||
state: 'custom_platform'
|
||||
baseUrl: string
|
||||
|
|
@ -73,6 +82,7 @@ export function ConsoleOAuthFlow({
|
|||
mode = 'login',
|
||||
forceLoginMethod: forceLoginMethodProp,
|
||||
}: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod
|
||||
const orgUUID = settings.forceLoginOrgUUID
|
||||
|
|
@ -273,6 +283,8 @@ export function ConsoleOAuthFlow({
|
|||
}
|
||||
// Reset modelType to anthropic when using OAuth login
|
||||
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any)
|
||||
delete process.env.CLAUDE_CODE_USE_COSTRICT
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void sendNotification(
|
||||
|
|
@ -438,6 +450,7 @@ function OAuthStatusMessage({
|
|||
setLoginWithClaudeAi,
|
||||
onDone,
|
||||
}: OAuthStatusMessageProps): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
switch (oauthStatus.state) {
|
||||
case 'idle':
|
||||
return (
|
||||
|
|
@ -453,6 +466,16 @@ function OAuthStatusMessage({
|
|||
<Box>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
CoStrict ·{' '}
|
||||
<Text dimColor>Sign in with CoStrict account</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'costrict',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
|
|
@ -530,7 +553,70 @@ function OAuthStatusMessage({
|
|||
},
|
||||
]}
|
||||
onChange={value => {
|
||||
if (value === 'custom_platform') {
|
||||
if (value === 'costrict') {
|
||||
void (async () => {
|
||||
try {
|
||||
const { generateState, getCoStrictBaseURL, buildCoStrictLoginURL, pollLoginToken } = await import('../costrict/provider/auth.js')
|
||||
const { generateMachineId, saveCoStrictCredentials } = await import('../costrict/provider/credentials.js')
|
||||
const { extractExpiryFromJWT } = await import('../costrict/provider/token.js')
|
||||
const { updateSettingsForSource } = await import('../utils/settings/settings.js')
|
||||
|
||||
const baseUrl = getCoStrictBaseURL()
|
||||
const state = generateState()
|
||||
const machineId = generateMachineId()
|
||||
const loginUrl = buildCoStrictLoginURL(baseUrl, state, machineId)
|
||||
|
||||
// 打开浏览器(复用 csc 的 openBrowser,Windows 用 rundll32 url,OpenURL 避免 & 被 cmd.exe 截断)
|
||||
const { openBrowser } = await import('../utils/browser.js')
|
||||
await openBrowser(loginUrl)
|
||||
|
||||
setOAuthStatus({ state: 'costrict_waiting', url: loginUrl })
|
||||
|
||||
// 轮询等待登录完成
|
||||
const tokens = await pollLoginToken(baseUrl, state, machineId)
|
||||
|
||||
// 保存凭证
|
||||
const expiryDate = extractExpiryFromJWT(tokens.access_token)
|
||||
await saveCoStrictCredentials({
|
||||
id: 'csc',
|
||||
name: 'CSC Auth',
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
state,
|
||||
machine_id: machineId,
|
||||
base_url: baseUrl,
|
||||
expiry_date: expiryDate,
|
||||
updated_at: new Date().toISOString(),
|
||||
expired_at: expiryDate ? new Date(expiryDate).toISOString() : undefined,
|
||||
})
|
||||
|
||||
// 设置 modelType 为 costrict
|
||||
updateSettingsForSource('userSettings', { modelType: 'costrict' as any } as any)
|
||||
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
|
||||
|
||||
// 预取模型列表,填充同步缓存,并进入模型选择界面
|
||||
try {
|
||||
const { fetchCoStrictModels } = await import('../costrict/provider/models.js')
|
||||
const models = await fetchCoStrictModels(baseUrl, tokens.access_token)
|
||||
if (models.length > 0) {
|
||||
setOAuthStatus({ state: 'costrict_model_select', models })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 预取失败,直接进入 success
|
||||
}
|
||||
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
} catch (err: any) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: err.message || String(err),
|
||||
toRetry: { state: 'idle' },
|
||||
})
|
||||
}
|
||||
})()
|
||||
} else if (value === 'custom_platform') {
|
||||
logEvent('tengu_custom_platform_selected', {})
|
||||
setOAuthStatus({
|
||||
state: 'custom_platform',
|
||||
|
|
@ -696,10 +782,12 @@ function OAuthStatusMessage({
|
|||
})
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
||||
delete process.env.CLAUDE_CODE_USE_COSTRICT
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
|
||||
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone, setAppState])
|
||||
|
||||
const handleEnter = useCallback(() => {
|
||||
const idx = FIELDS.indexOf(activeField)
|
||||
|
|
@ -916,10 +1004,12 @@ function OAuthStatusMessage({
|
|||
})
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
||||
delete process.env.CLAUDE_CODE_USE_COSTRICT
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
|
||||
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone, setAppState])
|
||||
|
||||
const handleOpenAIEnter = useCallback(() => {
|
||||
const idx = OPENAI_FIELDS.indexOf(activeField)
|
||||
|
|
@ -1149,10 +1239,12 @@ function OAuthStatusMessage({
|
|||
})
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
||||
delete process.env.CLAUDE_CODE_USE_COSTRICT
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
|
||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus, setAppState])
|
||||
|
||||
const handleGeminiEnter = useCallback(() => {
|
||||
const idx = GEMINI_FIELDS.indexOf(activeField)
|
||||
|
|
@ -1275,6 +1367,44 @@ function OAuthStatusMessage({
|
|||
)
|
||||
}
|
||||
|
||||
case 'costrict_waiting':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Opening browser for CoStrict login. If it does not open
|
||||
automatically, copy and paste this URL:
|
||||
</Text>
|
||||
<Box marginY={1}>
|
||||
<Text color="cyan">{oauthStatus.url}</Text>
|
||||
</Box>
|
||||
<Text dimColor>Waiting for authentication...</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
case 'costrict_model_select': {
|
||||
const sortedModels = [...oauthStatus.models].sort((a, b) => a.id.localeCompare(b.id))
|
||||
return (
|
||||
<ModelPicker
|
||||
initial={sortedModels[0]?.id ?? null}
|
||||
headerText="Login successful. Select a CoStrict model to use:"
|
||||
onSelect={(model) => {
|
||||
const selected = model ?? sortedModels[0]?.id ?? ''
|
||||
process.env.COSTRICT_MODEL = selected
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}}
|
||||
onCancel={() => {
|
||||
const selected = sortedModels[0]?.id ?? ''
|
||||
process.env.COSTRICT_MODEL = selected
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'platform_setup':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
|
|
|
|||
191
src/costrict/provider/auth.ts
Normal file
191
src/costrict/provider/auth.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* CoStrict OAuth 登录流程模块
|
||||
* 负责浏览器登录、Token 轮询和登录 URL 构建
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
generateMachineId,
|
||||
saveCoStrictCredentials,
|
||||
type CoStrictCredentials,
|
||||
} from './credentials.js'
|
||||
import { extractExpiryFromJWT } from './token.js'
|
||||
import { buildOAuthParams } from './oauth-params.js'
|
||||
|
||||
/**
|
||||
* 生成随机 state 字符串 (格式: {12hex}.{12hex})
|
||||
*/
|
||||
export function generateState(): string {
|
||||
const part1 = randomBytes(6).toString('hex')
|
||||
const part2 = randomBytes(6).toString('hex')
|
||||
return `${part1}.${part2}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CoStrict Base URL
|
||||
* 优先级: 环境变量 > credentialsBaseUrl > 默认值
|
||||
*/
|
||||
export function getCoStrictBaseURL(credentialsBaseUrl?: string): string {
|
||||
const envUrl = process.env.COSTRICT_BASE_URL
|
||||
const defaultUrl = 'https://zgsm.sangfor.com'
|
||||
const baseUrl = envUrl || credentialsBaseUrl || defaultUrl
|
||||
return baseUrl.replace(/\/chat-rag\/api\/v1$/, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 CoStrict 登录 URL (初次登录包含 machine_code)
|
||||
*/
|
||||
export function buildCoStrictLoginURL(
|
||||
baseUrl: string,
|
||||
state: string,
|
||||
machineId: string,
|
||||
): string {
|
||||
const params = buildOAuthParams(true, machineId, state)
|
||||
const queryString = params
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&')
|
||||
return `${baseUrl}/oidc-auth/api/v1/plugin/login?${queryString}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询 Token 端点获取登录凭证 (轮询时保留 machine_code)
|
||||
*/
|
||||
export async function pollLoginToken(
|
||||
baseUrl: string,
|
||||
state: string,
|
||||
machineId: string,
|
||||
maxAttempts = 120,
|
||||
intervalMs = 5000,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ access_token: string; refresh_token: string }> {
|
||||
const params = buildOAuthParams(true, machineId, state)
|
||||
const queryString = params
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&')
|
||||
const url = `${baseUrl}/oidc-auth/api/v1/plugin/login/token?${queryString}`
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (abortSignal?.aborted) throw new Error('Login cancelled by user')
|
||||
|
||||
await sleep(intervalMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: abortSignal,
|
||||
})
|
||||
|
||||
if (!response.ok) continue
|
||||
|
||||
const result = (await response.json()) as any
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.message || result.error || 'Unknown error'
|
||||
if (
|
||||
errorMsg.includes('invalid') ||
|
||||
errorMsg.includes('expired') ||
|
||||
errorMsg.includes('failed')
|
||||
) {
|
||||
throw new Error(`Login failed: ${errorMsg}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!result.data?.access_token ||
|
||||
!result.data?.refresh_token ||
|
||||
result.data.access_token === '' ||
|
||||
result.data.refresh_token === ''
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.data.state !== state) continue
|
||||
|
||||
return {
|
||||
access_token: result.data.access_token,
|
||||
refresh_token: result.data.refresh_token,
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError' || error.message?.includes('aborted')) {
|
||||
throw new Error('Login cancelled')
|
||||
}
|
||||
if (error.message?.includes('Login failed')) throw error
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Login timeout after ${(maxAttempts * intervalMs) / 1000} seconds. Please try again.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的 CoStrict OAuth 登录流程
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 生成 state 和 machine_id
|
||||
* 2. 打开浏览器
|
||||
* 3. 轮询 Token 端点
|
||||
* 4. 保存凭证到 ~/.costrict/share/auth.json
|
||||
*/
|
||||
export async function loginCoStrict(
|
||||
openBrowser?: (url: string) => Promise<void>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<CoStrictCredentials> {
|
||||
const baseUrl = getCoStrictBaseURL()
|
||||
const state = generateState()
|
||||
const machineId = generateMachineId()
|
||||
const loginUrl = buildCoStrictLoginURL(baseUrl, state, machineId)
|
||||
|
||||
if (openBrowser) {
|
||||
await openBrowser(loginUrl)
|
||||
} else {
|
||||
const { spawn } = await import('node:child_process')
|
||||
const platform = process.platform
|
||||
let command: string
|
||||
let args: string[]
|
||||
|
||||
if (platform === 'darwin') {
|
||||
command = 'open'
|
||||
args = [loginUrl]
|
||||
} else if (platform === 'win32') {
|
||||
command = 'cmd.exe'
|
||||
args = ['/c', 'start', '', loginUrl]
|
||||
} else {
|
||||
command = 'xdg-open'
|
||||
args = [loginUrl]
|
||||
}
|
||||
|
||||
spawn(command, args, { detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
|
||||
const tokens = await pollLoginToken(
|
||||
baseUrl,
|
||||
state,
|
||||
machineId,
|
||||
120,
|
||||
5000,
|
||||
abortSignal,
|
||||
)
|
||||
|
||||
const expiryDate = extractExpiryFromJWT(tokens.access_token)
|
||||
const credentials: CoStrictCredentials = {
|
||||
id: 'csc',
|
||||
name: 'CSC Auth',
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
state,
|
||||
machine_id: machineId,
|
||||
base_url: baseUrl,
|
||||
expiry_date: expiryDate,
|
||||
updated_at: new Date().toISOString(),
|
||||
expired_at: expiryDate ? new Date(expiryDate).toISOString() : undefined,
|
||||
}
|
||||
|
||||
await saveCoStrictCredentials(credentials)
|
||||
return credentials
|
||||
}
|
||||
104
src/costrict/provider/credentials.ts
Normal file
104
src/costrict/provider/credentials.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* CoStrict 凭证管理模块
|
||||
* 负责读写 ~/.costrict/share/auth.json,与 CoStrict IDE 插件共享凭证
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* CoStrict 凭证格式 (与 IDE 插件兼容)
|
||||
*/
|
||||
export interface CoStrictCredentials {
|
||||
id: string // 标识符
|
||||
name: string // 显示名称
|
||||
access_token: string // OAuth 访问令牌
|
||||
refresh_token?: string // OAuth 刷新令牌 (可选)
|
||||
state?: string // OAuth 状态标识 (可选)
|
||||
machine_id: string // 机器唯一标识 (SHA256)
|
||||
base_url: string // CoStrict 服务器地址
|
||||
expiry_date: number // Token 过期时间戳 (毫秒)
|
||||
updated_at: string // 最后更新时间 (ISO 8601)
|
||||
expired_at?: string // Token 过期时间 (ISO 8601)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CoStrict 凭证文件路径
|
||||
* @returns ~/.costrict/share/auth.json
|
||||
*/
|
||||
export function getCoStrictCredentialsPath(): string {
|
||||
return join(homedir(), '.costrict', 'share', 'auth.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成机器唯一标识 (SHA256)
|
||||
* 基于平台、主机名、用户名
|
||||
*/
|
||||
export function generateMachineId(): string {
|
||||
const os = require('node:os')
|
||||
const platform = os.platform()
|
||||
const hostname = os.hostname()
|
||||
const username = os.userInfo().username
|
||||
const machineInfo = `${platform}-${hostname}-${username}`
|
||||
return createHash('sha256').update(machineInfo).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 CoStrict 凭证
|
||||
* @returns 凭证对象或 null
|
||||
*/
|
||||
export async function loadCoStrictCredentials(): Promise<CoStrictCredentials | null> {
|
||||
try {
|
||||
const filepath = getCoStrictCredentialsPath()
|
||||
const content = await fs.readFile(filepath, 'utf-8')
|
||||
const credentials = JSON.parse(content) as CoStrictCredentials
|
||||
|
||||
if (!credentials.access_token || !credentials.base_url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return credentials
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') return null
|
||||
if (error instanceof SyntaxError) return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 CoStrict 凭证
|
||||
*/
|
||||
export async function saveCoStrictCredentials(
|
||||
credentials: CoStrictCredentials,
|
||||
): Promise<void> {
|
||||
const filepath = getCoStrictCredentialsPath()
|
||||
const dir = join(homedir(), '.costrict', 'share')
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o755 })
|
||||
const content = JSON.stringify(credentials, null, 2)
|
||||
await fs.writeFile(filepath, content, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 CoStrict 凭证
|
||||
*/
|
||||
export async function deleteCoStrictCredentials(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(getCoStrictCredentialsPath())
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 CoStrict 凭证是否存在
|
||||
*/
|
||||
export async function hasCoStrictCredentials(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(getCoStrictCredentialsPath())
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
122
src/costrict/provider/fetch.ts
Normal file
122
src/costrict/provider/fetch.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* CoStrict 自定义 fetch 模块
|
||||
* 动态注入 Authorization header 并处理 token 刷新
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
loadCoStrictCredentials,
|
||||
saveCoStrictCredentials,
|
||||
} from './credentials.js'
|
||||
import {
|
||||
isCoStrictTokenValid,
|
||||
refreshCoStrictToken,
|
||||
extractExpiryFromJWT,
|
||||
} from './token.js'
|
||||
|
||||
import { createRequire } from 'module'
|
||||
|
||||
function getVersion(): string {
|
||||
try {
|
||||
if (typeof MACRO !== 'undefined' && MACRO.VERSION) return MACRO.VERSION
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = require('../../../package.json') as { version: string }
|
||||
return pkg.version
|
||||
} catch {
|
||||
return '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const VERSION = getVersion()
|
||||
|
||||
/**
|
||||
* 创建自定义 fetch 函数,用于 CoStrict API 请求
|
||||
*
|
||||
* 功能:
|
||||
* 1. 动态读取凭证
|
||||
* 2. 预防性 Token 刷新(请求前检查)
|
||||
* 3. 注入 Authorization 和 CoStrict 特有 headers
|
||||
* 4. 反应性 401 错误恢复(自动重试一次)
|
||||
*/
|
||||
export function createCoStrictFetch(): typeof fetch {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// ========== 步骤 1: 动态读取凭证 ==========
|
||||
let creds = await loadCoStrictCredentials()
|
||||
|
||||
if (!creds) {
|
||||
throw new Error(
|
||||
'CoStrict credentials not found. Please run /costrict-login first.',
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 步骤 2: 预防性 Token 刷新 ==========
|
||||
if (creds.refresh_token && !isCoStrictTokenValid(creds)) {
|
||||
try {
|
||||
const refreshed = await refreshCoStrictToken({
|
||||
baseUrl: creds.base_url,
|
||||
refreshToken: creds.refresh_token,
|
||||
state: creds.state,
|
||||
})
|
||||
const updatedCreds = {
|
||||
...creds,
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
expiry_date: extractExpiryFromJWT(refreshed.access_token),
|
||||
updated_at: new Date().toISOString(),
|
||||
expired_at: new Date(
|
||||
extractExpiryFromJWT(refreshed.access_token),
|
||||
).toISOString(),
|
||||
}
|
||||
await saveCoStrictCredentials(updatedCreds)
|
||||
creds = updatedCreds
|
||||
} catch {
|
||||
// 刷新失败,继续使用旧 token 尝试
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 步骤 3: 构建 headers ==========
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Authorization', `Bearer ${creds.access_token}`)
|
||||
headers.set('HTTP-Referer', 'https://github.com/zgsm-ai/costrict-cli')
|
||||
headers.set('X-Title', 'CoStrict-CLI')
|
||||
headers.set('X-Costrict-Version', `costrict-cli-${VERSION}`)
|
||||
headers.set('X-Request-ID', randomUUID())
|
||||
headers.set('zgsm-client-id', creds.machine_id)
|
||||
headers.set('zgsm-client-ide', 'cli')
|
||||
|
||||
// ========== 步骤 4: 发起请求 ==========
|
||||
const response = await fetch(input, { ...init, headers })
|
||||
|
||||
// ========== 步骤 5: 反应性 401 恢复 ==========
|
||||
if (response.status === 401 && creds.refresh_token) {
|
||||
try {
|
||||
const refreshed = await refreshCoStrictToken({
|
||||
baseUrl: creds.base_url,
|
||||
refreshToken: creds.refresh_token,
|
||||
state: creds.state,
|
||||
})
|
||||
const updatedCreds = {
|
||||
...creds,
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
expiry_date: extractExpiryFromJWT(refreshed.access_token),
|
||||
updated_at: new Date().toISOString(),
|
||||
expired_at: new Date(
|
||||
extractExpiryFromJWT(refreshed.access_token),
|
||||
).toISOString(),
|
||||
}
|
||||
await saveCoStrictCredentials(updatedCreds)
|
||||
headers.set('Authorization', `Bearer ${refreshed.access_token}`)
|
||||
headers.set('X-Request-ID', randomUUID())
|
||||
return fetch(input, { ...init, headers })
|
||||
} catch {
|
||||
// 重试失败,返回原始 401 响应
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
225
src/costrict/provider/index.ts
Normal file
225
src/costrict/provider/index.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* CoStrict 查询入口
|
||||
* 复用 OpenAI 兼容路径,注入 CoStrict 自定义 fetch 和 baseURL
|
||||
*/
|
||||
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { SystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import type {
|
||||
Message,
|
||||
StreamEvent,
|
||||
SystemAPIErrorMessage,
|
||||
AssistantMessage,
|
||||
} from '../../types/message.js'
|
||||
import type { Tools } from '../../Tool.js'
|
||||
import type { Options } from '../../services/api/claude.js'
|
||||
import OpenAI from 'openai'
|
||||
import { getProxyFetchOptions } from '../../utils/proxy.js'
|
||||
import { anthropicMessagesToOpenAI } from '../../services/api/openai/convertMessages.js'
|
||||
import {
|
||||
anthropicToolsToOpenAI,
|
||||
anthropicToolChoiceToOpenAI,
|
||||
} from '../../services/api/openai/convertTools.js'
|
||||
import { adaptOpenAIStreamToAnthropic } from '../../services/api/openai/streamAdapter.js'
|
||||
import { normalizeMessagesForAPI } from '../../utils/messages.js'
|
||||
import { toolToAPISchema } from '../../utils/api.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { addToTotalSessionCost } from '../../cost-tracker.js'
|
||||
import { calculateUSDCost } from '../../utils/modelCost.js'
|
||||
import {
|
||||
createAssistantAPIErrorMessage,
|
||||
normalizeContentFromAPI,
|
||||
} from '../../utils/messages.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createCoStrictFetch } from './fetch.js'
|
||||
import { resolveCoStrictModel } from './modelMapping.js'
|
||||
import { getCoStrictBaseURL } from './auth.js'
|
||||
import { loadCoStrictCredentials } from './credentials.js'
|
||||
|
||||
/**
|
||||
* CoStrict 查询路径
|
||||
* 与 queryModelOpenAI 结构相同,使用 CoStrict 自定义 fetch 和 baseURL
|
||||
*/
|
||||
export async function* queryModelCoStrict(
|
||||
messages: Message[],
|
||||
systemPrompt: SystemPrompt,
|
||||
tools: Tools,
|
||||
signal: AbortSignal,
|
||||
options: Options,
|
||||
): AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage, void> {
|
||||
try {
|
||||
// 1. 解析模型名
|
||||
const costrictModel = resolveCoStrictModel(options.model)
|
||||
|
||||
// 2. 获取 CoStrict base URL
|
||||
const creds = await loadCoStrictCredentials()
|
||||
const baseUrl = getCoStrictBaseURL(creds?.base_url)
|
||||
const chatBaseURL = `${baseUrl}/chat-rag/api/v1`
|
||||
|
||||
// 3. 规范化消息
|
||||
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
|
||||
|
||||
// 4. 构建工具 schema
|
||||
const toolSchemas = await Promise.all(
|
||||
tools.map(tool =>
|
||||
toolToAPISchema(tool, {
|
||||
getToolPermissionContext: options.getToolPermissionContext,
|
||||
tools,
|
||||
agents: options.agents,
|
||||
allowedAgentTypes: options.allowedAgentTypes,
|
||||
model: options.model,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const standardTools = toolSchemas.filter(
|
||||
(t): t is BetaToolUnion & { type: string } => {
|
||||
const anyT = t as Record<string, unknown>
|
||||
return (
|
||||
anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// 5. 转换为 OpenAI 格式
|
||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt)
|
||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||
|
||||
// 6. 创建专用的 CoStrict OpenAI 客户端(不缓存,每次使用新的 fetch)
|
||||
const costrictFetch = createCoStrictFetch()
|
||||
const client = new OpenAI({
|
||||
apiKey: 'costrict-managed', // 实际 token 由 createCoStrictFetch 注入
|
||||
baseURL: chatBaseURL,
|
||||
maxRetries: 0,
|
||||
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }) as RequestInit,
|
||||
fetch: costrictFetch as any,
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`[CoStrict] model=${costrictModel}, baseURL=${chatBaseURL}, messages=${openaiMessages.length}, tools=${openaiTools.length}`,
|
||||
)
|
||||
|
||||
// 7. 调用 API(流式)
|
||||
const stream = await client.chat.completions.create(
|
||||
{
|
||||
model: costrictModel,
|
||||
messages: openaiMessages,
|
||||
...(openaiTools.length > 0 && {
|
||||
tools: openaiTools,
|
||||
...(openaiToolChoice && { tool_choice: openaiToolChoice }),
|
||||
}),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(options.temperatureOverride !== undefined && {
|
||||
temperature: options.temperatureOverride,
|
||||
}),
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
|
||||
// 8. 转换流并 yield 事件
|
||||
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, costrictModel)
|
||||
|
||||
const contentBlocks: Record<number, any> = {}
|
||||
let partialMessage: any = undefined
|
||||
let usage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}
|
||||
let ttftMs = 0
|
||||
const start = Date.now()
|
||||
|
||||
for await (const event of adaptedStream) {
|
||||
switch (event.type) {
|
||||
case 'message_start': {
|
||||
partialMessage = (event as any).message
|
||||
ttftMs = Date.now() - start
|
||||
if ((event as any).message?.usage) {
|
||||
usage = { ...usage, ...((event as any).message.usage) }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_start': {
|
||||
const idx = (event as any).index
|
||||
const cb = (event as any).content_block
|
||||
if (cb.type === 'tool_use') {
|
||||
contentBlocks[idx] = { ...cb, input: '' }
|
||||
} else if (cb.type === 'text') {
|
||||
contentBlocks[idx] = { ...cb, text: '' }
|
||||
} else if (cb.type === 'thinking') {
|
||||
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
|
||||
} else {
|
||||
contentBlocks[idx] = { ...cb }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
const idx = (event as any).index
|
||||
const delta = (event as any).delta
|
||||
const block = contentBlocks[idx]
|
||||
if (!block) break
|
||||
if (delta.type === 'text_delta') {
|
||||
block.text = (block.text || '') + delta.text
|
||||
} else if (delta.type === 'input_json_delta') {
|
||||
block.input = (block.input || '') + delta.partial_json
|
||||
} else if (delta.type === 'thinking_delta') {
|
||||
block.thinking = (block.thinking || '') + delta.thinking
|
||||
} else if (delta.type === 'signature_delta') {
|
||||
block.signature = delta.signature
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
const idx = (event as any).index
|
||||
const block = contentBlocks[idx]
|
||||
if (!block || !partialMessage) break
|
||||
const m: AssistantMessage = {
|
||||
message: {
|
||||
...partialMessage,
|
||||
content: normalizeContentFromAPI([block], tools, options.agentId),
|
||||
},
|
||||
requestId: undefined,
|
||||
type: 'assistant',
|
||||
uuid: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
yield m
|
||||
break
|
||||
}
|
||||
case 'message_delta': {
|
||||
const deltaUsage = (event as any).usage
|
||||
if (deltaUsage) usage = { ...usage, ...deltaUsage }
|
||||
break
|
||||
}
|
||||
case 'message_stop':
|
||||
break
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'message_stop' &&
|
||||
usage.input_tokens + usage.output_tokens > 0
|
||||
) {
|
||||
const costUSD = calculateUSDCost(costrictModel, usage as any)
|
||||
addToTotalSessionCost(costUSD, usage as any, options.model)
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'stream_event',
|
||||
event,
|
||||
...(event.type === 'message_start' ? { ttftMs } : undefined),
|
||||
} as StreamEvent
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`[CoStrict] Error: ${errorMsg}`, { level: 'error' })
|
||||
yield createAssistantAPIErrorMessage({
|
||||
content: `CoStrict API Error: ${errorMsg}`,
|
||||
apiError: 'api_error',
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
}
|
||||
}
|
||||
48
src/costrict/provider/modelMapping.ts
Normal file
48
src/costrict/provider/modelMapping.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* CoStrict 模型名称解析模块
|
||||
* 将 Anthropic 模型名映射到 CoStrict 模型名
|
||||
*/
|
||||
|
||||
import { getCachedCoStrictModels } from './models.js'
|
||||
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CoStrict 模型名
|
||||
*
|
||||
* 优先级:
|
||||
* 1. 传入的 model 本身就是已知的 CoStrict 模型 ID(用户通过 /model 明确选择)
|
||||
* 2. COSTRICT_MODEL 环境变量(管理员全局覆盖 / 登录默认值)
|
||||
* 3. COSTRICT_DEFAULT_{FAMILY}_MODEL 环境变量(按模型族)
|
||||
* 4. 已缓存的 CoStrict 模型列表中的第一个
|
||||
* 5. 直接透传原始模型名(最后兜底)
|
||||
*/
|
||||
export function resolveCoStrictModel(anthropicModel: string): string {
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
// 优先级 1: 如果传入的 model 本身就是已知 CoStrict 模型 ID,直接使用
|
||||
const cached = getCachedCoStrictModels()
|
||||
if (cached.some(m => m.id === cleanModel)) return cleanModel
|
||||
|
||||
// 优先级 2: COSTRICT_MODEL 环境变量
|
||||
if (process.env.COSTRICT_MODEL) return process.env.COSTRICT_MODEL
|
||||
|
||||
// 优先级 3: COSTRICT_DEFAULT_{FAMILY}_MODEL 环境变量
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const envVar = `COSTRICT_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const override = process.env[envVar]
|
||||
if (override) return override
|
||||
}
|
||||
|
||||
// 优先级 4: 缓存中的第一个模型
|
||||
if (cached.length > 0) return cached[0].id
|
||||
|
||||
// 优先级 5: 透传原始模型名
|
||||
return cleanModel
|
||||
}
|
||||
83
src/costrict/provider/models.ts
Normal file
83
src/costrict/provider/models.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* CoStrict 动态模型列表模块
|
||||
* 从 /ai-gateway/api/v1/models 获取可用模型,1小时缓存
|
||||
*/
|
||||
|
||||
export interface CoStrictModel {
|
||||
id: string
|
||||
name?: string
|
||||
object?: string
|
||||
created?: number
|
||||
owned_by?: string
|
||||
supportsImages?: boolean
|
||||
contextWindow?: number
|
||||
maxTokens?: number
|
||||
creditConsumption?: number
|
||||
creditDiscount?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ModelCache {
|
||||
models: CoStrictModel[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
let modelCache: ModelCache | null = null
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时
|
||||
|
||||
/**
|
||||
* 获取 CoStrict 可用模型列表
|
||||
*/
|
||||
export async function fetchCoStrictModels(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<CoStrictModel[]> {
|
||||
if (modelCache && Date.now() - modelCache.timestamp < CACHE_TTL_MS) {
|
||||
return modelCache.models
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/ai-gateway/api/v1/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: CoStrictModel[] }
|
||||
const models = data.data || []
|
||||
|
||||
if (models.length === 0) return getDefaultModels()
|
||||
|
||||
modelCache = { models, timestamp: Date.now() }
|
||||
return models
|
||||
} catch {
|
||||
// 有旧缓存则使用旧缓存
|
||||
if (modelCache) return modelCache.models
|
||||
return getDefaultModels()
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultModels(): CoStrictModel[] {
|
||||
return [
|
||||
{ id: 'gpt-4', name: 'GPT-4' },
|
||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
|
||||
]
|
||||
}
|
||||
|
||||
export function clearModelCache(): void {
|
||||
modelCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步读取已缓存的模型列表(不发起网络请求)
|
||||
* 供 modelOptions.ts 等同步上下文使用
|
||||
*/
|
||||
export function getCachedCoStrictModels(): CoStrictModel[] {
|
||||
return modelCache?.models ?? []
|
||||
}
|
||||
58
src/costrict/provider/oauth-params.ts
Normal file
58
src/costrict/provider/oauth-params.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* CoStrict OAuth 参数构建模块
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// 优先使用构建时注入的 MACRO.VERSION,否则回退到 package.json(与 opencode Installation.VERSION 逻辑一致)
|
||||
function getVersion(): string {
|
||||
try {
|
||||
// MACRO.VERSION 由 Bun dev/build 构建时注入(scripts/defines.ts)
|
||||
if (typeof MACRO !== 'undefined' && MACRO.VERSION) return MACRO.VERSION
|
||||
} catch {
|
||||
// 动态 import 时 MACRO 尚未定义,继续回退
|
||||
}
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = require('../../../package.json') as { version: string }
|
||||
return pkg.version
|
||||
} catch {
|
||||
return '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const COSTRICT_PLUGIN_VERSION = `costrict-cli-${getVersion()}`
|
||||
|
||||
/**
|
||||
* 构建标准的 OAuth 查询参数
|
||||
*
|
||||
* @param includeMachineCode 是否包含 machine_code (登录和轮询时需要,刷新时不需要)
|
||||
* @param machineId 机器唯一标识
|
||||
* @param state OAuth state (可选)
|
||||
*/
|
||||
export function buildOAuthParams(
|
||||
includeMachineCode: boolean,
|
||||
machineId?: string,
|
||||
state?: string,
|
||||
): [string, string][] {
|
||||
const params: [string, string][] = []
|
||||
|
||||
if (includeMachineCode) {
|
||||
if (!machineId) throw new Error('machineId is required when includeMachineCode is true')
|
||||
params.push(['machine_code', machineId])
|
||||
}
|
||||
|
||||
if (state) {
|
||||
params.push(['state', state])
|
||||
}
|
||||
|
||||
params.push(
|
||||
['provider', 'casdoor'],
|
||||
['plugin_version', COSTRICT_PLUGIN_VERSION],
|
||||
['vscode_version', COSTRICT_PLUGIN_VERSION],
|
||||
['uri_scheme', 'costrict-cli'],
|
||||
)
|
||||
|
||||
return params
|
||||
}
|
||||
119
src/costrict/provider/token.ts
Normal file
119
src/costrict/provider/token.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* CoStrict Token 管理模块
|
||||
* 负责 Token 验证、刷新和 JWT 解析
|
||||
*/
|
||||
|
||||
import type { CoStrictCredentials } from './credentials.js'
|
||||
import { buildOAuthParams } from './oauth-params.js'
|
||||
|
||||
interface JWTPayload {
|
||||
exp?: number
|
||||
iat?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JWT Token (不验证签名)
|
||||
*/
|
||||
export function parseJWT(token: string): JWTPayload {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) throw new Error('Invalid JWT format')
|
||||
const decoded = Buffer.from(parts[1], 'base64url').toString('utf-8')
|
||||
return JSON.parse(decoded) as JWTPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT Token 提取过期时间 (毫秒)
|
||||
*/
|
||||
export function extractExpiryFromJWT(token: string): number {
|
||||
try {
|
||||
const payload = parseJWT(token)
|
||||
return payload.exp ? payload.exp * 1000 : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 三层 Token 验证策略
|
||||
*/
|
||||
export function isCoStrictTokenValid(credentials: CoStrictCredentials): boolean {
|
||||
const now = Date.now()
|
||||
const bufferMs = 30 * 60 * 1000 // 30 分钟
|
||||
|
||||
// 策略 1: expiry_date (30 分钟缓冲)
|
||||
if (credentials.expiry_date) {
|
||||
return now < credentials.expiry_date - bufferMs
|
||||
}
|
||||
|
||||
// 策略 2: refresh_token JWT
|
||||
if (credentials.refresh_token) {
|
||||
try {
|
||||
const payload = parseJWT(credentials.refresh_token)
|
||||
if (payload.exp) return payload.exp * 1000 > now
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 3: access_token JWT (30 分钟缓冲)
|
||||
try {
|
||||
const payload = parseJWT(credentials.access_token)
|
||||
if (payload.exp) return now < payload.exp * 1000 - bufferMs
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export interface RefreshTokenParams {
|
||||
baseUrl: string
|
||||
refreshToken: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 CoStrict Token
|
||||
* ⚠️ 刷新时**排除** machine_code 参数
|
||||
*/
|
||||
export async function refreshCoStrictToken(
|
||||
params: RefreshTokenParams,
|
||||
): Promise<RefreshTokenResponse> {
|
||||
const queryParams = buildOAuthParams(false, undefined, params.state)
|
||||
const queryString = queryParams
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&')
|
||||
|
||||
const url = `${params.baseUrl}/oidc-auth/api/v1/plugin/login/token?${queryString}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.refreshToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(
|
||||
response.status === 400 || response.status === 401
|
||||
? `Refresh token is invalid or expired (${response.status}): ${body}`
|
||||
: `Token refresh failed with status ${response.status}: ${body}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as RefreshTokenResponse
|
||||
|
||||
if (!data.access_token || !data.refresh_token) {
|
||||
throw new Error('Token refresh response is missing required fields')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
|
@ -1356,6 +1356,12 @@ async function* queryModel(
|
|||
return
|
||||
}
|
||||
|
||||
if (getAPIProvider() === 'costrict') {
|
||||
const { queryModelCoStrict } = await import('../../costrict/provider/index.js')
|
||||
yield* queryModelCoStrict(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
||||
return
|
||||
}
|
||||
|
||||
// Instrumentation: Track message count after normalization
|
||||
logEvent('tengu_api_after_normalize', {
|
||||
postNormalizedMessageCount: messagesForAPI.length,
|
||||
|
|
|
|||
63
src/setup.ts
63
src/setup.ts
|
|
@ -46,6 +46,7 @@ import type { PermissionMode } from './utils/permissions/PermissionMode.js'
|
|||
import { getPlanSlug } from './utils/plans.js'
|
||||
import { saveWorktreeState } from './utils/sessionStorage.js'
|
||||
import { profileCheckpoint } from './utils/startupProfiler.js'
|
||||
import { getAPIProvider } from './utils/model/providers.js'
|
||||
import {
|
||||
createTmuxSessionForWorktree,
|
||||
createWorktreeForSession,
|
||||
|
|
@ -380,6 +381,68 @@ export async function setup(
|
|||
void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed
|
||||
profileCheckpoint('setup_after_prefetch')
|
||||
|
||||
// CoStrict provider: 启动时恢复凭证并预取模型列表
|
||||
if (getAPIProvider() === 'costrict') {
|
||||
void (async () => {
|
||||
try {
|
||||
const { loadCoStrictCredentials, saveCoStrictCredentials } = await import('./costrict/provider/credentials.js')
|
||||
const { isCoStrictTokenValid, refreshCoStrictToken, extractExpiryFromJWT } = await import('./costrict/provider/token.js')
|
||||
const { fetchCoStrictModels } = await import('./costrict/provider/models.js')
|
||||
const { getCoStrictBaseURL } = await import('./costrict/provider/auth.js')
|
||||
|
||||
const creds = await loadCoStrictCredentials()
|
||||
if (!creds) {
|
||||
// 没有凭证,清除 modelType 让下次启动回到登录界面
|
||||
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
|
||||
updateSettingsForSource('userSettings', { modelType: undefined } as any)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 / 刷新 token(注意 refreshCoStrictToken 接收 RefreshTokenParams,字段名与 CoStrictCredentials 不同)
|
||||
let activeCreds = creds
|
||||
if (!isCoStrictTokenValid(creds)) {
|
||||
if (creds.refresh_token) {
|
||||
try {
|
||||
const refreshed = await refreshCoStrictToken({
|
||||
baseUrl: getCoStrictBaseURL(creds.base_url),
|
||||
refreshToken: creds.refresh_token,
|
||||
state: creds.state,
|
||||
})
|
||||
activeCreds = {
|
||||
...creds,
|
||||
access_token: refreshed.access_token,
|
||||
refresh_token: refreshed.refresh_token,
|
||||
expiry_date: extractExpiryFromJWT(refreshed.access_token),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
await saveCoStrictCredentials(activeCreds)
|
||||
} catch {
|
||||
// 刷新失败,清除 modelType 提示重新登录
|
||||
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
|
||||
updateSettingsForSource('userSettings', { modelType: undefined } as any)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 无 refresh_token,清除 modelType
|
||||
const { updateSettingsForSource } = await import('./utils/settings/settings.js')
|
||||
updateSettingsForSource('userSettings', { modelType: undefined } as any)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 预取模型列表,填充同步缓存
|
||||
const baseUrl = getCoStrictBaseURL(activeCreds.base_url)
|
||||
const models = await fetchCoStrictModels(baseUrl, activeCreds.access_token)
|
||||
if (models.length > 0 && !process.env.COSTRICT_MODEL) {
|
||||
process.env.COSTRICT_MODEL = models[0].id
|
||||
}
|
||||
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
|
||||
} catch {
|
||||
// 初始化失败不阻断启动
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Pre-fetch data for Logo v2 - await to ensure it's ready before logo renders.
|
||||
// --bare / SIMPLE: skip — release notes are interactive-UI display data,
|
||||
// and getRecentActivity() reads up to 10 session JSONL files.
|
||||
|
|
|
|||
|
|
@ -378,6 +378,36 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||
return standardOptions
|
||||
}
|
||||
|
||||
// CoStrict provider: 从缓存中动态展示 CoStrict 可用模型
|
||||
if (getAPIProvider() === 'costrict') {
|
||||
// 延迟 require 避免循环依赖,且该模块只在 costrict provider 下加载
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getCachedCoStrictModels } = require('../../costrict/provider/models.js') as typeof import('../../costrict/provider/models.js')
|
||||
const cachedModels = getCachedCoStrictModels()
|
||||
if (cachedModels.length > 0) {
|
||||
const sorted = [...cachedModels].sort((a, b) => a.id.localeCompare(b.id))
|
||||
return sorted.map(m => {
|
||||
const description =
|
||||
m.id === 'Auto'
|
||||
? `${Math.round((m.creditDiscount ?? 0) * 100)}% discount`
|
||||
: `${m.creditConsumption ?? '?'}x credit`
|
||||
return {
|
||||
value: m.id,
|
||||
label: m.id,
|
||||
description,
|
||||
}
|
||||
})
|
||||
}
|
||||
// 缓存尚未加载(凭证过期或首次加载中),提示用户重新登录
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
label: 'CoStrict (not logged in)',
|
||||
description: 'Run /login to sign in with CoStrict again',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ export type APIProvider =
|
|||
| 'openai'
|
||||
| 'gemini'
|
||||
| 'grok'
|
||||
| 'costrict'
|
||||
|
||||
export function getAPIProvider(): APIProvider {
|
||||
const modelType = getInitialSettings().modelType
|
||||
if (modelType === 'openai') return 'openai'
|
||||
if (modelType === 'gemini') return 'gemini'
|
||||
if (modelType === 'grok') return 'grok'
|
||||
if (modelType === 'costrict') return 'costrict'
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex'
|
||||
|
|
@ -24,6 +26,7 @@ export function getAPIProvider(): APIProvider {
|
|||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok'
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_COSTRICT)) return 'costrict'
|
||||
|
||||
return 'firstParty'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,10 +373,10 @@ export const SettingsSchema = lazySchema(() =>
|
|||
.optional()
|
||||
.describe('Tool usage permissions configuration'),
|
||||
modelType: z
|
||||
.enum(['anthropic', 'openai', 'gemini', 'grok'])
|
||||
.enum(['anthropic', 'openai', 'gemini', 'grok', 'costrict'])
|
||||
.optional()
|
||||
.describe(
|
||||
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' +
|
||||
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "costrict" uses the CoStrict API. ' +
|
||||
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.',
|
||||
),
|
||||
model: z
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user