feat: add costrict login provider

This commit is contained in:
Askhz 2026-04-07 20:32:32 +08:00
parent ca0c3265e6
commit d77963fb3f
14 changed files with 1189 additions and 7 deletions

View File

@ -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 的 openBrowserWindows 用 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}>

View 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
}

View 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
}
}

View 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
}
}

View 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)),
})
}
}

View 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
}

View 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 ?? []
}

View 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
}

View 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
}

View File

@ -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,

View File

@ -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.

View File

@ -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)]

View File

@ -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'
}

View File

@ -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