mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
Compare commits
22 Commits
aae7af17a9
...
c6dd476475
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6dd476475 | ||
|
|
8ab375161d | ||
|
|
42260710d8 | ||
|
|
5e8646c6a5 | ||
|
|
7e93e8b9b2 | ||
|
|
03c3eee9f3 | ||
|
|
908506c83f | ||
|
|
e21c6ce2fc | ||
|
|
bffc0f9a95 | ||
|
|
f9d39072ea | ||
|
|
c223b7a2dd | ||
|
|
38d254fa99 | ||
|
|
f56204b0cf | ||
|
|
f1468390dd | ||
|
|
1f5293ccc0 | ||
|
|
50d2a1f0c4 | ||
|
|
771e5e6c3e | ||
|
|
cafc1648d9 | ||
|
|
76d48f9ccb | ||
|
|
115cd80432 | ||
|
|
531101742e | ||
|
|
c3c577dff4 |
@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867bbff5e1f 100644
|
||||
index d004b415c5841a1969705823614f395265ea5a8a..6b1e0dad4610b0424393ecc12e9114723bbe316b 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -474,7 +474,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@ -12,7 +12,7 @@ index 51ce7e423934fb717cb90245cdfcdb3dae6780e6..0f7f7009e2f41a79a8669d38c8a44867
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b6036d4014b6 100644
|
||||
index 1780dd2391b7f42224a0b8048c723d2f81222c44..1f12ed14399d6902107ce9b435d7d8e6cc61e06b 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -480,7 +480,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@ -24,3 +24,14 @@ index f4b77e35c0cbfece85a3ef0d4f4e67aa6dde6271..8d2fecf8155a226006a0bde72b00b603
|
||||
}
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
@@ -1909,8 +1909,7 @@ function createGoogleGenerativeAI(options = {}) {
|
||||
}
|
||||
var google = createGoogleGenerativeAI();
|
||||
export {
|
||||
- VERSION,
|
||||
createGoogleGenerativeAI,
|
||||
- google
|
||||
+ google, VERSION
|
||||
};
|
||||
//# sourceMappingURL=index.mjs.map
|
||||
\ No newline at end of file
|
||||
@ -114,8 +114,8 @@
|
||||
"@ai-sdk/anthropic": "^2.0.49",
|
||||
"@ai-sdk/cerebras": "^1.0.31",
|
||||
"@ai-sdk/gateway": "^2.0.15",
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch",
|
||||
"@ai-sdk/google-vertex": "^3.0.79",
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
|
||||
"@ai-sdk/google-vertex": "^3.0.94",
|
||||
"@ai-sdk/huggingface": "^0.0.10",
|
||||
"@ai-sdk/mistral": "^2.0.24",
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
|
||||
@ -416,7 +416,8 @@
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
|
||||
"@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch",
|
||||
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch"
|
||||
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch",
|
||||
"@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@ -386,5 +386,13 @@ export enum IpcChannel {
|
||||
WebSocket_Stop = 'webSocket:stop',
|
||||
WebSocket_Status = 'webSocket:status',
|
||||
WebSocket_SendFile = 'webSocket:send-file',
|
||||
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
|
||||
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates',
|
||||
|
||||
// Volcengine
|
||||
Volcengine_SaveCredentials = 'volcengine:save-credentials',
|
||||
Volcengine_HasCredentials = 'volcengine:has-credentials',
|
||||
Volcengine_ClearCredentials = 'volcengine:clear-credentials',
|
||||
Volcengine_ListModels = 'volcengine:list-models',
|
||||
Volcengine_GetAuthHeaders = 'volcengine:get-auth-headers',
|
||||
Volcengine_MakeRequest = 'volcengine:make-request'
|
||||
}
|
||||
|
||||
@ -80,6 +80,7 @@ import {
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import VertexAIService from './services/VertexAIService'
|
||||
import VolcengineService from './services/VolcengineService'
|
||||
import WebSocketService from './services/WebSocketService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@ -1121,6 +1122,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
|
||||
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
|
||||
|
||||
// Volcengine
|
||||
ipcMain.handle(IpcChannel.Volcengine_SaveCredentials, VolcengineService.saveCredentials)
|
||||
ipcMain.handle(IpcChannel.Volcengine_HasCredentials, VolcengineService.hasCredentials)
|
||||
ipcMain.handle(IpcChannel.Volcengine_ClearCredentials, VolcengineService.clearCredentials)
|
||||
ipcMain.handle(IpcChannel.Volcengine_ListModels, VolcengineService.listModels)
|
||||
ipcMain.handle(IpcChannel.Volcengine_GetAuthHeaders, VolcengineService.getAuthHeaders)
|
||||
ipcMain.handle(IpcChannel.Volcengine_MakeRequest, VolcengineService.makeRequest)
|
||||
|
||||
ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => {
|
||||
mainWindow.webContents.forcefullyCrashRenderer()
|
||||
})
|
||||
|
||||
762
src/main/services/VolcengineService.ts
Normal file
762
src/main/services/VolcengineService.ts
Normal file
@ -0,0 +1,762 @@
|
||||
import { loggerService } from '@logger'
|
||||
import crypto from 'crypto'
|
||||
import { app, net, safeStorage } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { getConfigDir } from '../utils/file'
|
||||
|
||||
const logger = loggerService.withContext('VolcengineService')
|
||||
|
||||
/**
|
||||
* Calculate SHA256 hash of data and return hex encoded string
|
||||
* @internal
|
||||
*/
|
||||
export function _sha256Hash(data: string | Buffer): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate HMAC-SHA256 and return buffer
|
||||
* @internal
|
||||
*/
|
||||
export function _hmacSha256(key: Buffer | string, data: string): Buffer {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate HMAC-SHA256 and return hex encoded string
|
||||
* @internal
|
||||
*/
|
||||
export function _hmacSha256Hex(key: Buffer | string, data: string): string {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode according to RFC3986
|
||||
* @internal
|
||||
*/
|
||||
export function _uriEncode(str: string, encodeSlash: boolean = true): string {
|
||||
if (!str) return ''
|
||||
|
||||
// RFC3986 unreserved: A-Z a-z 0-9 - _ . ~
|
||||
// If encodeSlash is false, / is also unencoded
|
||||
const pattern = encodeSlash ? /[^A-Za-z0-9_\-.~]/g : /[^A-Za-z0-9_\-.~/]/g
|
||||
return str.replace(pattern, (char) => encodeURIComponent(char))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build canonical query string from query parameters
|
||||
* @internal
|
||||
*/
|
||||
export function _buildCanonicalQueryString(query: Record<string, string>): string {
|
||||
if (!query || Object.keys(query).length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return Object.keys(query)
|
||||
.sort()
|
||||
.map((key) => `${_uriEncode(key)}=${_uriEncode(query[key])}`)
|
||||
.join('&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build canonical headers string
|
||||
* @internal
|
||||
*/
|
||||
export function _buildCanonicalHeaders(headers: Record<string, string>): {
|
||||
canonicalHeaders: string
|
||||
signedHeaders: string
|
||||
} {
|
||||
// Create a lowercase-keyed map to handle mixed-case input headers
|
||||
const lowercaseHeaders: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
lowercaseHeaders[key.toLowerCase()] = value
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(lowercaseHeaders).sort()
|
||||
|
||||
const canonicalHeaders = sortedKeys.map((key) => `${key}:${lowercaseHeaders[key]?.trim() || ''}`).join('\n') + '\n'
|
||||
|
||||
const signedHeaders = sortedKeys.join(';')
|
||||
|
||||
return { canonicalHeaders, signedHeaders }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the signing key through a series of HMAC operations
|
||||
* @internal
|
||||
*/
|
||||
export function _deriveSigningKey(secretKey: string, date: string, region: string, service: string): Buffer {
|
||||
const kDate = _hmacSha256(secretKey, date)
|
||||
const kRegion = _hmacSha256(kDate, region)
|
||||
const kService = _hmacSha256(kRegion, service)
|
||||
const kSigning = _hmacSha256(kService, 'request')
|
||||
return kSigning
|
||||
}
|
||||
|
||||
/**
|
||||
* Create canonical request string
|
||||
* @internal
|
||||
*/
|
||||
export function _createCanonicalRequest(
|
||||
method: string,
|
||||
canonicalUri: string,
|
||||
canonicalQueryString: string,
|
||||
canonicalHeaders: string,
|
||||
signedHeaders: string,
|
||||
payloadHash: string
|
||||
): string {
|
||||
return [method, canonicalUri, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create string to sign
|
||||
* @internal
|
||||
*/
|
||||
export function _createStringToSign(dateTime: string, credentialScope: string, canonicalRequest: string): string {
|
||||
const hashedCanonicalRequest = _sha256Hash(canonicalRequest)
|
||||
return ['HMAC-SHA256', dateTime, credentialScope, hashedCanonicalRequest].join('\n')
|
||||
}
|
||||
|
||||
// Configuration constants
|
||||
const CONFIG = {
|
||||
ALGORITHM: 'HMAC-SHA256',
|
||||
REQUEST_TYPE: 'request',
|
||||
DEFAULT_REGION: 'cn-beijing',
|
||||
SERVICE_NAME: 'ark',
|
||||
DEFAULT_HEADERS: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json'
|
||||
},
|
||||
API_URLS: {
|
||||
ARK_HOST: 'open.volcengineapi.com'
|
||||
},
|
||||
CREDENTIALS_FILE_NAME: '.volcengine_credentials',
|
||||
API_VERSION: '2024-01-01',
|
||||
DEFAULT_PAGE_SIZE: 100
|
||||
} as const
|
||||
|
||||
// Request schemas
|
||||
const ListFoundationModelsRequestSchema = z.object({
|
||||
PageNumber: z.optional(z.number()),
|
||||
PageSize: z.optional(z.number())
|
||||
})
|
||||
|
||||
const ListEndpointsRequestSchema = z.object({
|
||||
ProjectName: z.optional(z.string()),
|
||||
PageNumber: z.optional(z.number()),
|
||||
PageSize: z.optional(z.number())
|
||||
})
|
||||
|
||||
// Response schemas - only keep fields needed for model list
|
||||
const FoundationModelItemSchema = z.object({
|
||||
Name: z.string(),
|
||||
DisplayName: z.optional(z.string()),
|
||||
Description: z.optional(z.string())
|
||||
})
|
||||
|
||||
const EndpointItemSchema = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.optional(z.string()),
|
||||
Description: z.optional(z.string()),
|
||||
ModelReference: z.optional(
|
||||
z.object({
|
||||
FoundationModel: z.optional(
|
||||
z.object({
|
||||
Name: z.optional(z.string()),
|
||||
ModelVersion: z.optional(z.string())
|
||||
})
|
||||
),
|
||||
CustomModelId: z.optional(z.string())
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const ListFoundationModelsResponseSchema = z.object({
|
||||
Result: z.object({
|
||||
TotalCount: z.number(),
|
||||
Items: z.array(FoundationModelItemSchema)
|
||||
})
|
||||
})
|
||||
|
||||
const ListEndpointsResponseSchema = z.object({
|
||||
Result: z.object({
|
||||
TotalCount: z.number(),
|
||||
Items: z.array(EndpointItemSchema)
|
||||
})
|
||||
})
|
||||
|
||||
// Infer types from schemas
|
||||
type ListFoundationModelsRequest = z.infer<typeof ListFoundationModelsRequestSchema>
|
||||
type ListEndpointsRequest = z.infer<typeof ListEndpointsRequestSchema>
|
||||
type ListFoundationModelsResponse = z.infer<typeof ListFoundationModelsResponseSchema>
|
||||
type ListEndpointsResponse = z.infer<typeof ListEndpointsResponseSchema>
|
||||
|
||||
// ============= Internal Type Definitions =============
|
||||
|
||||
interface VolcengineCredentials {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
interface SignedRequestParams {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
query: Record<string, string>
|
||||
headers: Record<string, string>
|
||||
body?: string
|
||||
service: string
|
||||
region: string
|
||||
}
|
||||
|
||||
interface SignedHeaders {
|
||||
Authorization: string
|
||||
'X-Date': string
|
||||
'X-Content-Sha256': string
|
||||
Host: string
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
created?: number
|
||||
}
|
||||
|
||||
interface ListModelsResult {
|
||||
models: ModelInfo[]
|
||||
total?: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
// Custom error class
|
||||
class VolcengineServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'VolcengineServiceError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Volcengine API Signing Service
|
||||
*
|
||||
* Implements HMAC-SHA256 signing algorithm for Volcengine API authentication.
|
||||
* Securely stores credentials using Electron's safeStorage.
|
||||
*/
|
||||
class VolcengineService {
|
||||
private readonly credentialsFilePath: string
|
||||
|
||||
constructor() {
|
||||
this.credentialsFilePath = this.getCredentialsFilePath()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for storing encrypted credentials
|
||||
*/
|
||||
private getCredentialsFilePath(): string {
|
||||
const oldPath = path.join(app.getPath('userData'), CONFIG.CREDENTIALS_FILE_NAME)
|
||||
if (fs.existsSync(oldPath)) {
|
||||
return oldPath
|
||||
}
|
||||
return path.join(getConfigDir(), CONFIG.CREDENTIALS_FILE_NAME)
|
||||
}
|
||||
|
||||
// ============= Cryptographic Helper Methods =============
|
||||
private sha256Hash(data: string | Buffer): string {
|
||||
return _sha256Hash(data)
|
||||
}
|
||||
|
||||
private hmacSha256Hex(key: Buffer | string, data: string): string {
|
||||
return _hmacSha256Hex(key, data)
|
||||
}
|
||||
|
||||
private uriEncode(str: string, encodeSlash: boolean = true): string {
|
||||
return _uriEncode(str, encodeSlash)
|
||||
}
|
||||
|
||||
// ============= Signing Implementation =============
|
||||
|
||||
/**
|
||||
* Get current UTC time in ISO8601 format (YYYYMMDD'T'HHMMSS'Z')
|
||||
*/
|
||||
private getIso8601DateTime(): string {
|
||||
const now = new Date()
|
||||
return now
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date portion from datetime (YYYYMMDD)
|
||||
*/
|
||||
private getDateFromDateTime(dateTime: string): string {
|
||||
return dateTime.substring(0, 8)
|
||||
}
|
||||
|
||||
private buildCanonicalQueryString(query: Record<string, string>): string {
|
||||
return _buildCanonicalQueryString(query)
|
||||
}
|
||||
|
||||
private buildCanonicalHeaders(headers: Record<string, string>): {
|
||||
canonicalHeaders: string
|
||||
signedHeaders: string
|
||||
} {
|
||||
return _buildCanonicalHeaders(headers)
|
||||
}
|
||||
|
||||
private deriveSigningKey(secretKey: string, date: string, region: string, service: string): Buffer {
|
||||
return _deriveSigningKey(secretKey, date, region, service)
|
||||
}
|
||||
|
||||
private createCanonicalRequest(
|
||||
method: string,
|
||||
canonicalUri: string,
|
||||
canonicalQueryString: string,
|
||||
canonicalHeaders: string,
|
||||
signedHeaders: string,
|
||||
payloadHash: string
|
||||
): string {
|
||||
return _createCanonicalRequest(
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
)
|
||||
}
|
||||
|
||||
private createStringToSign(dateTime: string, credentialScope: string, canonicalRequest: string): string {
|
||||
return _createStringToSign(dateTime, credentialScope, canonicalRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signature for the request
|
||||
*/
|
||||
private generateSignature(params: SignedRequestParams, credentials: VolcengineCredentials): SignedHeaders {
|
||||
const { method, host, path: requestPath, query, body, service, region } = params
|
||||
|
||||
// Step 1: Prepare datetime
|
||||
const dateTime = this.getIso8601DateTime()
|
||||
const date = this.getDateFromDateTime(dateTime)
|
||||
|
||||
// Step 2: Calculate payload hash
|
||||
const payloadHash = this.sha256Hash(body || '')
|
||||
|
||||
// Step 3: Prepare headers for signing
|
||||
const headersToSign: Record<string, string> = {
|
||||
host: host,
|
||||
'x-date': dateTime,
|
||||
'x-content-sha256': payloadHash,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
// Step 4: Build canonical components
|
||||
const canonicalUri = this.uriEncode(requestPath, false) || '/'
|
||||
const canonicalQueryString = this.buildCanonicalQueryString(query)
|
||||
const { canonicalHeaders, signedHeaders } = this.buildCanonicalHeaders(headersToSign)
|
||||
|
||||
// Step 5: Create canonical request
|
||||
const canonicalRequest = this.createCanonicalRequest(
|
||||
method.toUpperCase(),
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
)
|
||||
|
||||
// Step 6: Create credential scope and string to sign
|
||||
const credentialScope = `${date}/${region}/${service}/${CONFIG.REQUEST_TYPE}`
|
||||
const stringToSign = this.createStringToSign(dateTime, credentialScope, canonicalRequest)
|
||||
|
||||
// Step 7: Calculate signature
|
||||
const signingKey = this.deriveSigningKey(credentials.secretAccessKey, date, region, service)
|
||||
const signature = this.hmacSha256Hex(signingKey, stringToSign)
|
||||
|
||||
// Step 8: Build authorization header
|
||||
const authorization = `${CONFIG.ALGORITHM} Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
||||
|
||||
return {
|
||||
Authorization: authorization,
|
||||
'X-Date': dateTime,
|
||||
'X-Content-Sha256': payloadHash,
|
||||
Host: host
|
||||
}
|
||||
}
|
||||
|
||||
// ============= Credential Management =============
|
||||
|
||||
/**
|
||||
* Save credentials securely using Electron's safeStorage
|
||||
*/
|
||||
public saveCredentials = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
throw new VolcengineServiceError('Access Key ID and Secret Access Key are required')
|
||||
}
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new VolcengineServiceError('Secure storage is not available on this platform')
|
||||
}
|
||||
|
||||
const credentials: VolcengineCredentials = { accessKeyId, secretAccessKey }
|
||||
const credentialsJson = JSON.stringify(credentials)
|
||||
const encryptedData = safeStorage.encryptString(credentialsJson)
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.credentialsFilePath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(this.credentialsFilePath, encryptedData)
|
||||
await fs.promises.chmod(this.credentialsFilePath, 0o600) // Read/write for owner only
|
||||
logger.info('Volcengine credentials saved successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to save Volcengine credentials:', error as Error)
|
||||
throw new VolcengineServiceError('Failed to save credentials', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from encrypted storage
|
||||
* @throws VolcengineServiceError if credentials file exists but is corrupted
|
||||
*/
|
||||
private async loadCredentials(): Promise<VolcengineCredentials | null> {
|
||||
if (!fs.existsSync(this.credentialsFilePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedData = await fs.promises.readFile(this.credentialsFilePath)
|
||||
const decryptedJson = safeStorage.decryptString(Buffer.from(encryptedData))
|
||||
return JSON.parse(decryptedJson) as VolcengineCredentials
|
||||
} catch (error) {
|
||||
logger.error('Failed to load Volcengine credentials:', error as Error)
|
||||
throw new VolcengineServiceError(
|
||||
'Credentials file exists but could not be loaded. Please re-enter your credentials.',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials exist
|
||||
*/
|
||||
public hasCredentials = async (): Promise<boolean> => {
|
||||
return fs.existsSync(this.credentialsFilePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials
|
||||
*/
|
||||
public clearCredentials = async (): Promise<void> => {
|
||||
try {
|
||||
if (fs.existsSync(this.credentialsFilePath)) {
|
||||
await fs.promises.unlink(this.credentialsFilePath)
|
||||
logger.info('Volcengine credentials cleared')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear Volcengine credentials:', error as Error)
|
||||
throw new VolcengineServiceError('Failed to clear credentials', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============= API Methods =============
|
||||
|
||||
/**
|
||||
* Make a signed request to Volcengine API
|
||||
*/
|
||||
private async makeSignedRequest<T>(
|
||||
method: 'GET' | 'POST',
|
||||
host: string,
|
||||
path: string,
|
||||
action: string,
|
||||
version: string,
|
||||
query?: Record<string, string>,
|
||||
body?: Record<string, unknown>,
|
||||
service: string = CONFIG.SERVICE_NAME,
|
||||
region: string = CONFIG.DEFAULT_REGION
|
||||
): Promise<T> {
|
||||
const credentials = await this.loadCredentials()
|
||||
if (!credentials) {
|
||||
throw new VolcengineServiceError('No credentials found. Please save credentials first.')
|
||||
}
|
||||
|
||||
const fullQuery: Record<string, string> = {
|
||||
Action: action,
|
||||
Version: version,
|
||||
...query
|
||||
}
|
||||
|
||||
const bodyString = body ? JSON.stringify(body) : ''
|
||||
|
||||
const signedHeaders = this.generateSignature(
|
||||
{
|
||||
method,
|
||||
host,
|
||||
path,
|
||||
query: fullQuery,
|
||||
headers: {},
|
||||
body: bodyString,
|
||||
service,
|
||||
region
|
||||
},
|
||||
credentials
|
||||
)
|
||||
|
||||
// Build URL with query string (use simple encoding for URL, canonical encoding is only for signature)
|
||||
const urlParams = new URLSearchParams(fullQuery)
|
||||
const url = `https://${host}${path}?${urlParams.toString()}`
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
...CONFIG.DEFAULT_HEADERS,
|
||||
Authorization: signedHeaders.Authorization,
|
||||
'X-Date': signedHeaders['X-Date'],
|
||||
'X-Content-Sha256': signedHeaders['X-Content-Sha256']
|
||||
}
|
||||
|
||||
logger.debug('Making Volcengine API request', { url, method, action })
|
||||
|
||||
try {
|
||||
const response = await net.fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: method === 'POST' && bodyString ? bodyString : undefined
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`Volcengine API error: ${response.status}`, { errorText })
|
||||
throw new VolcengineServiceError(`API request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
} catch (error) {
|
||||
if (error instanceof VolcengineServiceError) {
|
||||
throw error
|
||||
}
|
||||
logger.error('Volcengine API request failed:', error as Error)
|
||||
throw new VolcengineServiceError('API request failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List foundation models from Volcengine ARK
|
||||
*/
|
||||
private async listFoundationModels(region: string = CONFIG.DEFAULT_REGION): Promise<ListFoundationModelsResponse> {
|
||||
const requestBody: ListFoundationModelsRequest = {
|
||||
PageNumber: 1,
|
||||
PageSize: CONFIG.DEFAULT_PAGE_SIZE
|
||||
}
|
||||
|
||||
const response = await this.makeSignedRequest<unknown>(
|
||||
'POST',
|
||||
CONFIG.API_URLS.ARK_HOST,
|
||||
'/',
|
||||
'ListFoundationModels',
|
||||
CONFIG.API_VERSION,
|
||||
{},
|
||||
requestBody,
|
||||
CONFIG.SERVICE_NAME,
|
||||
region
|
||||
)
|
||||
|
||||
return ListFoundationModelsResponseSchema.parse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* List user-created endpoints from Volcengine ARK
|
||||
*/
|
||||
private async listEndpoints(
|
||||
projectName?: string,
|
||||
region: string = CONFIG.DEFAULT_REGION
|
||||
): Promise<ListEndpointsResponse> {
|
||||
const requestBody: ListEndpointsRequest = {
|
||||
ProjectName: projectName || 'default',
|
||||
PageNumber: 1,
|
||||
PageSize: CONFIG.DEFAULT_PAGE_SIZE
|
||||
}
|
||||
|
||||
const response = await this.makeSignedRequest<unknown>(
|
||||
'POST',
|
||||
CONFIG.API_URLS.ARK_HOST,
|
||||
'/',
|
||||
'ListEndpoints',
|
||||
CONFIG.API_VERSION,
|
||||
{},
|
||||
requestBody,
|
||||
CONFIG.SERVICE_NAME,
|
||||
region
|
||||
)
|
||||
|
||||
return ListEndpointsResponseSchema.parse(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available models from Volcengine ARK
|
||||
* Combines foundation models and user-created endpoints
|
||||
*/
|
||||
public listModels = async (
|
||||
_?: Electron.IpcMainInvokeEvent,
|
||||
projectName?: string,
|
||||
region?: string
|
||||
): Promise<ListModelsResult> => {
|
||||
try {
|
||||
const effectiveRegion = region || CONFIG.DEFAULT_REGION
|
||||
const [foundationModelsResult, endpointsResult] = await Promise.allSettled([
|
||||
this.listFoundationModels(effectiveRegion),
|
||||
this.listEndpoints(projectName, effectiveRegion)
|
||||
])
|
||||
|
||||
const models: ModelInfo[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
if (foundationModelsResult.status === 'fulfilled') {
|
||||
const foundationModels = foundationModelsResult.value
|
||||
for (const item of foundationModels.Result.Items) {
|
||||
models.push({
|
||||
id: item.Name,
|
||||
name: item.DisplayName || item.Name,
|
||||
description: item.Description
|
||||
})
|
||||
}
|
||||
logger.info(`Found ${foundationModels.Result.Items.length} foundation models`)
|
||||
} else {
|
||||
const errorMsg = `Failed to fetch foundation models: ${foundationModelsResult.reason}`
|
||||
logger.warn(errorMsg)
|
||||
warnings.push(errorMsg)
|
||||
}
|
||||
|
||||
// Process endpoints
|
||||
if (endpointsResult.status === 'fulfilled') {
|
||||
const endpoints = endpointsResult.value
|
||||
for (const item of endpoints.Result.Items) {
|
||||
const modelRef = item.ModelReference
|
||||
const foundationModelName = modelRef?.FoundationModel?.Name
|
||||
const modelVersion = modelRef?.FoundationModel?.ModelVersion
|
||||
const customModelId = modelRef?.CustomModelId
|
||||
|
||||
let displayName = item.Name || item.Id
|
||||
if (foundationModelName) {
|
||||
displayName = modelVersion ? `${foundationModelName} (${modelVersion})` : foundationModelName
|
||||
} else if (customModelId) {
|
||||
displayName = customModelId
|
||||
}
|
||||
|
||||
models.push({
|
||||
id: item.Id,
|
||||
name: displayName,
|
||||
description: item.Description
|
||||
})
|
||||
}
|
||||
logger.info(`Found ${endpoints.Result.Items.length} endpoints`)
|
||||
} else {
|
||||
const errorMsg = `Failed to fetch endpoints: ${endpointsResult.reason}`
|
||||
logger.warn(errorMsg)
|
||||
warnings.push(errorMsg)
|
||||
}
|
||||
|
||||
// If both failed, throw error
|
||||
if (foundationModelsResult.status === 'rejected' && endpointsResult.status === 'rejected') {
|
||||
throw new VolcengineServiceError('Failed to fetch both foundation models and endpoints')
|
||||
}
|
||||
|
||||
const total =
|
||||
(foundationModelsResult.status === 'fulfilled' ? foundationModelsResult.value.Result.TotalCount : 0) +
|
||||
(endpointsResult.status === 'fulfilled' ? endpointsResult.value.Result.TotalCount : 0)
|
||||
|
||||
logger.info(`Total models found: ${models.length}`)
|
||||
|
||||
return {
|
||||
models,
|
||||
total,
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to list Volcengine models:', error as Error)
|
||||
throw new VolcengineServiceError('Failed to list models', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for external use
|
||||
* This allows the renderer process to make direct API calls with proper authentication
|
||||
*/
|
||||
public getAuthHeaders = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
params: {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
query?: Record<string, string>
|
||||
body?: string
|
||||
service?: string
|
||||
region?: string
|
||||
}
|
||||
): Promise<SignedHeaders> => {
|
||||
const credentials = await this.loadCredentials()
|
||||
if (!credentials) {
|
||||
throw new VolcengineServiceError('No credentials found. Please save credentials first.')
|
||||
}
|
||||
|
||||
return this.generateSignature(
|
||||
{
|
||||
method: params.method,
|
||||
host: params.host,
|
||||
path: params.path,
|
||||
query: params.query || {},
|
||||
headers: {},
|
||||
body: params.body,
|
||||
service: params.service || CONFIG.SERVICE_NAME,
|
||||
region: params.region || CONFIG.DEFAULT_REGION
|
||||
},
|
||||
credentials
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a generic signed API request
|
||||
* This is a more flexible method that allows custom API calls
|
||||
*/
|
||||
public makeRequest = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
params: {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
action: string
|
||||
version: string
|
||||
query?: Record<string, string>
|
||||
body?: Record<string, unknown>
|
||||
service?: string
|
||||
region?: string
|
||||
}
|
||||
): Promise<unknown> => {
|
||||
return this.makeSignedRequest(
|
||||
params.method,
|
||||
params.host,
|
||||
params.path,
|
||||
params.action,
|
||||
params.version,
|
||||
params.query,
|
||||
params.body,
|
||||
params.service || CONFIG.SERVICE_NAME,
|
||||
params.region || CONFIG.DEFAULT_REGION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default new VolcengineService()
|
||||
700
src/main/services/__tests__/VolcengineService.test.ts
Normal file
700
src/main/services/__tests__/VolcengineService.test.ts
Normal file
@ -0,0 +1,700 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock dependencies
|
||||
// Mock fs first before any imports
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual('fs')
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => false),
|
||||
promises: {
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
chmod: vi.fn()
|
||||
}
|
||||
},
|
||||
existsSync: vi.fn(() => false),
|
||||
promises: {
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
chmod: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/test/userData')
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((str: string) => Buffer.from(`encrypted:${str}`)),
|
||||
decryptString: vi.fn((buffer: Buffer) => {
|
||||
const str = buffer.toString()
|
||||
if (str.startsWith('encrypted:')) {
|
||||
return str.substring('encrypted:'.length)
|
||||
}
|
||||
throw new Error('Invalid encrypted data')
|
||||
})
|
||||
},
|
||||
net: {
|
||||
fetch: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils/file', () => ({
|
||||
getConfigDir: vi.fn(() => '/test/config')
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { net, safeStorage } from 'electron'
|
||||
|
||||
import VolcengineService, {
|
||||
_buildCanonicalHeaders,
|
||||
_buildCanonicalQueryString,
|
||||
_createCanonicalRequest,
|
||||
_createStringToSign,
|
||||
_deriveSigningKey,
|
||||
_hmacSha256,
|
||||
_hmacSha256Hex,
|
||||
_sha256Hash,
|
||||
_uriEncode
|
||||
} from '../VolcengineService'
|
||||
|
||||
const service = VolcengineService
|
||||
|
||||
describe('VolcengineService', () => {
|
||||
const mockEvent = {} as Electron.IpcMainInvokeEvent
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Cryptographic Helper Methods', () => {
|
||||
describe('sha256Hash', () => {
|
||||
it('should correctly hash a string', () => {
|
||||
const input = 'test string'
|
||||
const expectedHash = crypto.createHash('sha256').update(input).digest('hex')
|
||||
|
||||
const result = _sha256Hash(input)
|
||||
|
||||
expect(result).toBe(expectedHash)
|
||||
})
|
||||
|
||||
it('should correctly hash a buffer', () => {
|
||||
const input = Buffer.from('test buffer')
|
||||
const expectedHash = crypto.createHash('sha256').update(input).digest('hex')
|
||||
|
||||
const result = _sha256Hash(input)
|
||||
|
||||
expect(result).toBe(expectedHash)
|
||||
})
|
||||
|
||||
it('should hash empty string', () => {
|
||||
const expectedHash = crypto.createHash('sha256').update('').digest('hex')
|
||||
|
||||
const result = _sha256Hash('')
|
||||
|
||||
expect(result).toBe(expectedHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hmacSha256', () => {
|
||||
it('should correctly compute HMAC-SHA256 with string key', () => {
|
||||
const key = 'secret'
|
||||
const data = 'message'
|
||||
const expectedHmac = crypto.createHmac('sha256', key).update(data, 'utf8').digest()
|
||||
|
||||
const result = _hmacSha256(key, data)
|
||||
|
||||
expect(result.equals(expectedHmac)).toBe(true)
|
||||
})
|
||||
|
||||
it('should correctly compute HMAC-SHA256 with buffer key', () => {
|
||||
const key = Buffer.from('secret')
|
||||
const data = 'message'
|
||||
const expectedHmac = crypto.createHmac('sha256', key).update(data, 'utf8').digest()
|
||||
|
||||
const result = _hmacSha256(key, data)
|
||||
|
||||
expect(result.equals(expectedHmac)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hmacSha256Hex', () => {
|
||||
it('should correctly compute HMAC-SHA256 and return hex string', () => {
|
||||
const key = 'secret'
|
||||
const data = 'message'
|
||||
const expectedHex = crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex')
|
||||
|
||||
const result = _hmacSha256Hex(key, data)
|
||||
|
||||
expect(result).toBe(expectedHex)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Encoding (RFC3986)', () => {
|
||||
describe('uriEncode', () => {
|
||||
it('should encode special characters', () => {
|
||||
const input = 'hello world@#$%^&*()'
|
||||
const result = _uriEncode(input)
|
||||
|
||||
// RFC3986 unreserved: A-Z a-z 0-9 - _ . ~
|
||||
// encodeURIComponent encodes most special chars except ! ' ( ) *
|
||||
expect(result).toContain('hello%20world')
|
||||
expect(result).toContain('%40') // @
|
||||
expect(result).toContain('%23') // #
|
||||
expect(result).toContain('%24') // $
|
||||
})
|
||||
|
||||
it('should not encode unreserved characters', () => {
|
||||
const input = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~'
|
||||
const result = _uriEncode(input)
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it('should encode slash by default', () => {
|
||||
const input = 'path/to/resource'
|
||||
const result = _uriEncode(input)
|
||||
|
||||
expect(result).toBe('path%2Fto%2Fresource')
|
||||
})
|
||||
|
||||
it('should not encode slash when encodeSlash is false', () => {
|
||||
const input = 'path/to/resource'
|
||||
const result = _uriEncode(input, false)
|
||||
|
||||
expect(result).toBe('path/to/resource')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = _uriEncode('')
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should encode spaces as %20', () => {
|
||||
const input = 'hello world'
|
||||
const result = _uriEncode(input)
|
||||
|
||||
expect(result).toBe('hello%20world')
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const input = '你好世界'
|
||||
const result = _uriEncode(input)
|
||||
|
||||
expect(result).not.toBe(input)
|
||||
expect(result).toContain('%')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canonical Request Building', () => {
|
||||
describe('buildCanonicalQueryString', () => {
|
||||
it('should build sorted query string', () => {
|
||||
const query = {
|
||||
z: 'last',
|
||||
a: 'first',
|
||||
m: 'middle'
|
||||
}
|
||||
|
||||
const result = _buildCanonicalQueryString(query)
|
||||
|
||||
expect(result).toBe('a=first&m=middle&z=last')
|
||||
})
|
||||
|
||||
it('should handle empty query object', () => {
|
||||
const result = _buildCanonicalQueryString({})
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should URL encode keys and values', () => {
|
||||
const query = {
|
||||
'key with space': 'value with space',
|
||||
'special@#': 'chars$%^'
|
||||
}
|
||||
|
||||
const result = _buildCanonicalQueryString(query)
|
||||
|
||||
expect(result).toContain('key%20with%20space=value%20with%20space')
|
||||
expect(result).toContain('special%40%23=chars%24%25%5E')
|
||||
})
|
||||
|
||||
it('should handle single parameter', () => {
|
||||
const query = { action: 'ListModels' }
|
||||
|
||||
const result = _buildCanonicalQueryString(query)
|
||||
|
||||
expect(result).toBe('action=ListModels')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCanonicalHeaders', () => {
|
||||
it('should lowercase and sort header names', () => {
|
||||
// Headers should already be lowercase when passed to this method
|
||||
const headers = {
|
||||
'x-date': '20240101T120000Z',
|
||||
'content-type': 'application/json',
|
||||
host: 'example.com'
|
||||
}
|
||||
|
||||
const result = _buildCanonicalHeaders(headers)
|
||||
|
||||
expect(result.canonicalHeaders).toBe(
|
||||
'content-type:application/json\nhost:example.com\nx-date:20240101T120000Z\n'
|
||||
)
|
||||
expect(result.signedHeaders).toBe('content-type;host;x-date')
|
||||
})
|
||||
|
||||
it('should trim header values', () => {
|
||||
// Headers should already be lowercase when passed to this method
|
||||
const headers = {
|
||||
host: ' example.com ',
|
||||
'x-date': ' 20240101T120000Z '
|
||||
}
|
||||
|
||||
const result = _buildCanonicalHeaders(headers)
|
||||
|
||||
expect(result.canonicalHeaders).toBe('host:example.com\nx-date:20240101T120000Z\n')
|
||||
})
|
||||
|
||||
it('should handle empty header values', () => {
|
||||
// Headers should already be lowercase when passed to this method
|
||||
const headers = {
|
||||
host: 'example.com',
|
||||
'x-custom': ''
|
||||
}
|
||||
|
||||
const result = _buildCanonicalHeaders(headers)
|
||||
|
||||
expect(result.canonicalHeaders).toBe('host:example.com\nx-custom:\n')
|
||||
})
|
||||
|
||||
it('should handle mixed-case header keys', () => {
|
||||
const headers = {
|
||||
'X-Date': '20240101T120000Z',
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'example.com'
|
||||
}
|
||||
|
||||
const result = _buildCanonicalHeaders(headers)
|
||||
|
||||
expect(result.canonicalHeaders).toBe(
|
||||
'content-type:application/json\nhost:example.com\nx-date:20240101T120000Z\n'
|
||||
)
|
||||
expect(result.signedHeaders).toBe('content-type;host;x-date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSigningKey', () => {
|
||||
it('should derive signing key correctly', () => {
|
||||
const secretKey = 'testSecret'
|
||||
const date = '20240101'
|
||||
const region = 'cn-beijing'
|
||||
const serviceName = 'ark'
|
||||
|
||||
const result = _deriveSigningKey(secretKey, date, region, serviceName)
|
||||
|
||||
// The result should be a Buffer
|
||||
expect(Buffer.isBuffer(result)).toBe(true)
|
||||
|
||||
// The key derivation should be deterministic
|
||||
const result2 = _deriveSigningKey(secretKey, date, region, serviceName)
|
||||
expect(result.equals(result2)).toBe(true)
|
||||
})
|
||||
|
||||
it('should produce different keys for different inputs', () => {
|
||||
const secretKey = 'testSecret'
|
||||
const date = '20240101'
|
||||
const region = 'cn-beijing'
|
||||
const serviceName = 'ark'
|
||||
|
||||
const key1 = _deriveSigningKey(secretKey, date, region, serviceName)
|
||||
const key2 = _deriveSigningKey('differentSecret', date, region, serviceName)
|
||||
const key3 = _deriveSigningKey(secretKey, '20240102', region, serviceName)
|
||||
|
||||
expect(key1.equals(key2)).toBe(false)
|
||||
expect(key1.equals(key3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCanonicalRequest', () => {
|
||||
it('should create canonical request string correctly', () => {
|
||||
const method = 'POST'
|
||||
const canonicalUri = '/'
|
||||
const canonicalQueryString = 'Action=ListModels&Version=2024-01-01'
|
||||
const canonicalHeaders = 'host:open.volcengineapi.com\nx-date:20240101T120000Z\n'
|
||||
const signedHeaders = 'host;x-date'
|
||||
const payloadHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
|
||||
const result = _createCanonicalRequest(
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
)
|
||||
|
||||
const expected = [
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join('\n')
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStringToSign', () => {
|
||||
it('should create string to sign correctly', () => {
|
||||
const dateTime = '20240101T120000Z'
|
||||
const credentialScope = '20240101/cn-beijing/ark/request'
|
||||
const canonicalRequest = 'POST\n/\n\nhost:example.com\n\nhost\npayloadhash'
|
||||
|
||||
const result = _createStringToSign(dateTime, credentialScope, canonicalRequest)
|
||||
|
||||
const expectedHash = _sha256Hash(canonicalRequest)
|
||||
const expected = ['HMAC-SHA256', dateTime, credentialScope, expectedHash].join('\n')
|
||||
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Note: Signature generation is tested through the public getAuthHeaders method
|
||||
// This ensures the complete signature flow works correctly
|
||||
|
||||
describe('Credential Management', () => {
|
||||
describe('saveCredentials', () => {
|
||||
it('should save credentials using safeStorage', async () => {
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true)
|
||||
|
||||
await service.saveCredentials(mockEvent, 'testAccessKey', 'testSecretKey')
|
||||
|
||||
expect(safeStorage.encryptString).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
accessKeyId: 'testAccessKey',
|
||||
secretAccessKey: 'testSecretKey'
|
||||
})
|
||||
)
|
||||
expect(fs.promises.writeFile).toHaveBeenCalled()
|
||||
expect(fs.promises.chmod).toHaveBeenCalledWith(expect.any(String), 0o600)
|
||||
})
|
||||
|
||||
it('should throw error when credentials are empty', async () => {
|
||||
await expect(service.saveCredentials(mockEvent, '', 'secret')).rejects.toThrow('Failed to save credentials')
|
||||
|
||||
await expect(service.saveCredentials(mockEvent, 'key', '')).rejects.toThrow('Failed to save credentials')
|
||||
})
|
||||
|
||||
it('should throw error when safeStorage is not available', async () => {
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false)
|
||||
|
||||
await expect(service.saveCredentials(mockEvent, 'key', 'secret')).rejects.toThrow('Failed to save credentials')
|
||||
})
|
||||
|
||||
it('should create directory if it does not exist', async () => {
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true)
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
|
||||
|
||||
await service.saveCredentials(mockEvent, 'testAccessKey', 'testSecretKey')
|
||||
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true })
|
||||
})
|
||||
})
|
||||
|
||||
// loadCredentials is tested indirectly through public APIs like getAuthHeaders and listModels
|
||||
|
||||
describe('hasCredentials', () => {
|
||||
it('should return true when credentials file exists', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
|
||||
const result = await service.hasCredentials()
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when credentials file does not exist', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
|
||||
|
||||
const result = await service.hasCredentials()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearCredentials', () => {
|
||||
it('should delete credentials file when it exists', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
|
||||
await service.clearCredentials()
|
||||
|
||||
expect(fs.promises.unlink).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw error when credentials file does not exist', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
|
||||
|
||||
await expect(service.clearCredentials()).resolves.not.toThrow()
|
||||
expect(fs.promises.unlink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error when file deletion fails', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.unlink).mockRejectedValue(new Error('Permission denied'))
|
||||
|
||||
await expect(service.clearCredentials()).rejects.toThrow('Failed to clear credentials')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Methods', () => {
|
||||
describe('listModels', () => {
|
||||
it('should fetch and combine foundation models and endpoints', async () => {
|
||||
const mockFoundationModelsResponse = {
|
||||
Result: {
|
||||
TotalCount: 2,
|
||||
Items: [
|
||||
{ Name: 'model1', DisplayName: 'Model 1', Description: 'Test model 1' },
|
||||
{ Name: 'model2', DisplayName: 'Model 2', Description: 'Test model 2' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const mockEndpointsResponse = {
|
||||
Result: {
|
||||
TotalCount: 1,
|
||||
Items: [
|
||||
{
|
||||
Id: 'ep-123',
|
||||
Name: 'Custom Endpoint',
|
||||
Description: 'Custom endpoint',
|
||||
ModelReference: {
|
||||
FoundationModel: {
|
||||
Name: 'base-model',
|
||||
ModelVersion: 'v1.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
// Mock API calls
|
||||
vi.mocked(net.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockFoundationModelsResponse
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockEndpointsResponse
|
||||
} as any)
|
||||
|
||||
const result = await service.listModels(mockEvent)
|
||||
|
||||
expect(result.models).toHaveLength(3)
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.models[0].id).toBe('model1')
|
||||
expect(result.models[2].id).toBe('ep-123')
|
||||
})
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
const mockFoundationModelsResponse = {
|
||||
Result: {
|
||||
TotalCount: 1,
|
||||
Items: [{ Name: 'model1', DisplayName: 'Model 1' }]
|
||||
}
|
||||
}
|
||||
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
// Mock API calls - first succeeds, second fails
|
||||
vi.mocked(net.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockFoundationModelsResponse
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Server error'
|
||||
} as any)
|
||||
|
||||
const result = await service.listModels(mockEvent)
|
||||
|
||||
expect(result.models).toHaveLength(1)
|
||||
expect(result.warnings).toBeDefined()
|
||||
expect(result.warnings?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should throw error when both API calls fail', async () => {
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
// Mock both API calls to fail
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Server error'
|
||||
} as any)
|
||||
|
||||
await expect(service.listModels(mockEvent)).rejects.toThrow('Failed to list models')
|
||||
})
|
||||
|
||||
it('should throw error when no credentials are found', async () => {
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false)
|
||||
|
||||
await expect(service.listModels(mockEvent)).rejects.toThrow('Failed to list models')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthHeaders', () => {
|
||||
it('should generate auth headers for external use', async () => {
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
const params = {
|
||||
method: 'POST' as const,
|
||||
host: 'open.volcengineapi.com',
|
||||
path: '/v1/chat/completions',
|
||||
query: {},
|
||||
body: '{"model":"test"}'
|
||||
}
|
||||
|
||||
const result = await service.getAuthHeaders(mockEvent, params)
|
||||
|
||||
expect(result).toHaveProperty('Authorization')
|
||||
expect(result).toHaveProperty('X-Date')
|
||||
expect(result).toHaveProperty('X-Content-Sha256')
|
||||
expect(result).toHaveProperty('Host')
|
||||
})
|
||||
|
||||
it('should use default service and region when not provided', async () => {
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
const params = {
|
||||
method: 'POST' as const,
|
||||
host: 'open.volcengineapi.com',
|
||||
path: '/',
|
||||
query: {}
|
||||
}
|
||||
|
||||
const result = await service.getAuthHeaders(mockEvent, params)
|
||||
|
||||
// Should not throw and should generate headers
|
||||
expect(result).toHaveProperty('Authorization')
|
||||
expect(result.Authorization).toContain('cn-beijing/ark/request')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeRequest', () => {
|
||||
it('should make a generic signed API request', async () => {
|
||||
const mockResponse = { success: true, data: 'test' }
|
||||
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
} as any)
|
||||
|
||||
const params = {
|
||||
method: 'POST' as const,
|
||||
host: 'open.volcengineapi.com',
|
||||
path: '/',
|
||||
action: 'TestAction',
|
||||
version: '2024-01-01',
|
||||
query: {},
|
||||
body: { test: true }
|
||||
}
|
||||
|
||||
const result = await service.makeRequest(mockEvent, params)
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
expect(net.fetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle network errors in API requests', async () => {
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
vi.mocked(net.fetch).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(service.listModels(mockEvent)).rejects.toThrow('Failed to list models')
|
||||
})
|
||||
|
||||
it('should handle API error responses', async () => {
|
||||
// Setup credentials
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true)
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(
|
||||
Buffer.from(`encrypted:${JSON.stringify({ accessKeyId: 'test', secretAccessKey: 'test' })}`)
|
||||
)
|
||||
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'Unauthorized'
|
||||
} as any)
|
||||
|
||||
await expect(service.listModels(mockEvent)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -595,6 +595,41 @@ const api = {
|
||||
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
|
||||
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
|
||||
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
|
||||
},
|
||||
volcengine: {
|
||||
saveCredentials: (accessKeyId: string, secretAccessKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.Volcengine_SaveCredentials, accessKeyId, secretAccessKey),
|
||||
hasCredentials: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Volcengine_HasCredentials),
|
||||
clearCredentials: (): Promise<void> => ipcRenderer.invoke(IpcChannel.Volcengine_ClearCredentials),
|
||||
listModels: (
|
||||
projectName?: string,
|
||||
region?: string
|
||||
): Promise<{
|
||||
models: Array<{ id: string; name: string; description?: string; created?: number }>
|
||||
total?: number
|
||||
warnings?: string[]
|
||||
}> => ipcRenderer.invoke(IpcChannel.Volcengine_ListModels, projectName, region),
|
||||
getAuthHeaders: (params: {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
query?: Record<string, string>
|
||||
body?: string
|
||||
service?: string
|
||||
region?: string
|
||||
}): Promise<{ Authorization: string; 'X-Date': string; 'X-Content-Sha256': string; Host: string }> =>
|
||||
ipcRenderer.invoke(IpcChannel.Volcengine_GetAuthHeaders, params),
|
||||
makeRequest: (params: {
|
||||
method: 'GET' | 'POST'
|
||||
host: string
|
||||
path: string
|
||||
action: string
|
||||
version: string
|
||||
query?: Record<string, string>
|
||||
body?: Record<string, unknown>
|
||||
service?: string
|
||||
region?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke(IpcChannel.Volcengine_MakeRequest, params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
import { OVMSClient } from './ovms/OVMSClient'
|
||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||
import { VolcengineAPIClient } from './volcengine/VolcengineAPIClient'
|
||||
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
|
||||
|
||||
const logger = loggerService.withContext('ApiClientFactory')
|
||||
@ -64,6 +65,12 @@ export class ApiClientFactory {
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'doubao') {
|
||||
logger.debug(`Creating VolcengineAPIClient for provider: ${provider.id}`)
|
||||
instance = new VolcengineAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
|
||||
if (provider.id === 'ovms') {
|
||||
logger.debug(`Creating OVMSClient for provider: ${provider.id}`)
|
||||
instance = new OVMSClient(provider) as BaseApiClient
|
||||
|
||||
@ -69,7 +69,7 @@ export abstract class OpenAIBaseClient<
|
||||
const sdk = await this.getSdkInstance()
|
||||
const response = (await sdk.request({
|
||||
method: 'post',
|
||||
path: '/images/generations',
|
||||
path: '/v1/images/generations',
|
||||
signal,
|
||||
body: {
|
||||
model,
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import type OpenAI from '@cherrystudio/openai'
|
||||
import { loggerService } from '@logger'
|
||||
import { getVolcengineProjectName, getVolcengineRegion } from '@renderer/hooks/useVolcengine'
|
||||
import type { Provider } from '@renderer/types'
|
||||
|
||||
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||
|
||||
const logger = loggerService.withContext('VolcengineAPIClient')
|
||||
|
||||
/**
|
||||
* Volcengine (Doubao) API Client
|
||||
*
|
||||
* Extends OpenAIAPIClient for standard chat completions (OpenAI-compatible),
|
||||
* but overrides listModels to use Volcengine's signed API via IPC.
|
||||
*/
|
||||
export class VolcengineAPIClient extends OpenAIAPIClient {
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* List models using Volcengine's signed API
|
||||
* This calls the main process VolcengineService which handles HMAC-SHA256 signing
|
||||
*/
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
try {
|
||||
const hasCredentials = await window.api.volcengine.hasCredentials()
|
||||
|
||||
if (!hasCredentials) {
|
||||
logger.info('Volcengine credentials not configured, falling back to OpenAI-compatible list')
|
||||
// Fall back to standard OpenAI-compatible API if no Volcengine credentials
|
||||
return super.listModels()
|
||||
}
|
||||
|
||||
logger.info('Fetching models from Volcengine API using signed request')
|
||||
|
||||
const projectName = getVolcengineProjectName()
|
||||
const region = getVolcengineRegion()
|
||||
const response = await window.api.volcengine.listModels(projectName, region)
|
||||
|
||||
if (!response || !response.models) {
|
||||
logger.warn('Empty response from Volcengine listModels')
|
||||
return []
|
||||
}
|
||||
|
||||
// Notify user of any partial failures
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
for (const warning of response.warnings) {
|
||||
logger.warn(warning)
|
||||
}
|
||||
window.toast?.warning('Some Volcengine models could not be fetched. Check logs for details.')
|
||||
}
|
||||
|
||||
const models: OpenAI.Models.Model[] = response.models.map((model) => ({
|
||||
id: model.id,
|
||||
object: 'model' as const,
|
||||
created: model.created || Math.floor(Date.now() / 1000),
|
||||
owned_by: 'volcengine',
|
||||
// @ts-ignore - description is used by UI to display model name
|
||||
name: model.name || model.id
|
||||
}))
|
||||
|
||||
logger.info(`Found ${models.length} models from Volcengine API`)
|
||||
return models
|
||||
} catch (error) {
|
||||
logger.error('Failed to list Volcengine models:', error as Error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Credential errors should not fall back - user must fix
|
||||
if (errorMessage.includes('could not be loaded') || errorMessage.includes('credentials')) {
|
||||
window.toast?.error('Volcengine credentials error. Please re-enter your credentials in settings.')
|
||||
throw error
|
||||
}
|
||||
|
||||
// Auth errors should not fall back
|
||||
if (errorMessage.includes('401') || errorMessage.includes('403')) {
|
||||
window.toast?.error('Volcengine authentication failed. Please verify your Access Key.')
|
||||
throw error
|
||||
}
|
||||
|
||||
// Only fall back for transient network errors
|
||||
window.toast?.warning('Temporarily unable to fetch Volcengine models. Using fallback.')
|
||||
logger.info('Falling back to OpenAI-compatible model list')
|
||||
return super.listModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,7 +79,7 @@ vi.mock('@renderer/services/AssistantService', () => ({
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import type { Model, Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provider'
|
||||
import { isAzureOpenAIProvider, isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provider'
|
||||
|
||||
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
|
||||
import { getActualProvider, providerToAiSdkConfig } from '../providerConfig'
|
||||
@ -133,6 +133,17 @@ const createPerplexityProvider = (): Provider => ({
|
||||
isSystem: false
|
||||
})
|
||||
|
||||
const createAzureProvider = (apiVersion: string): Provider => ({
|
||||
id: 'azure-openai',
|
||||
type: 'azure-openai',
|
||||
name: 'Azure OpenAI',
|
||||
apiKey: 'test-key',
|
||||
apiHost: 'https://example.openai.azure.com/openai',
|
||||
apiVersion,
|
||||
models: [],
|
||||
isSystem: true
|
||||
})
|
||||
|
||||
describe('Copilot responses routing', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as any).window = {
|
||||
@ -504,3 +515,46 @@ describe('Stream options includeUsage configuration', () => {
|
||||
expect(config.providerId).toBe('github-copilot-openai-compatible')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Azure OpenAI traditional API routing', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as any).window = {
|
||||
...(globalThis as any).window,
|
||||
keyv: createWindowKeyv()
|
||||
}
|
||||
mockGetState.mockReturnValue({
|
||||
settings: {
|
||||
openAI: {
|
||||
streamOptions: {
|
||||
includeUsage: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mocked(isAzureOpenAIProvider).mockImplementation((provider) => provider.type === 'azure-openai')
|
||||
})
|
||||
|
||||
it('uses deployment-based URLs when apiVersion is a date version', () => {
|
||||
const provider = createAzureProvider('2024-02-15-preview')
|
||||
const config = providerToAiSdkConfig(provider, createModel('gpt-4o', 'GPT-4o', provider.id))
|
||||
|
||||
expect(config.providerId).toBe('azure')
|
||||
expect(config.options.apiVersion).toBe('2024-02-15-preview')
|
||||
expect(config.options.useDeploymentBasedUrls).toBe(true)
|
||||
})
|
||||
|
||||
it('does not force deployment-based URLs for apiVersion v1/preview', () => {
|
||||
const v1Provider = createAzureProvider('v1')
|
||||
const v1Config = providerToAiSdkConfig(v1Provider, createModel('gpt-4o', 'GPT-4o', v1Provider.id))
|
||||
expect(v1Config.providerId).toBe('azure-responses')
|
||||
expect(v1Config.options.apiVersion).toBe('v1')
|
||||
expect(v1Config.options.useDeploymentBasedUrls).toBeUndefined()
|
||||
|
||||
const previewProvider = createAzureProvider('preview')
|
||||
const previewConfig = providerToAiSdkConfig(previewProvider, createModel('gpt-4o', 'GPT-4o', previewProvider.id))
|
||||
expect(previewConfig.providerId).toBe('azure-responses')
|
||||
expect(previewConfig.options.apiVersion).toBe('preview')
|
||||
expect(previewConfig.options.useDeploymentBasedUrls).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -214,6 +214,15 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
} else if (aiSdkProviderId === 'azure') {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
if (isAzureOpenAIProvider(actualProvider)) {
|
||||
const apiVersion = actualProvider.apiVersion?.trim()
|
||||
if (apiVersion) {
|
||||
extraOptions.apiVersion = apiVersion
|
||||
if (!['preview', 'v1'].includes(apiVersion)) {
|
||||
extraOptions.useDeploymentBasedUrls = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bedrock
|
||||
if (aiSdkProviderId === 'bedrock') {
|
||||
|
||||
@ -36,7 +36,7 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import type { Assistant, Model } from '@renderer/types'
|
||||
import type { Assistant, Model, ReasoningEffortOption } from '@renderer/types'
|
||||
import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types'
|
||||
import type { OpenAIReasoningSummary } from '@renderer/types/aiCoreTypes'
|
||||
import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk'
|
||||
@ -539,20 +539,25 @@ export function getAnthropicReasoningParams(
|
||||
return {}
|
||||
}
|
||||
|
||||
// type GoogleThinkingLevel = NonNullable<GoogleGenerativeAIProviderOptions['thinkingConfig']>['thinkingLevel']
|
||||
type GoogleThinkingLevel = NonNullable<GoogleGenerativeAIProviderOptions['thinkingConfig']>['thinkingLevel']
|
||||
|
||||
// function mapToGeminiThinkingLevel(reasoningEffort: ReasoningEffortOption): GoogelThinkingLevel {
|
||||
// switch (reasoningEffort) {
|
||||
// case 'low':
|
||||
// return 'low'
|
||||
// case 'medium':
|
||||
// return 'medium'
|
||||
// case 'high':
|
||||
// return 'high'
|
||||
// default:
|
||||
// return 'medium'
|
||||
// }
|
||||
// }
|
||||
function mapToGeminiThinkingLevel(reasoningEffort: ReasoningEffortOption): GoogleThinkingLevel {
|
||||
switch (reasoningEffort) {
|
||||
case 'default':
|
||||
return undefined
|
||||
case 'minimal':
|
||||
return 'minimal'
|
||||
case 'low':
|
||||
return 'low'
|
||||
case 'medium':
|
||||
return 'medium'
|
||||
case 'high':
|
||||
return 'high'
|
||||
default:
|
||||
logger.warn('Unknown thinking level for Gemini. Fallback to medium instead.', { reasoningEffort })
|
||||
return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Gemini 推理参数
|
||||
@ -585,15 +590,15 @@ export function getGeminiReasoningParams(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 很多中转还不支持
|
||||
// https://ai.google.dev/gemini-api/docs/gemini-3?thinking=high#new_api_features_in_gemini_3
|
||||
// if (isGemini3ThinkingTokenModel(model)) {
|
||||
// return {
|
||||
// thinkingConfig: {
|
||||
// thinkingLevel: mapToGeminiThinkingLevel(reasoningEffort)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if (isGemini3ThinkingTokenModel(model)) {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingLevel: mapToGeminiThinkingLevel(reasoningEffort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
|
||||
|
||||
@ -695,15 +695,20 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
|
||||
})
|
||||
|
||||
describe('Gemini models', () => {
|
||||
it('should return gemini for Flash models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest' }))).toBe('gemini')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-flash-latest' }))).toBe('gemini')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-flash-lite-latest' }))).toBe('gemini')
|
||||
it('should return gemini2_flash for Flash models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest' }))).toBe('gemini2_flash')
|
||||
})
|
||||
it('should return gemini3_flash for Gemini 3 Flash models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-3-flash-preview' }))).toBe('gemini3_flash')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-flash-latest' }))).toBe('gemini3_flash')
|
||||
})
|
||||
|
||||
it('should return gemini_pro for Pro models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-pro-latest' }))).toBe('gemini_pro')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-pro-latest' }))).toBe('gemini_pro')
|
||||
it('should return gemini2_pro for Gemini 2.5 Pro models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-pro-latest' }))).toBe('gemini2_pro')
|
||||
})
|
||||
it('should return gemini3_pro for Gemini 3 Pro models', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-3-pro-preview' }))).toBe('gemini3_pro')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-pro-latest' }))).toBe('gemini3_pro')
|
||||
})
|
||||
})
|
||||
|
||||
@ -810,7 +815,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
|
||||
name: 'gemini-2.5-flash-latest'
|
||||
})
|
||||
)
|
||||
).toBe('gemini')
|
||||
).toBe('gemini2_flash')
|
||||
})
|
||||
|
||||
it('should use id result when id matches', () => {
|
||||
@ -835,7 +840,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
|
||||
|
||||
it('should handle case insensitivity correctly', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'GPT-5.1' }))).toBe('gpt5_1')
|
||||
expect(getThinkModelType(createModel({ id: 'Gemini-2.5-Flash-Latest' }))).toBe('gemini')
|
||||
expect(getThinkModelType(createModel({ id: 'Gemini-2.5-Flash-Latest' }))).toBe('gemini2_flash')
|
||||
expect(getThinkModelType(createModel({ id: 'DeepSeek-V3.1' }))).toBe('deepseek_hybrid')
|
||||
})
|
||||
|
||||
@ -855,7 +860,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
|
||||
it('should handle models with version suffixes', () => {
|
||||
expect(getThinkModelType(createModel({ id: 'gpt-5-preview-2024' }))).toBe('gpt5')
|
||||
expect(getThinkModelType(createModel({ id: 'o3-mini-2024' }))).toBe('o')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest-001' }))).toBe('gemini')
|
||||
expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest-001' }))).toBe('gemini2_flash')
|
||||
})
|
||||
|
||||
it('should prioritize GPT-5.1 over GPT-5 detection', () => {
|
||||
@ -955,6 +960,14 @@ describe('Gemini Models', () => {
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-flash-preview',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'google/gemini-3-pro-preview',
|
||||
@ -996,6 +1009,31 @@ describe('Gemini Models', () => {
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
// Version with date suffixes
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-flash-preview-09-2025',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-pro-preview-09-2025',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-flash-exp-1234',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
// Version with decimals
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
@ -1015,7 +1053,8 @@ describe('Gemini Models', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for gemini-3 image models', () => {
|
||||
it('should return true for gemini-3-pro-image models only', () => {
|
||||
// Only gemini-3-pro-image models should return true
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-pro-image-preview',
|
||||
@ -1024,6 +1063,17 @@ describe('Gemini Models', () => {
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-pro-image',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other gemini-3 image models', () => {
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3.0-flash-image-preview',
|
||||
@ -1086,6 +1136,22 @@ describe('Gemini Models', () => {
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-flash-preview-tts',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSupportedThinkingTokenGeminiModel({
|
||||
id: 'gemini-3-pro-tts',
|
||||
name: '',
|
||||
provider: '',
|
||||
group: ''
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for older gemini models', () => {
|
||||
@ -1811,7 +1877,7 @@ describe('getModelSupportedReasoningEffortOptions', () => {
|
||||
|
||||
describe('Gemini models', () => {
|
||||
it('should return correct options for Gemini Flash models', () => {
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-flash-latest' }))).toEqual([
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-flash' }))).toEqual([
|
||||
'default',
|
||||
'none',
|
||||
'low',
|
||||
@ -1819,36 +1885,46 @@ describe('getModelSupportedReasoningEffortOptions', () => {
|
||||
'high',
|
||||
'auto'
|
||||
])
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-flash-latest' }))).toEqual([
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-flash-preview' }))).toEqual([
|
||||
'default',
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'auto'
|
||||
'high'
|
||||
])
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-flash-latest' }))).toEqual([
|
||||
'default',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return correct options for Gemini Pro models', () => {
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-pro-latest' }))).toEqual([
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-pro' }))).toEqual([
|
||||
'default',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'auto'
|
||||
])
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-pro-preview' }))).toEqual([
|
||||
'default',
|
||||
'low',
|
||||
'high'
|
||||
])
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-pro-latest' }))).toEqual([
|
||||
'default',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'auto'
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return correct options for Gemini 3 models', () => {
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-flash' }))).toEqual([
|
||||
'default',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high'
|
||||
@ -1856,7 +1932,6 @@ describe('getModelSupportedReasoningEffortOptions', () => {
|
||||
expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-pro-preview' }))).toEqual([
|
||||
'default',
|
||||
'low',
|
||||
'medium',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
@ -2078,7 +2153,7 @@ describe('getModelSupportedReasoningEffortOptions', () => {
|
||||
|
||||
const geminiModel = createModel({ id: 'gemini-2.5-flash-latest' })
|
||||
const geminiResult = getModelSupportedReasoningEffortOptions(geminiModel)
|
||||
expect(geminiResult).toEqual(MODEL_SUPPORTED_OPTIONS.gemini)
|
||||
expect(geminiResult).toEqual(MODEL_SUPPORTED_OPTIONS.gemini2_flash)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -20,6 +20,8 @@ import {
|
||||
getModelSupportedVerbosity,
|
||||
groupQwenModels,
|
||||
isAnthropicModel,
|
||||
isGemini3FlashModel,
|
||||
isGemini3ProModel,
|
||||
isGeminiModel,
|
||||
isGemmaModel,
|
||||
isGenerateImageModels,
|
||||
@ -432,6 +434,101 @@ describe('model utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGemini3FlashModel', () => {
|
||||
it('detects gemini-3-flash model', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-flash-preview model', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-flash with version suffixes', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-latest' }))).toBe(true)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-preview-09-2025' }))).toBe(true)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-exp-1234' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-flash-latest alias', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-flash-latest' }))).toBe(true)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'Gemini-Flash-Latest' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-flash with uppercase', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'Gemini-3-Flash' }))).toBe(true)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'GEMINI-3-FLASH-PREVIEW' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes gemini-3-flash-image models', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-image-preview' }))).toBe(false)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-flash-image' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-flash gemini-3 models', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-pro' }))).toBe(false)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-pro-preview' }))).toBe(false)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-3-pro-image-preview' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for other gemini models', () => {
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-2-flash' }))).toBe(false)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-2-flash-preview' }))).toBe(false)
|
||||
expect(isGemini3FlashModel(createModel({ id: 'gemini-2.5-flash-preview-09-2025' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null/undefined models', () => {
|
||||
expect(isGemini3FlashModel(null)).toBe(false)
|
||||
expect(isGemini3FlashModel(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGemini3ProModel', () => {
|
||||
it('detects gemini-3-pro model', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-pro-preview model', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-pro with version suffixes', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-latest' }))).toBe(true)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-preview-09-2025' }))).toBe(true)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-exp-1234' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-pro-latest alias', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-pro-latest' }))).toBe(true)
|
||||
expect(isGemini3ProModel(createModel({ id: 'Gemini-Pro-Latest' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects gemini-3-pro with uppercase', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'Gemini-3-Pro' }))).toBe(true)
|
||||
expect(isGemini3ProModel(createModel({ id: 'GEMINI-3-PRO-PREVIEW' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes gemini-3-pro-image models', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-image-preview' }))).toBe(false)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-image' }))).toBe(false)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-pro-image-latest' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-pro gemini-3 models', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-flash' }))).toBe(false)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-3-flash-preview' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for other gemini models', () => {
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-2-pro' }))).toBe(false)
|
||||
expect(isGemini3ProModel(createModel({ id: 'gemini-2.5-pro-preview-09-2025' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null/undefined models', () => {
|
||||
expect(isGemini3ProModel(null)).toBe(false)
|
||||
expect(isGemini3ProModel(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isZhipuModel', () => {
|
||||
it('detects Zhipu models by provider', () => {
|
||||
expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true)
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
isOpenAIReasoningModel,
|
||||
isSupportedReasoningEffortOpenAIModel
|
||||
} from './openai'
|
||||
import { GEMINI_FLASH_MODEL_REGEX, isGemini3ThinkingTokenModel } from './utils'
|
||||
import { GEMINI_FLASH_MODEL_REGEX, isGemini3FlashModel, isGemini3ProModel } from './utils'
|
||||
import { isTextToImageModel } from './vision'
|
||||
|
||||
// Reasoning models
|
||||
@ -43,9 +43,10 @@ export const MODEL_SUPPORTED_REASONING_EFFORT = {
|
||||
gpt52pro: ['medium', 'high', 'xhigh'] as const,
|
||||
grok: ['low', 'high'] as const,
|
||||
grok4_fast: ['auto'] as const,
|
||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini3: ['low', 'medium', 'high'] as const,
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini2_flash: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini2_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||
gemini3_flash: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
gemini3_pro: ['low', 'high'] as const,
|
||||
qwen: ['low', 'medium', 'high'] as const,
|
||||
qwen_thinking: ['low', 'medium', 'high'] as const,
|
||||
doubao: ['auto', 'high'] as const,
|
||||
@ -73,9 +74,10 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
gpt52pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt52pro] as const,
|
||||
grok: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.grok] as const,
|
||||
grok4_fast: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
||||
gemini: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
gemini_pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro] as const,
|
||||
gemini3: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3] as const,
|
||||
gemini2_flash: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini2_flash] as const,
|
||||
gemini2_pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini2_pro] as const,
|
||||
gemini3_flash: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3_flash] as const,
|
||||
gemini3_pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini3_pro] as const,
|
||||
qwen: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||
qwen_thinking: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking] as const,
|
||||
doubao: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||
@ -102,8 +104,7 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return 'openai_deep_research'
|
||||
}
|
||||
if (isGPT51SeriesModel(model)) {
|
||||
} else if (isGPT51SeriesModel(model)) {
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_1_codex'
|
||||
if (isGPT51CodexMaxModel(model)) {
|
||||
@ -131,16 +132,18 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
} else if (isGrok4FastReasoningModel(model)) {
|
||||
thinkingModelType = 'grok4_fast'
|
||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
thinkingModelType = 'gemini'
|
||||
if (isGemini3FlashModel(model)) {
|
||||
thinkingModelType = 'gemini3_flash'
|
||||
} else if (isGemini3ProModel(model)) {
|
||||
thinkingModelType = 'gemini3_pro'
|
||||
} else if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
thinkingModelType = 'gemini2_flash'
|
||||
} else {
|
||||
thinkingModelType = 'gemini_pro'
|
||||
thinkingModelType = 'gemini2_pro'
|
||||
}
|
||||
if (isGemini3ThinkingTokenModel(model)) {
|
||||
thinkingModelType = 'gemini3'
|
||||
}
|
||||
} else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok'
|
||||
else if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
} else if (isSupportedReasoningEffortGrokModel(model)) {
|
||||
thinkingModelType = 'grok'
|
||||
} else if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
if (isQwenAlwaysThinkModel(model)) {
|
||||
thinkingModelType = 'qwen_thinking'
|
||||
}
|
||||
@ -153,11 +156,17 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
} else {
|
||||
thinkingModelType = 'doubao_no_auto'
|
||||
}
|
||||
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
|
||||
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
|
||||
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
|
||||
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
|
||||
else if (isSupportedThinkingTokenMiMoModel(model)) thinkingModelType = 'mimo'
|
||||
} else if (isSupportedThinkingTokenHunyuanModel(model)) {
|
||||
thinkingModelType = 'hunyuan'
|
||||
} else if (isSupportedReasoningEffortPerplexityModel(model)) {
|
||||
thinkingModelType = 'perplexity'
|
||||
} else if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
thinkingModelType = 'zhipu'
|
||||
} else if (isDeepSeekHybridInferenceModel(model)) {
|
||||
thinkingModelType = 'deepseek_hybrid'
|
||||
} else if (isSupportedThinkingTokenMiMoModel(model)) {
|
||||
thinkingModelType = 'mimo'
|
||||
}
|
||||
return thinkingModelType
|
||||
}
|
||||
|
||||
|
||||
@ -267,3 +267,43 @@ export const isGemini3ThinkingTokenModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return isGemini3Model(model) && !modelId.includes('image')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is a Gemini 3 Flash model
|
||||
* Matches: gemini-3-flash, gemini-3-flash-preview, gemini-3-flash-preview-09-2025, gemini-flash-latest (alias)
|
||||
* Excludes: gemini-3-flash-image-preview
|
||||
* @param model - The model to check
|
||||
* @returns true if the model is a Gemini 3 Flash model
|
||||
*/
|
||||
export const isGemini3FlashModel = (model: Model | undefined | null): boolean => {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// Check for gemini-flash-latest alias (currently points to gemini-3-flash, may change in future)
|
||||
if (modelId === 'gemini-flash-latest') {
|
||||
return true
|
||||
}
|
||||
// Check for gemini-3-flash with optional suffixes, excluding image variants
|
||||
return /gemini-3-flash(?!-image)(?:-[\w-]+)*$/i.test(modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is a Gemini 3 Pro model
|
||||
* Matches: gemini-3-pro, gemini-3-pro-preview, gemini-3-pro-preview-09-2025, gemini-pro-latest (alias)
|
||||
* Excludes: gemini-3-pro-image-preview
|
||||
* @param model - The model to check
|
||||
* @returns true if the model is a Gemini 3 Pro model
|
||||
*/
|
||||
export const isGemini3ProModel = (model: Model | undefined | null): boolean => {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// Check for gemini-pro-latest alias (currently points to gemini-3-pro, may change in future)
|
||||
if (modelId === 'gemini-pro-latest') {
|
||||
return true
|
||||
}
|
||||
// Check for gemini-3-pro with optional suffixes, excluding image variants
|
||||
return /gemini-3-pro(?!-image)(?:-[\w-]+)*$/i.test(modelId)
|
||||
}
|
||||
|
||||
26
src/renderer/src/hooks/useVolcengine.ts
Normal file
26
src/renderer/src/hooks/useVolcengine.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { setVolcengineProjectName, setVolcengineRegion } from '@renderer/store/llm'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
export function useVolcengineSettings() {
|
||||
const settings = useAppSelector((state) => state.llm.settings.volcengine)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return {
|
||||
...settings,
|
||||
setRegion: (region: string) => dispatch(setVolcengineRegion(region)),
|
||||
setProjectName: (projectName: string) => dispatch(setVolcengineProjectName(projectName))
|
||||
}
|
||||
}
|
||||
|
||||
export function getVolcengineSettings() {
|
||||
return store.getState().llm.settings.volcengine
|
||||
}
|
||||
|
||||
export function getVolcengineRegion() {
|
||||
return store.getState().llm.settings.volcengine.region
|
||||
}
|
||||
|
||||
export function getVolcengineProjectName() {
|
||||
return store.getState().llm.settings.volcengine.projectName
|
||||
}
|
||||
@ -4601,6 +4601,25 @@
|
||||
"private_key_placeholder": "Enter Service Account private key",
|
||||
"title": "Service Account Configuration"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Access Key ID",
|
||||
"access_key_id_help": "Your Volcengine Access Key ID",
|
||||
"clear_credentials": "Clear Credentials",
|
||||
"credentials_cleared": "Credentials cleared",
|
||||
"credentials_required": "Please fill in Access Key ID and Secret Access Key",
|
||||
"credentials_save_failed": "Failed to save credentials",
|
||||
"credentials_saved": "Credentials saved",
|
||||
"credentials_saved_notice": "Credentials have been securely saved. Clear credentials to update them.",
|
||||
"description": "Volcengine is ByteDance's cloud service platform, providing Doubao and other large language model services. Please use IAM user's Access Key for authentication, do not use the root user credentials.",
|
||||
"project_name": "Project Name",
|
||||
"project_name_help": "Project name for endpoint filtering, default is 'default'",
|
||||
"region": "Region",
|
||||
"region_help": "Service region, e.g., cn-beijing",
|
||||
"save_credentials": "Save Credentials",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "Your Volcengine Secret Access Key, please keep it secure",
|
||||
"title": "Volcengine Configuration"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -560,7 +560,7 @@
|
||||
"medium": "斟酌",
|
||||
"medium_description": "中强度推理",
|
||||
"minimal": "微念",
|
||||
"minimal_description": "最小程度的思考",
|
||||
"minimal_description": "最小程度的推理",
|
||||
"off": "关闭",
|
||||
"off_description": "禁用推理",
|
||||
"xhigh": "穷究",
|
||||
@ -4601,6 +4601,25 @@
|
||||
"private_key_placeholder": "请输入 Service Account 私钥",
|
||||
"title": "Service Account 配置"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Access Key ID",
|
||||
"access_key_id_help": "您的火山引擎 Access Key ID",
|
||||
"clear_credentials": "清除凭证",
|
||||
"credentials_cleared": "凭证已清除",
|
||||
"credentials_required": "请填写 Access Key ID 和 Secret Access Key",
|
||||
"credentials_save_failed": "凭证保存失败",
|
||||
"credentials_saved": "凭证已保存",
|
||||
"credentials_saved_notice": "凭证已安全保存。如需更新,请先清除凭证。",
|
||||
"description": "火山引擎是字节跳动旗下的云服务平台,提供豆包等大语言模型服务。请使用 IAM 子用户的 Access Key 进行身份验证,不要使用主账号的根用户密钥。",
|
||||
"project_name": "项目名称",
|
||||
"project_name_help": "用于筛选推理接入点的项目名称,默认为 'default'",
|
||||
"region": "地域",
|
||||
"region_help": "服务地域,例如 cn-beijing",
|
||||
"save_credentials": "保存凭证",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "您的火山引擎 Secret Access Key,请妥善保管",
|
||||
"title": "火山引擎配置"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,25 @@
|
||||
"private_key_placeholder": "輸入服務帳戶私密金鑰",
|
||||
"title": "服務帳戶設定"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Access Key ID",
|
||||
"access_key_id_help": "您的火山引擎 Access Key ID",
|
||||
"clear_credentials": "清除憑證",
|
||||
"credentials_cleared": "憑證已清除",
|
||||
"credentials_required": "請填寫 Access Key ID 和 Secret Access Key",
|
||||
"credentials_save_failed": "憑證儲存失敗",
|
||||
"credentials_saved": "憑證已儲存",
|
||||
"credentials_saved_notice": "憑證已安全儲存。如需更新,請先清除憑證。",
|
||||
"description": "火山引擎是字節跳動旗下的雲端服務平台,提供豆包等大型語言模型服務。請使用 IAM 子用戶的 Access Key 進行身份驗證,不要使用主帳號的根用戶密鑰。",
|
||||
"project_name": "專案名稱",
|
||||
"project_name_help": "用於篩選推論接入點的專案名稱,預設為 'default'",
|
||||
"region": "地區",
|
||||
"region_help": "服務地區,例如 cn-beijing",
|
||||
"save_credentials": "儲存憑證",
|
||||
"secret_access_key": "Secret Access Key",
|
||||
"secret_access_key_help": "您的火山引擎 Secret Access Key,請妥善保管",
|
||||
"title": "火山引擎設定"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Service Account-Privat-Schlüssel eingeben",
|
||||
"title": "Service Account-Konfiguration"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Zugangsschlüssel-ID",
|
||||
"access_key_id_help": "Ihre Volcengine-Zugriffsschlüssel-ID",
|
||||
"clear_credentials": "Berechtigungen löschen",
|
||||
"credentials_cleared": "Anmeldeinformationen gelöscht",
|
||||
"credentials_required": "Bitte geben Sie die Access-Key-ID und den Secret Access Key ein.",
|
||||
"credentials_save_failed": "Fehler beim Speichern der Anmeldeinformationen",
|
||||
"credentials_saved": "Anmeldedaten gespeichert",
|
||||
"description": "Volcengine ist die Cloud-Service-Plattform von ByteDance und bietet Doubao sowie weitere große Sprachmodell-Dienste an. Verwenden Sie den Access Key zur Authentifizierung, um die Modellliste abzurufen.",
|
||||
"project_name": "Projektname",
|
||||
"project_name_help": "Projektname für Endpunktfilterung, Standardwert ist 'default'",
|
||||
"region": "Region",
|
||||
"region_help": "Service-Region, z. B. cn-beijing",
|
||||
"save_credentials": "Anmeldedaten speichern",
|
||||
"secret_access_key": "Geheimer Zugangsschlüssel",
|
||||
"secret_access_key_help": "Ihr Volcengine Secret Access Key, bitte bewahren Sie ihn sicher auf.",
|
||||
"title": "Volcengine-Konfiguration"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Παρακαλώ εισάγετε το ιδιωτικό κλειδί του λογαριασμού υπηρεσίας",
|
||||
"title": "Διαμόρφωση λογαριασμού υπηρεσίας"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Αναγνωριστικό Κλειδιού Πρόσβασης",
|
||||
"access_key_id_help": "Το Αναγνωριστικό Κλειδιού Πρόσβασης του Volcengine σας",
|
||||
"clear_credentials": "Καθαρά Διαπιστευτήρια",
|
||||
"credentials_cleared": "Τα διαπιστευτήρια διαγράφηκαν",
|
||||
"credentials_required": "Παρακαλούμε συμπληρώστε το Access Key ID και το Secret Access Key",
|
||||
"credentials_save_failed": "Αποτυχία αποθήκευσης διαπιστευτηρίων",
|
||||
"credentials_saved": "Τα διαπιστευτήρια αποθηκεύτηκαν",
|
||||
"description": "Η Volcengine είναι η πλατφόρμα υπηρεσιών cloud της ByteDance, παρέχοντας το Doubao και άλλες υπηρεσίες μεγάλων γλωσσικών μοντέλων. Χρησιμοποιήστε το Access Key για πιστοποίηση για να ανακτήσετε τη λίστα μοντέλων.",
|
||||
"project_name": "Όνομα Έργου",
|
||||
"project_name_help": "Όνομα έργου για φιλτράρισμα τελικού σημείου, προεπιλογή είναι 'default'",
|
||||
"region": "Περιοχή",
|
||||
"region_help": "Περιοχή υπηρεσίας, π.χ. cn-beijing",
|
||||
"save_credentials": "Αποθήκευση Διαπιστευτηρίων",
|
||||
"secret_access_key": "Μυστικό Κλειδί Πρόσβασης",
|
||||
"secret_access_key_help": "Το μυστικό κλειδί πρόσβασής σας στο Volcengine, παρακαλώ διατηρήστε το ασφαλές",
|
||||
"title": "Διαμόρφωση Volcengine"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Ingrese la clave privada de Service Account",
|
||||
"title": "Configuración de Service Account"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "ID de clave de acceso",
|
||||
"access_key_id_help": "Tu ID de clave de acceso de Volcengine",
|
||||
"clear_credentials": "Credenciales Claras",
|
||||
"credentials_cleared": "Credenciales borradas",
|
||||
"credentials_required": "Por favor, complete el ID de clave de acceso y la clave de acceso secreta.",
|
||||
"credentials_save_failed": "Error al guardar las credenciales",
|
||||
"credentials_saved": "Credenciales guardadas",
|
||||
"description": "Volcengine es la plataforma de servicios en la nube de ByteDance, que proporciona Doubao y otros servicios de modelos de lenguaje de gran escala. Utiliza Access Key para la autenticación y obtener la lista de modelos.",
|
||||
"project_name": "Nombre del Proyecto",
|
||||
"project_name_help": "Nombre del proyecto para el filtrado de puntos de conexión, el valor predeterminado es 'default'",
|
||||
"region": "Región",
|
||||
"region_help": "Región de servicio, p. ej., cn-beijing",
|
||||
"save_credentials": "Guardar Credenciales",
|
||||
"secret_access_key": "Clave de acceso secreta",
|
||||
"secret_access_key_help": "Tu clave de acceso secreta de Volcengine, por favor mantenla segura.",
|
||||
"title": "Configuración de Volcengine"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Veuillez saisir la clé privée du compte de service",
|
||||
"title": "Configuration du compte de service"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "ID de clé d’accès",
|
||||
"access_key_id_help": "Votre ID de clé d'accès Volcengine",
|
||||
"clear_credentials": "Effacer les identifiants",
|
||||
"credentials_cleared": "Identifiants effacés",
|
||||
"credentials_required": "Veuillez renseigner l'ID de clé d'accès et la clé d'accès secrète",
|
||||
"credentials_save_failed": "Échec de l'enregistrement des identifiants",
|
||||
"credentials_saved": "Identifiants enregistrés",
|
||||
"description": "Volcengine est la plateforme de services cloud de ByteDance, fournissant des services de modèles de langage de grande taille tels que Doubao. Utilisez la clé d'accès pour l'authentification afin de récupérer la liste des modèles.",
|
||||
"project_name": "Nom du projet",
|
||||
"project_name_help": "Nom du projet pour le filtrage des points de terminaison, la valeur par défaut est 'default'",
|
||||
"region": "Région",
|
||||
"region_help": "Région de service, par ex. cn-beijing",
|
||||
"save_credentials": "Enregistrer les identifiants",
|
||||
"secret_access_key": "Clé d'accès secrète",
|
||||
"secret_access_key_help": "Votre clé d'accès secrète Volcengine, veuillez la conserver en sécurité",
|
||||
"title": "Configuration Volcengine"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "サービスアカウントの秘密鍵を入力してください",
|
||||
"title": "サービスアカウント設定"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "アクセスキーID",
|
||||
"access_key_id_help": "あなたのVolcengineアクセスキーID",
|
||||
"clear_credentials": "資格証明書をクリア",
|
||||
"credentials_cleared": "資格情報がクリアされました",
|
||||
"credentials_required": "アクセスキーIDとシークレットアクセスキーを入力してください",
|
||||
"credentials_save_failed": "認証情報の保存に失敗しました",
|
||||
"credentials_saved": "認証情報が保存されました",
|
||||
"description": "VolcengineはByteDanceのクラウドサービスプラットフォームで、Doubaoやその他の大規模言語モデルサービスを提供しています。認証にはアクセスキーを使用してモデルリストを取得します。",
|
||||
"project_name": "プロジェクト名",
|
||||
"project_name_help": "エンドポイントフィルタリング用のプロジェクト名、デフォルトは「default」",
|
||||
"region": "地域",
|
||||
"region_help": "サービスリージョン、例: cn-beijing",
|
||||
"save_credentials": "資格情報を保存",
|
||||
"secret_access_key": "シークレットアクセスキー",
|
||||
"secret_access_key_help": "あなたの Volcengine シークレットアクセスキーは、安全に保管してください。",
|
||||
"title": "Volcengine設定"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Por favor, insira a chave privada da Conta de Serviço",
|
||||
"title": "Configuração da Conta de Serviço"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "ID da Chave de Acesso",
|
||||
"access_key_id_help": "Seu ID de Chave de Acesso Volcengine",
|
||||
"clear_credentials": "Credenciais Limpas",
|
||||
"credentials_cleared": "Credenciais limpas",
|
||||
"credentials_required": "Por favor, preencha o ID da Chave de Acesso e a Chave de Acesso Secreta",
|
||||
"credentials_save_failed": "Falha ao salvar as credenciais",
|
||||
"credentials_saved": "Credenciais salvas",
|
||||
"description": "Volcengine é a plataforma de serviços em nuvem da ByteDance, fornecendo o Doubao e outros serviços de grandes modelos de linguagem. Use a Access Key para autenticação ao obter a lista de modelos.",
|
||||
"project_name": "Nome do Projeto",
|
||||
"project_name_help": "Nome do projeto para filtragem de endpoint, o padrão é 'default'",
|
||||
"region": "Região",
|
||||
"region_help": "Região de serviço, por exemplo, cn-beijing",
|
||||
"save_credentials": "Salvar Credenciais",
|
||||
"secret_access_key": "Chave de Acesso Secreta",
|
||||
"secret_access_key_help": "Sua Chave de Acesso Secreta da Volcengine, mantenha-a segura",
|
||||
"title": "Configuração do Volcengine"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -4601,6 +4601,24 @@
|
||||
"private_key_placeholder": "Введите приватный ключ Service Account",
|
||||
"title": "Конфигурация Service Account"
|
||||
}
|
||||
},
|
||||
"volcengine": {
|
||||
"access_key_id": "Идентификатор ключа доступа",
|
||||
"access_key_id_help": "Ваш идентификатор ключа доступа Volcengine",
|
||||
"clear_credentials": "Очистить учетные данные",
|
||||
"credentials_cleared": "Учетные данные очищены",
|
||||
"credentials_required": "Пожалуйста, введите идентификатор ключа доступа и секретный ключ доступа",
|
||||
"credentials_save_failed": "Не удалось сохранить учетные данные",
|
||||
"credentials_saved": "Учетные данные сохранены",
|
||||
"description": "Volcengine — это облачная платформа ByteDance, предоставляющая Doubao и другие сервисы крупных языковых моделей. Для аутентификации используйте Access Key, чтобы получить список моделей.",
|
||||
"project_name": "Название проекта",
|
||||
"project_name_help": "Имя проекта для фильтрации конечных точек, по умолчанию — «default»",
|
||||
"region": "Регион",
|
||||
"region_help": "Регион обслуживания, например, cn-beijing",
|
||||
"save_credentials": "Сохранить учетные данные",
|
||||
"secret_access_key": "Секретный ключ доступа",
|
||||
"secret_access_key_help": "Ваш секретный ключ доступа Volcengine, пожалуйста, храните его в безопасности",
|
||||
"title": "Конфигурация Volcengine"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@ -62,6 +62,7 @@ import OVMSSettings from './OVMSSettings'
|
||||
import ProviderOAuth from './ProviderOAuth'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
import VertexAISettings from './VertexAISettings'
|
||||
import VolcengineSettings from './VolcengineSettings'
|
||||
|
||||
interface Props {
|
||||
providerId: string
|
||||
@ -602,6 +603,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
|
||||
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings />}
|
||||
{provider.id === 'doubao' && <VolcengineSettings />}
|
||||
<ModelList providerId={provider.id} />
|
||||
</SettingContainer>
|
||||
)
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useVolcengineSettings } from '@renderer/hooks/useVolcengine'
|
||||
import { Alert, Button, Input, Space } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
|
||||
const accessKeyWebSite = 'https://console.volcengine.com/iam/identitymanage'
|
||||
|
||||
const VolcengineSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { region, projectName, setRegion, setProjectName } = useVolcengineSettings()
|
||||
|
||||
const [localAccessKeyId, setLocalAccessKeyId] = useState('')
|
||||
const [localSecretAccessKey, setLocalSecretAccessKey] = useState('')
|
||||
const [localRegion, setLocalRegion] = useState(region)
|
||||
const [localProjectName, setLocalProjectName] = useState(projectName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [hasCredentials, setHasCredentials] = useState(false)
|
||||
|
||||
// Check if credentials exist on mount
|
||||
useEffect(() => {
|
||||
window.api.volcengine
|
||||
.hasCredentials()
|
||||
.then(setHasCredentials)
|
||||
.catch((error) => {
|
||||
loggerService.withContext('VolcengineSettings').error('Failed to check credentials:', error as Error)
|
||||
window.toast?.error('Failed to check Volcengine credentials')
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Sync local state with store (only for region and projectName)
|
||||
useEffect(() => {
|
||||
setLocalRegion(region)
|
||||
setLocalProjectName(projectName)
|
||||
}, [region, projectName])
|
||||
|
||||
const handleSaveCredentials = useCallback(async () => {
|
||||
if (!localAccessKeyId || !localSecretAccessKey) {
|
||||
window.toast.error(t('settings.provider.volcengine.credentials_required'))
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
// Save credentials to secure storage via IPC first
|
||||
await window.api.volcengine.saveCredentials(localAccessKeyId, localSecretAccessKey)
|
||||
|
||||
// Only update Redux after IPC success (for region and projectName only)
|
||||
setRegion(localRegion)
|
||||
setProjectName(localProjectName)
|
||||
|
||||
setHasCredentials(true)
|
||||
// Clear local credential state after successful save (they're now in secure storage)
|
||||
setLocalAccessKeyId('')
|
||||
setLocalSecretAccessKey('')
|
||||
window.toast.success(t('settings.provider.volcengine.credentials_saved'))
|
||||
} catch (error) {
|
||||
loggerService.withContext('VolcengineSettings').error('Failed to save credentials:', error as Error)
|
||||
window.toast.error(t('settings.provider.volcengine.credentials_save_failed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [localAccessKeyId, localSecretAccessKey, localRegion, localProjectName, setRegion, setProjectName, t])
|
||||
|
||||
const handleClearCredentials = useCallback(async () => {
|
||||
try {
|
||||
await window.api.volcengine.clearCredentials()
|
||||
setLocalAccessKeyId('')
|
||||
setLocalSecretAccessKey('')
|
||||
setHasCredentials(false)
|
||||
window.toast.success(t('settings.provider.volcengine.credentials_cleared'))
|
||||
} catch (error) {
|
||||
loggerService.withContext('VolcengineSettings').error('Failed to clear credentials:', error as Error)
|
||||
window.toast.error(String(error))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.volcengine.title')}</SettingSubtitle>
|
||||
<Alert type="info" style={{ marginTop: 5 }} message={t('settings.provider.volcengine.description')} showIcon />
|
||||
|
||||
{!hasCredentials ? (
|
||||
<>
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.access_key_id')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localAccessKeyId}
|
||||
placeholder="Access Key ID"
|
||||
onChange={(e) => setLocalAccessKeyId(e.target.value)}
|
||||
style={{ marginTop: 5 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.access_key_id_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>
|
||||
{t('settings.provider.volcengine.secret_access_key')}
|
||||
</SettingSubtitle>
|
||||
<Input.Password
|
||||
value={localSecretAccessKey}
|
||||
placeholder="Secret Access Key"
|
||||
onChange={(e) => setLocalSecretAccessKey(e.target.value)}
|
||||
style={{ marginTop: 5 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack>
|
||||
<SettingHelpLink target="_blank" href={accessKeyWebSite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
</HStack>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.secret_access_key_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.region')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localRegion}
|
||||
placeholder="cn-beijing"
|
||||
onChange={(e) => setLocalRegion(e.target.value)}
|
||||
onBlur={() => setRegion(localRegion)}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.region_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
<SettingSubtitle style={{ marginTop: 15 }}>{t('settings.provider.volcengine.project_name')}</SettingSubtitle>
|
||||
<Input
|
||||
value={localProjectName}
|
||||
placeholder="default"
|
||||
onChange={(e) => setLocalProjectName(e.target.value)}
|
||||
onBlur={() => setProjectName(localProjectName)}
|
||||
style={{ marginTop: 5 }}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('settings.provider.volcengine.project_name_help')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
style={{ marginTop: 15 }}
|
||||
message={t('settings.provider.volcengine.credentials_saved_notice')}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<Space style={{ marginTop: 15 }}>
|
||||
<Button type="primary" onClick={handleSaveCredentials} loading={saving}>
|
||||
{t('settings.provider.volcengine.save_credentials')}
|
||||
</Button>
|
||||
{hasCredentials && (
|
||||
<Button danger onClick={handleClearCredentials}>
|
||||
{t('settings.provider.volcengine.clear_credentials')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolcengineSettings
|
||||
@ -74,7 +74,9 @@ export function getDefaultTranslateAssistant(
|
||||
throw new Error('Unknown target language')
|
||||
}
|
||||
|
||||
const reasoningEffort = getModelSupportedReasoningEffortOptions(model)?.[0]
|
||||
const supportedOptions = getModelSupportedReasoningEffortOptions(model)
|
||||
// disable reasoning if it could be disabled, otherwise no configuration
|
||||
const reasoningEffort = supportedOptions?.includes('none') ? 'none' : 'default'
|
||||
const settings = {
|
||||
temperature: 0.7,
|
||||
reasoning_effort: reasoningEffort,
|
||||
|
||||
@ -242,6 +242,10 @@ vi.mock('@renderer/store/llm.ts', () => {
|
||||
secretAccessKey: '',
|
||||
apiKey: '',
|
||||
region: ''
|
||||
},
|
||||
volcengine: {
|
||||
region: 'cn-beijing',
|
||||
projectName: 'default'
|
||||
}
|
||||
}
|
||||
} satisfies LlmState
|
||||
|
||||
@ -31,6 +31,10 @@ type LlmSettings = {
|
||||
apiKey: string
|
||||
region: string
|
||||
}
|
||||
volcengine: {
|
||||
region: string
|
||||
projectName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface LlmState {
|
||||
@ -75,6 +79,10 @@ export const initialState: LlmState = {
|
||||
secretAccessKey: '',
|
||||
apiKey: '',
|
||||
region: ''
|
||||
},
|
||||
volcengine: {
|
||||
region: 'cn-beijing',
|
||||
projectName: 'default'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -216,6 +224,12 @@ const llmSlice = createSlice({
|
||||
setAwsBedrockRegion: (state, action: PayloadAction<string>) => {
|
||||
state.settings.awsBedrock.region = action.payload
|
||||
},
|
||||
setVolcengineRegion: (state, action: PayloadAction<string>) => {
|
||||
state.settings.volcengine.region = action.payload
|
||||
},
|
||||
setVolcengineProjectName: (state, action: PayloadAction<string>) => {
|
||||
state.settings.volcengine.projectName = action.payload
|
||||
},
|
||||
updateModel: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
@ -257,6 +271,8 @@ export const {
|
||||
setAwsBedrockSecretAccessKey,
|
||||
setAwsBedrockApiKey,
|
||||
setAwsBedrockRegion,
|
||||
setVolcengineRegion,
|
||||
setVolcengineProjectName,
|
||||
updateModel
|
||||
} = llmSlice.actions
|
||||
|
||||
|
||||
@ -2961,6 +2961,9 @@ const migrateConfig = {
|
||||
}
|
||||
})
|
||||
state.llm.providers = moveProvider(state.llm.providers, SystemProviderIds.poe, 10)
|
||||
if (!state.llm.settings.volcengine) {
|
||||
state.llm.settings.volcengine = llmInitialState.settings.volcengine
|
||||
}
|
||||
logger.info('migrate 183 success')
|
||||
return state
|
||||
} catch (error) {
|
||||
|
||||
@ -94,9 +94,10 @@ const ThinkModelTypes = [
|
||||
'gpt52pro',
|
||||
'grok',
|
||||
'grok4_fast',
|
||||
'gemini',
|
||||
'gemini_pro',
|
||||
'gemini3',
|
||||
'gemini2_flash',
|
||||
'gemini2_pro',
|
||||
'gemini3_flash',
|
||||
'gemini3_pro',
|
||||
'qwen',
|
||||
'qwen_thinking',
|
||||
'doubao',
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { styleSheetSerializer } from 'jest-styled-components/serializer'
|
||||
import { expect, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const bufferModule = require('buffer')
|
||||
if (!bufferModule.SlowBuffer) {
|
||||
bufferModule.SlowBuffer = bufferModule.Buffer
|
||||
}
|
||||
|
||||
expect.addSnapshotSerializer(styleSheetSerializer)
|
||||
|
||||
// Mock LoggerService globally for renderer tests
|
||||
@ -48,3 +55,29 @@ vi.stubGlobal('api', {
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof globalThis.localStorage === 'undefined' || typeof (globalThis.localStorage as any).getItem !== 'function') {
|
||||
let store = new Map<string, string>()
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
clear: () => {
|
||||
store.clear()
|
||||
},
|
||||
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
||||
get length() {
|
||||
return store.size
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('localStorage', localStorageMock)
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
}
|
||||
}
|
||||
|
||||
111
yarn.lock
111
yarn.lock
@ -102,6 +102,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/anthropic@npm:2.0.56":
|
||||
version: 2.0.56
|
||||
resolution: "@ai-sdk/anthropic@npm:2.0.56"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.19"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/f2b6029c92443f831a2d124420e805d057668003067b1f677a4292d02f27aa3ad533374ea996d77ede7746a42c46fb94a8f2d8c0e7758a4555ea18c8b532052c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/azure@npm:^2.0.87":
|
||||
version: 2.0.87
|
||||
resolution: "@ai-sdk/azure@npm:2.0.87"
|
||||
@ -166,42 +178,42 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/google-vertex@npm:^3.0.79":
|
||||
version: 3.0.79
|
||||
resolution: "@ai-sdk/google-vertex@npm:3.0.79"
|
||||
"@ai-sdk/google-vertex@npm:^3.0.94":
|
||||
version: 3.0.94
|
||||
resolution: "@ai-sdk/google-vertex@npm:3.0.94"
|
||||
dependencies:
|
||||
"@ai-sdk/anthropic": "npm:2.0.49"
|
||||
"@ai-sdk/google": "npm:2.0.43"
|
||||
"@ai-sdk/anthropic": "npm:2.0.56"
|
||||
"@ai-sdk/google": "npm:2.0.49"
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.17"
|
||||
google-auth-library: "npm:^9.15.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.19"
|
||||
google-auth-library: "npm:^10.5.0"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/a86949b8d4a855409acdf7dc8d93ad9ea8ccf2bc3849acbe1ecbe4d6d66f06bcb5242f0df8eea24214e78732618b71ec8a019cbbeab16366f9ad3c860c5d8d30
|
||||
checksum: 10c0/68e2ee9e6525a5e43f90304980e64bf2a4227fd3ce74a7bf17e5ace094ea1bca8f8f18a8cc332a492fee4b912568a768f7479a4eed8148b84e7de1adf4104ad0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/google@npm:2.0.43":
|
||||
version: 2.0.43
|
||||
resolution: "@ai-sdk/google@npm:2.0.43"
|
||||
"@ai-sdk/google@npm:2.0.49":
|
||||
version: 2.0.49
|
||||
resolution: "@ai-sdk/google@npm:2.0.49"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.17"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.19"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/5a421a9746cf8cbdf3bb7fb49426453a4fe0e354ea55a0123e628afb7acf9bb19959d512c0f8e6d7dbefbfa7e1cef4502fc146149007258a8eeb57743ac5e9e5
|
||||
checksum: 10c0/f3f8acfcd956edc7d807d22963d5eff0f765418f1f2c7d18615955ccdfcebb4d43cc26ce1f712c6a53572f1d8becc0773311b77b1f1bf1af87d675c5f017d5a4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch":
|
||||
version: 2.0.43
|
||||
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch::version=2.0.43&hash=4dde1e"
|
||||
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch":
|
||||
version: 2.0.49
|
||||
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch::version=2.0.49&hash=406c25"
|
||||
dependencies:
|
||||
"@ai-sdk/provider": "npm:2.0.0"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.17"
|
||||
"@ai-sdk/provider-utils": "npm:3.0.19"
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
checksum: 10c0/4cfd17e9c47f2b742d8a0b1ca3532b4dc48753088363b74b01a042f63652174fa9a3fbf655a23f823974c673121dffbd2d192bb0c1bf158da4e2bf498fc76527
|
||||
checksum: 10c0/8d4d881583c2301dce8a4e3066af2ba7d99b30520b6219811f90271c93bf8a07dc23e752fa25ffd0e72c6ec56e97d40d32e04072a362accf7d01a745a2d2a352
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -10051,8 +10063,8 @@ __metadata:
|
||||
"@ai-sdk/anthropic": "npm:^2.0.49"
|
||||
"@ai-sdk/cerebras": "npm:^1.0.31"
|
||||
"@ai-sdk/gateway": "npm:^2.0.15"
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.43#~/.yarn/patches/@ai-sdk-google-npm-2.0.43-689ed559b3.patch"
|
||||
"@ai-sdk/google-vertex": "npm:^3.0.79"
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch"
|
||||
"@ai-sdk/google-vertex": "npm:^3.0.94"
|
||||
"@ai-sdk/huggingface": "npm:^0.0.10"
|
||||
"@ai-sdk/mistral": "npm:^2.0.24"
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch"
|
||||
@ -15499,6 +15511,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gaxios@npm:^7.0.0":
|
||||
version: 7.1.3
|
||||
resolution: "gaxios@npm:7.1.3"
|
||||
dependencies:
|
||||
extend: "npm:^3.0.2"
|
||||
https-proxy-agent: "npm:^7.0.1"
|
||||
node-fetch: "npm:^3.3.2"
|
||||
rimraf: "npm:^5.0.1"
|
||||
checksum: 10c0/a4a1cdf9a392c0c22e9734a40dca5a77a2903f505b939a50f1e68e312458b1289b7993d2f72d011426e89657cae77a3aa9fc62fb140e8ba90a1faa31fdbde4d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gcp-metadata@npm:^6.1.0":
|
||||
version: 6.1.1
|
||||
resolution: "gcp-metadata@npm:6.1.1"
|
||||
@ -15510,6 +15534,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gcp-metadata@npm:^8.0.0":
|
||||
version: 8.1.2
|
||||
resolution: "gcp-metadata@npm:8.1.2"
|
||||
dependencies:
|
||||
gaxios: "npm:^7.0.0"
|
||||
google-logging-utils: "npm:^1.0.0"
|
||||
json-bigint: "npm:^1.0.0"
|
||||
checksum: 10c0/15a61231a9410dc11c2828d2c9fdc8b0a939f1af746195c44edc6f2ffea0acab52cef3a7b9828069a36fd5d68bda730f7328a415fe42a01258f6e249dfba6908
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gensync@npm:^1.0.0-beta.2":
|
||||
version: 1.0.0-beta.2
|
||||
resolution: "gensync@npm:1.0.0-beta.2"
|
||||
@ -15733,7 +15768,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"google-auth-library@npm:^9.14.2, google-auth-library@npm:^9.15.0, google-auth-library@npm:^9.15.1, google-auth-library@npm:^9.4.2":
|
||||
"google-auth-library@npm:^10.5.0":
|
||||
version: 10.5.0
|
||||
resolution: "google-auth-library@npm:10.5.0"
|
||||
dependencies:
|
||||
base64-js: "npm:^1.3.0"
|
||||
ecdsa-sig-formatter: "npm:^1.0.11"
|
||||
gaxios: "npm:^7.0.0"
|
||||
gcp-metadata: "npm:^8.0.0"
|
||||
google-logging-utils: "npm:^1.0.0"
|
||||
gtoken: "npm:^8.0.0"
|
||||
jws: "npm:^4.0.0"
|
||||
checksum: 10c0/49d3931d20b1f4a4d075216bf5518e2b3396dcf441a8f1952611cf3b6080afb1261c3d32009609047ee4a1cc545269a74b4957e6bba9cce840581df309c4b145
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"google-auth-library@npm:^9.14.2, google-auth-library@npm:^9.15.1, google-auth-library@npm:^9.4.2":
|
||||
version: 9.15.1
|
||||
resolution: "google-auth-library@npm:9.15.1"
|
||||
dependencies:
|
||||
@ -15754,6 +15804,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"google-logging-utils@npm:^1.0.0":
|
||||
version: 1.1.3
|
||||
resolution: "google-logging-utils@npm:1.1.3"
|
||||
checksum: 10c0/e65201c7e96543bd1423b9324013736646b9eed60941e0bfa47b9bfd146d2f09cf3df1c99ca60b7d80a726075263ead049ee72de53372cb8458c3bc55c2c1e59
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "gopd@npm:1.2.0"
|
||||
@ -15842,6 +15899,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gtoken@npm:^8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "gtoken@npm:8.0.0"
|
||||
dependencies:
|
||||
gaxios: "npm:^7.0.0"
|
||||
jws: "npm:^4.0.0"
|
||||
checksum: 10c0/058538e5bbe081d30ada5f1fd34d3a8194357c2e6ecbf7c8a98daeefbf13f7e06c15649c7dace6a1d4cc3bc6dc5483bd484d6d7adc5852021896d7c05c439f37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hachure-fill@npm:^0.5.2":
|
||||
version: 0.5.2
|
||||
resolution: "hachure-fill@npm:0.5.2"
|
||||
@ -22778,7 +22845,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rimraf@npm:^5.0.10":
|
||||
"rimraf@npm:^5.0.1, rimraf@npm:^5.0.10":
|
||||
version: 5.0.10
|
||||
resolution: "rimraf@npm:5.0.10"
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user