feat: enhance API key management and fix routing logic
* ``` feat(accounts): 支持批量添加API密钥连接 - 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥 - 实现批量API密钥解析功能,支持换行符、空格和逗号分隔 - 重构账户创建逻辑以支持单个和批量API密钥处理 - 在前端界面中添加批量API密钥输入检测和处理 - 添加批量创建结果返回和错误处理 - 更新验证流程以支持批量模式下的逐条验证 - 添加详细的批量API密钥测试用例 该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。 ``` * ``` fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名 当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称 而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正 确覆盖精确模型匹配。 相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲 突情况下获得更高优先级。 ``` * ``` fix(accounts): 修复API密钥验证错误消息显示逻辑 当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息 feat(accounts): 更新API密钥输入界面提示文本 将批量添加说明从输入框占位符移至独立提示文本, 使界面更清晰易懂 ``` * ``` feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称 - 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量 - 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取 - 更新docker.metadata-action配置以使用环境变量中的镜像名称 - 添加测试用例验证Docker Hub镜像名称的正确配置 ``` * fix(accounts): 修复批量创建回归并下沉创建流程
This commit is contained in:
@@ -268,4 +268,97 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => {
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined);
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined);
|
||||
});
|
||||
|
||||
it('supports batch creating multiple API key connections for one site', async () => {
|
||||
getModelsMock
|
||||
.mockResolvedValueOnce(['gpt-4o-mini'])
|
||||
.mockResolvedValueOnce(['gpt-4.1-mini']);
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Nihao Batch Pool',
|
||||
url: 'https://console.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
username: 'batch-key',
|
||||
accessToken: 'sk-batch-a\nsk-batch-b',
|
||||
credentialMode: 'apikey',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
batch: true,
|
||||
totalCount: 2,
|
||||
createdCount: 2,
|
||||
failedCount: 0,
|
||||
});
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-batch-a', undefined);
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-batch-b', undefined);
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toHaveLength(2);
|
||||
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-batch-a', 'sk-batch-b']);
|
||||
expect(accounts.map((item) => item.username)).toEqual(['batch-key #1', 'batch-key #2']);
|
||||
});
|
||||
|
||||
it('treats accessTokens payloads as batch API key creation even without credentialMode', async () => {
|
||||
getModelsMock
|
||||
.mockResolvedValueOnce(['gpt-4o-mini'])
|
||||
.mockResolvedValueOnce(['gpt-4.1-mini']);
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Nihao Batch Array',
|
||||
url: 'https://console.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
username: 'array-key',
|
||||
accessTokens: ['sk-array-a', 'sk-array-b'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
batch: true,
|
||||
totalCount: 2,
|
||||
createdCount: 2,
|
||||
failedCount: 0,
|
||||
});
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-array-a', undefined);
|
||||
expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-array-b', undefined);
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toHaveLength(2);
|
||||
expect(accounts.map((item) => item.apiToken)).toEqual(['sk-array-a', 'sk-array-b']);
|
||||
expect(accounts.map((item) => item.username)).toEqual(['array-key #1', 'array-key #2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema, runtimeDbDialect } from '../../db/index.js';
|
||||
import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { refreshBalance } from '../../services/balanceService.js';
|
||||
import { getAdapter } from '../../services/platforms/index.js';
|
||||
@@ -34,6 +34,7 @@ import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||
import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import {
|
||||
type AccountCreatePayload,
|
||||
parseAccountBatchPayload,
|
||||
parseAccountCreatePayload,
|
||||
parseAccountHealthRefreshPayload,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
parseAccountVerifyTokenPayload,
|
||||
} from '../../contracts/accountsRoutePayloads.js';
|
||||
import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js';
|
||||
import { buildBatchApiKeyConnectionName, parseBatchApiKeys } from '../../services/apiKeyBatch.js';
|
||||
import { createManualAccount } from '../../services/manualAccountCreationService.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
@@ -65,17 +68,6 @@ type AccountCapabilities = {
|
||||
proxyOnly: boolean;
|
||||
};
|
||||
|
||||
type AccountInitializationParams = {
|
||||
accountId: number;
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
adapter: NonNullable<ReturnType<typeof getAdapter>>;
|
||||
tokenType: 'session' | 'apikey' | 'unknown';
|
||||
accessToken: string;
|
||||
apiToken: string;
|
||||
platformUserId?: number;
|
||||
skipModelFetch?: boolean;
|
||||
};
|
||||
|
||||
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
|
||||
|
||||
const limitAccountLogin = createRateLimitGuard({
|
||||
@@ -150,65 +142,18 @@ function normalizePinnedFlag(input: unknown): boolean | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function initializeAccountInBackground({
|
||||
accountId,
|
||||
site,
|
||||
adapter,
|
||||
tokenType,
|
||||
accessToken,
|
||||
apiToken,
|
||||
platformUserId,
|
||||
skipModelFetch,
|
||||
}: AccountInitializationParams) {
|
||||
const summary = {
|
||||
accountId,
|
||||
syncedTokenCount: 0,
|
||||
refreshedBalance: false,
|
||||
refreshedModels: false,
|
||||
rebuiltRoutes: false,
|
||||
};
|
||||
|
||||
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
|
||||
if (tokenType === 'session' && accessToken) {
|
||||
try {
|
||||
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
|
||||
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
|
||||
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
|
||||
} catch {}
|
||||
function resolveRequestedCreateTokens(
|
||||
body: AccountCreatePayload,
|
||||
credentialMode: AccountCredentialMode,
|
||||
): string[] {
|
||||
if (credentialMode !== 'apikey') {
|
||||
const single = String(body.accessToken || '').trim();
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
const convergence = await convergeAccountMutation({
|
||||
accountId,
|
||||
preferredApiToken: tokenType === 'session' ? apiToken : null,
|
||||
defaultTokenSource: 'manual',
|
||||
ensurePreferredTokenBeforeSync: tokenType === 'session',
|
||||
upstreamTokens: fetchedUpstreamTokens,
|
||||
refreshBalance: tokenType === 'session',
|
||||
refreshModels: skipModelFetch !== true,
|
||||
rebuildRoutes: skipModelFetch !== true,
|
||||
continueOnError: true,
|
||||
});
|
||||
summary.refreshedBalance = convergence.refreshedBalance;
|
||||
summary.refreshedModels = convergence.refreshedModels;
|
||||
summary.rebuiltRoutes = convergence.rebuiltRoutes;
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildQueuedAccountInitializationMessage(
|
||||
tokenType: 'session' | 'apikey' | 'unknown',
|
||||
skipModelFetch?: boolean,
|
||||
) {
|
||||
if (tokenType === 'session' && skipModelFetch === true) {
|
||||
return '账号已添加,后台正在同步令牌和余额信息。';
|
||||
}
|
||||
if (tokenType === 'session') {
|
||||
return '账号已添加,后台正在同步令牌、余额和模型信息。';
|
||||
}
|
||||
if (skipModelFetch === true) {
|
||||
return '已添加为 API Key 账号(可用于代理转发)。';
|
||||
}
|
||||
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
|
||||
const batchTokens = parseBatchApiKeys(body.accessTokens);
|
||||
if (batchTokens.length > 0) return batchTokens;
|
||||
return parseBatchApiKeys(body.accessToken);
|
||||
}
|
||||
|
||||
function normalizeSortOrder(input: unknown): number | null {
|
||||
@@ -1147,174 +1092,103 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
|
||||
}
|
||||
|
||||
const credentialMode = resolveRequestedCredentialMode(body.credentialMode);
|
||||
const rawAccessToken = (body.accessToken || '').trim();
|
||||
if (!rawAccessToken) {
|
||||
const explicitBatchTokens = parseBatchApiKeys(body.accessTokens);
|
||||
const credentialMode = explicitBatchTokens.length > 0
|
||||
? 'apikey'
|
||||
: resolveRequestedCredentialMode(body.credentialMode);
|
||||
const requestedTokens = explicitBatchTokens.length > 0
|
||||
? explicitBatchTokens
|
||||
: resolveRequestedCreateTokens(body, credentialMode);
|
||||
if (requestedTokens.length === 0) {
|
||||
return reply.code(400).send({ success: false, message: '请填写 Token' });
|
||||
}
|
||||
|
||||
let username = (body.username || '').trim();
|
||||
let accessToken = rawAccessToken;
|
||||
let apiToken = (body.apiToken || '').trim();
|
||||
let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown';
|
||||
let verifiedModels: string[] = [];
|
||||
if (credentialMode === 'apikey' && requestedTokens.length > 1) {
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let createdCount = 0;
|
||||
|
||||
if (credentialMode === 'apikey') {
|
||||
if (body.skipModelFetch === true) {
|
||||
tokenType = 'apikey';
|
||||
accessToken = '';
|
||||
if (!apiToken) apiToken = rawAccessToken;
|
||||
} else {
|
||||
for (const [index, token] of requestedTokens.entries()) {
|
||||
try {
|
||||
const models = await getModelsWithSiteApiEndpointPool(
|
||||
const created = await createManualAccount({
|
||||
body,
|
||||
site,
|
||||
adapter,
|
||||
rawAccessToken,
|
||||
body.platformUserId,
|
||||
);
|
||||
verifiedModels = Array.isArray(models)
|
||||
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || 'API Key 验证失败',
|
||||
credentialMode,
|
||||
rawAccessToken: token,
|
||||
usernameOverride: buildBatchApiKeyConnectionName(body.username, index, requestedTokens.length) || undefined,
|
||||
});
|
||||
createdCount += 1;
|
||||
items.push({
|
||||
index,
|
||||
status: 'created',
|
||||
id: created.account.id,
|
||||
username: created.account.username || null,
|
||||
queued: created.queued === true,
|
||||
message: created.message || null,
|
||||
modelCount: created.modelCount || 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
items.push({
|
||||
index,
|
||||
status: 'failed',
|
||||
message: error?.message || '创建失败',
|
||||
requiresVerification: error?.requiresVerification === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (verifiedModels.length === 0) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
requiresVerification: true,
|
||||
message: 'API Key 验证失败:未获取到可用模型',
|
||||
});
|
||||
}
|
||||
|
||||
tokenType = 'apikey';
|
||||
accessToken = '';
|
||||
if (!apiToken) apiToken = rawAccessToken;
|
||||
}
|
||||
} else {
|
||||
let verifyResult: any;
|
||||
try {
|
||||
verifyResult = await adapter.verifyToken(site.url, rawAccessToken, body.platformUserId);
|
||||
} catch (err: any) {
|
||||
|
||||
if (createdCount === 0) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||
batch: true,
|
||||
totalCount: requestedTokens.length,
|
||||
createdCount: 0,
|
||||
failedCount: requestedTokens.length,
|
||||
message: `批量添加失败(0/${requestedTokens.length})`,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
tokenType = verifyResult.tokenType;
|
||||
if (tokenType === 'unknown') {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
requiresVerification: true,
|
||||
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
|
||||
});
|
||||
}
|
||||
|
||||
if (credentialMode === 'session' && tokenType !== 'session') {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token',
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenType === 'session') {
|
||||
if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim();
|
||||
if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim();
|
||||
} else if (tokenType === 'apikey') {
|
||||
accessToken = '';
|
||||
if (!apiToken) apiToken = rawAccessToken;
|
||||
verifiedModels = Array.isArray(verifyResult.models)
|
||||
? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
batch: true,
|
||||
totalCount: requestedTokens.length,
|
||||
createdCount,
|
||||
failedCount: requestedTokens.length - createdCount,
|
||||
message: `批量添加完成:成功 ${createdCount},失败 ${requestedTokens.length - createdCount}`,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
// Store platformUserId and credential mode in extraConfig.
|
||||
const resolvedPlatformUserId =
|
||||
body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined;
|
||||
const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session';
|
||||
const extraConfigPatch: Record<string, unknown> = { credentialMode: resolvedCredentialMode };
|
||||
if (resolvedPlatformUserId) {
|
||||
extraConfigPatch.platformUserId = resolvedPlatformUserId;
|
||||
try {
|
||||
const created = await createManualAccount({
|
||||
body,
|
||||
site,
|
||||
adapter,
|
||||
credentialMode,
|
||||
rawAccessToken: requestedTokens[0]!,
|
||||
});
|
||||
return {
|
||||
...created.account,
|
||||
tokenType: created.tokenType,
|
||||
credentialMode: resolveStoredCredentialMode(created.account),
|
||||
capabilities: buildCapabilitiesForAccount(created.account),
|
||||
modelCount: created.modelCount,
|
||||
apiTokenFound: created.apiTokenFound,
|
||||
usernameDetected: created.usernameDetected,
|
||||
queued: created.queued,
|
||||
jobId: created.jobId,
|
||||
message: created.message,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
requiresVerification: err?.requiresVerification === true,
|
||||
message: credentialMode !== 'apikey'
|
||||
? appendSessionTokenRebindHint(err?.message || 'Token 验证失败')
|
||||
: (err?.message || 'API Key 验证失败'),
|
||||
});
|
||||
}
|
||||
if ((site.platform || '').toLowerCase() === 'sub2api') {
|
||||
const managedRefreshToken = normalizeManagedRefreshToken(body.refreshToken);
|
||||
const managedTokenExpiresAt = normalizeManagedTokenExpiresAt(body.tokenExpiresAt);
|
||||
if (managedRefreshToken) {
|
||||
extraConfigPatch.sub2apiAuth = managedTokenExpiresAt
|
||||
? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt }
|
||||
: { refreshToken: managedRefreshToken };
|
||||
}
|
||||
}
|
||||
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
|
||||
|
||||
const result = await insertAndGetById<typeof schema.accounts.$inferSelect>({
|
||||
table: schema.accounts,
|
||||
idColumn: schema.accounts.id,
|
||||
values: {
|
||||
siteId: body.siteId,
|
||||
username: username || undefined,
|
||||
accessToken,
|
||||
apiToken: apiToken || undefined,
|
||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
},
|
||||
insertErrorMessage: '创建账号失败',
|
||||
loadErrorMessage: '创建账号失败',
|
||||
});
|
||||
|
||||
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
|
||||
let queuedTaskId: string | undefined;
|
||||
let queuedMessage: string | undefined;
|
||||
if (shouldQueueInitialization) {
|
||||
const taskTitle = `初始化连接 #${result.id}`;
|
||||
const { task } = startBackgroundTask(
|
||||
{
|
||||
type: 'account-init',
|
||||
title: taskTitle,
|
||||
dedupeKey: `account-init-${result.id}`,
|
||||
notifyOnFailure: true,
|
||||
successMessage: () => `${taskTitle}已完成`,
|
||||
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
|
||||
},
|
||||
async () => initializeAccountInBackground({
|
||||
accountId: result.id,
|
||||
site,
|
||||
adapter,
|
||||
tokenType,
|
||||
accessToken,
|
||||
apiToken,
|
||||
platformUserId: resolvedPlatformUserId,
|
||||
skipModelFetch: body.skipModelFetch,
|
||||
}),
|
||||
);
|
||||
queuedTaskId = task.id;
|
||||
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
|
||||
}
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
|
||||
const capabilities = account
|
||||
? buildCapabilitiesForAccount(account)
|
||||
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
|
||||
return {
|
||||
...account,
|
||||
tokenType,
|
||||
credentialMode: finalCredentialMode,
|
||||
capabilities,
|
||||
modelCount: verifiedModels.length,
|
||||
apiTokenFound: !!apiToken,
|
||||
usernameDetected: !!(!body.username && username),
|
||||
queued: !!queuedTaskId,
|
||||
jobId: queuedTaskId,
|
||||
message: queuedMessage,
|
||||
};
|
||||
});
|
||||
|
||||
// Update an account
|
||||
|
||||
@@ -658,7 +658,7 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
expect(updated?.tokenId).toBe(seeded.token.id);
|
||||
});
|
||||
|
||||
it('prefers an exact route over a colliding explicit-group display name', async () => {
|
||||
it('prefers an explicit-group display name over a colliding exact route', async () => {
|
||||
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
|
||||
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
|
||||
|
||||
@@ -716,9 +716,8 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
success: true,
|
||||
decision: {
|
||||
matched: true,
|
||||
routeId: exactRoute.id,
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
actualModel: 'claude-opus-4-6',
|
||||
routeId: groupResponse.json().id,
|
||||
actualModel: 'claude-opus-4-5',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user