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 { Select } from './CustomSelect/select.js'
|
||||||
import { Spinner } from './Spinner.js'
|
import { Spinner } from './Spinner.js'
|
||||||
import TextInput from './TextInput.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 = {
|
type Props = {
|
||||||
onDone(): void
|
onDone(): void
|
||||||
|
|
@ -28,6 +29,14 @@ type Props = {
|
||||||
type OAuthStatus =
|
type OAuthStatus =
|
||||||
| { state: 'idle' } // Initial state, waiting to select login method
|
| { state: 'idle' } // Initial state, waiting to select login method
|
||||||
| { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry)
|
| { 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'
|
state: 'custom_platform'
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
|
@ -73,6 +82,7 @@ export function ConsoleOAuthFlow({
|
||||||
mode = 'login',
|
mode = 'login',
|
||||||
forceLoginMethod: forceLoginMethodProp,
|
forceLoginMethod: forceLoginMethodProp,
|
||||||
}: Props): React.ReactNode {
|
}: Props): React.ReactNode {
|
||||||
|
const setAppState = useSetAppState()
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod
|
const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod
|
||||||
const orgUUID = settings.forceLoginOrgUUID
|
const orgUUID = settings.forceLoginOrgUUID
|
||||||
|
|
@ -273,6 +283,8 @@ export function ConsoleOAuthFlow({
|
||||||
}
|
}
|
||||||
// Reset modelType to anthropic when using OAuth login
|
// Reset modelType to anthropic when using OAuth login
|
||||||
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any)
|
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any)
|
||||||
|
delete process.env.CLAUDE_CODE_USE_COSTRICT
|
||||||
|
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||||
|
|
||||||
setOAuthStatus({ state: 'success' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void sendNotification(
|
void sendNotification(
|
||||||
|
|
@ -438,6 +450,7 @@ function OAuthStatusMessage({
|
||||||
setLoginWithClaudeAi,
|
setLoginWithClaudeAi,
|
||||||
onDone,
|
onDone,
|
||||||
}: OAuthStatusMessageProps): React.ReactNode {
|
}: OAuthStatusMessageProps): React.ReactNode {
|
||||||
|
const setAppState = useSetAppState()
|
||||||
switch (oauthStatus.state) {
|
switch (oauthStatus.state) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return (
|
return (
|
||||||
|
|
@ -453,6 +466,16 @@ function OAuthStatusMessage({
|
||||||
<Box>
|
<Box>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Text>
|
||||||
|
CoStrict ·{' '}
|
||||||
|
<Text dimColor>Sign in with CoStrict account</Text>
|
||||||
|
{'\n'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: 'costrict',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Text>
|
<Text>
|
||||||
|
|
@ -530,7 +553,70 @@ function OAuthStatusMessage({
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={value => {
|
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', {})
|
logEvent('tengu_custom_platform_selected', {})
|
||||||
setOAuthStatus({
|
setOAuthStatus({
|
||||||
state: 'custom_platform',
|
state: 'custom_platform',
|
||||||
|
|
@ -696,10 +782,12 @@ function OAuthStatusMessage({
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
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' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
void onDone()
|
||||||
}
|
}
|
||||||
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
|
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone, setAppState])
|
||||||
|
|
||||||
const handleEnter = useCallback(() => {
|
const handleEnter = useCallback(() => {
|
||||||
const idx = FIELDS.indexOf(activeField)
|
const idx = FIELDS.indexOf(activeField)
|
||||||
|
|
@ -916,10 +1004,12 @@ function OAuthStatusMessage({
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
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' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
void onDone()
|
||||||
}
|
}
|
||||||
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
|
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone, setAppState])
|
||||||
|
|
||||||
const handleOpenAIEnter = useCallback(() => {
|
const handleOpenAIEnter = useCallback(() => {
|
||||||
const idx = OPENAI_FIELDS.indexOf(activeField)
|
const idx = OPENAI_FIELDS.indexOf(activeField)
|
||||||
|
|
@ -1149,10 +1239,12 @@ function OAuthStatusMessage({
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
for (const [k, v] of Object.entries(env)) process.env[k] = v
|
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' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
void onDone()
|
||||||
}
|
}
|
||||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
|
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus, setAppState])
|
||||||
|
|
||||||
const handleGeminiEnter = useCallback(() => {
|
const handleGeminiEnter = useCallback(() => {
|
||||||
const idx = GEMINI_FIELDS.indexOf(activeField)
|
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':
|
case 'platform_setup':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
<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
|
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
|
// Instrumentation: Track message count after normalization
|
||||||
logEvent('tengu_api_after_normalize', {
|
logEvent('tengu_api_after_normalize', {
|
||||||
postNormalizedMessageCount: messagesForAPI.length,
|
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 { getPlanSlug } from './utils/plans.js'
|
||||||
import { saveWorktreeState } from './utils/sessionStorage.js'
|
import { saveWorktreeState } from './utils/sessionStorage.js'
|
||||||
import { profileCheckpoint } from './utils/startupProfiler.js'
|
import { profileCheckpoint } from './utils/startupProfiler.js'
|
||||||
|
import { getAPIProvider } from './utils/model/providers.js'
|
||||||
import {
|
import {
|
||||||
createTmuxSessionForWorktree,
|
createTmuxSessionForWorktree,
|
||||||
createWorktreeForSession,
|
createWorktreeForSession,
|
||||||
|
|
@ -380,6 +381,68 @@ export async function setup(
|
||||||
void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed
|
void prefetchApiKeyFromApiKeyHelperIfSafe(getIsNonInteractiveSession()) // Prefetch safely - only executes if trust already confirmed
|
||||||
profileCheckpoint('setup_after_prefetch')
|
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.
|
// 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,
|
// --bare / SIMPLE: skip — release notes are interactive-UI display data,
|
||||||
// and getRecentActivity() reads up to 10 session JSONL files.
|
// and getRecentActivity() reads up to 10 session JSONL files.
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,36 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||||
return standardOptions
|
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
|
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
||||||
if (getAPIProvider() === 'firstParty') {
|
if (getAPIProvider() === 'firstParty') {
|
||||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ export type APIProvider =
|
||||||
| 'openai'
|
| 'openai'
|
||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'grok'
|
| 'grok'
|
||||||
|
| 'costrict'
|
||||||
|
|
||||||
export function getAPIProvider(): APIProvider {
|
export function getAPIProvider(): APIProvider {
|
||||||
const modelType = getInitialSettings().modelType
|
const modelType = getInitialSettings().modelType
|
||||||
if (modelType === 'openai') return 'openai'
|
if (modelType === 'openai') return 'openai'
|
||||||
if (modelType === 'gemini') return 'gemini'
|
if (modelType === 'gemini') return 'gemini'
|
||||||
if (modelType === 'grok') return 'grok'
|
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_BEDROCK)) return 'bedrock'
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex'
|
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_OPENAI)) return 'openai'
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini'
|
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_GROK)) return 'grok'
|
||||||
|
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_COSTRICT)) return 'costrict'
|
||||||
|
|
||||||
return 'firstParty'
|
return 'firstParty'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -373,10 +373,10 @@ export const SettingsSchema = lazySchema(() =>
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Tool usage permissions configuration'),
|
.describe('Tool usage permissions configuration'),
|
||||||
modelType: z
|
modelType: z
|
||||||
.enum(['anthropic', 'openai', 'gemini', 'grok'])
|
.enum(['anthropic', 'openai', 'gemini', 'grok', 'costrict'])
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.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.',
|
'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
|
model: z
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user