1
0
forked from boos/BoosAPI

refactor: move platform discovery out of model service

This commit is contained in:
Cita
2026-03-23 12:14:22 +08:00
parent 50c34b8866
commit 86518202fa
3 changed files with 276 additions and 228 deletions
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
}
describe('modelService discovery architecture boundaries', () => {
it('keeps platform-specific oauth discovery HTTP logic in a dedicated discovery registry', () => {
const source = readSource('./modelService.ts');
expect(source).toContain("from './platformDiscoveryRegistry.js'");
expect(source).not.toContain('function discoverCodexModelsFromCloud');
expect(source).not.toContain('function discoverClaudeModelsFromCloud');
expect(source).not.toContain('function validateGeminiCliOauthConnection');
expect(source).not.toContain('function discoverAntigravityModelsFromCloud');
});
});
+6 -228
View File
@@ -1,5 +1,4 @@
import { and, eq } from 'drizzle-orm';
import { fetch } from 'undici';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import {
@@ -20,27 +19,20 @@ import {
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { withAccountProxyOverride } from './siteProxy.js';
import { isCodexPlatform } from './oauth/codexAccount.js';
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
import {
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_MODELS_USER_AGENT,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_UPSTREAM_BASE_URL,
} from './oauth/antigravityProvider.js';
import {
GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE,
GEMINI_CLI_USER_AGENT,
} from './oauth/geminiCliProvider.js';
discoverAntigravityModelsFromCloud,
discoverClaudeModelsFromCloud,
discoverCodexModelsFromCloud,
validateGeminiCliOauthConnection,
} from './platformDiscoveryRegistry.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
const GEMINI_CLI_STATIC_MODELS = [
'gemini-2.5-pro',
'gemini-2.5-flash',
@@ -152,88 +144,6 @@ function normalizeModels(models: string[]): string[] {
return normalizedModels;
}
function buildCodexModelsEndpoint(baseUrl: string): string {
const normalized = (baseUrl || '').replace(/\/+$/, '');
return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`;
}
function extractCodexModelIds(payload: unknown): string[] {
const collection = (() => {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
const record = payload as Record<string, unknown>;
if (Array.isArray(record.models)) return record.models;
if (Array.isArray(record.data)) return record.data;
if (Array.isArray(record.items)) return record.items;
return [];
})();
return collection.flatMap((item) => {
if (typeof item === 'string') return [item];
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
const id = typeof record.id === 'string'
? record.id
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
return id ? [id] : [];
});
}
function extractClaudeModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [];
const record = payload as { data?: unknown };
if (!Array.isArray(record.data)) return [];
return record.data.flatMap((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function extractAntigravityModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return [];
const record = payload as { models?: unknown };
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
}
if (!Array.isArray(record.models)) return [];
return record.models.flatMap((item) => {
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? [trimmed] : [];
}
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
const seen = new Set<string>();
return [
siteUrl,
ANTIGRAVITY_UPSTREAM_BASE_URL,
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
].flatMap((candidate) => {
const normalized = normalizeBaseUrl(candidate);
if (!normalized || seen.has(normalized)) return [];
seen.add(normalized);
return [normalized];
});
}
async function updateOauthModelDiscoveryState(input: {
account: typeof schema.accounts.$inferSelect;
checkedAt: string;
@@ -259,138 +169,6 @@ async function updateOauthModelDiscoveryState(input: {
return extraConfig;
}
async function discoverCodexModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('codex oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
Originator: 'codex_cli_rs',
};
if (oauth?.accountId) {
headers['Chatgpt-Account-Id'] = oauth.accountId;
}
const response = await fetch(
buildCodexModelsEndpoint(input.site.url),
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
}
return normalizeModels(extractCodexModelIds(await response.json()));
}
async function discoverClaudeModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('claude oauth access token missing');
}
const response = await fetch(
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
},
}),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
}
return normalizeModels(extractClaudeModelIds(await response.json()));
}
async function validateGeminiCliOauthConnection(input: {
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
}, true),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
}
const payload = await response.json() as { state?: unknown };
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
async function discoverAntigravityModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('antigravity oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
let lastError = '';
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
const payload = await response.json();
const models = normalizeModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
}
lastError = '未获取到可用模型';
}
throw new Error(lastError || '未获取到可用模型');
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
@@ -0,0 +1,252 @@
import { fetch } from 'undici';
import { schema } from '../db/index.js';
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import {
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_MODELS_USER_AGENT,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_UPSTREAM_BASE_URL,
} from './oauth/antigravityProvider.js';
import {
GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE,
GEMINI_CLI_USER_AGENT,
} from './oauth/geminiCliProvider.js';
type PlatformDiscoverySite = typeof schema.sites.$inferSelect;
type PlatformDiscoveryAccount = typeof schema.accounts.$inferSelect;
function normalizeDiscoveredModels(models: string[]): string[] {
const normalizedModels: string[] = [];
const seen = new Set<string>();
for (const rawModel of models) {
if (typeof rawModel !== 'string') continue;
const modelName = rawModel.trim();
if (!modelName) continue;
const dedupeKey = modelName.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
normalizedModels.push(modelName);
}
return normalizedModels;
}
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function buildCodexModelsEndpoint(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl);
return `${normalized}/models?client_version=${encodeURIComponent('1.0.0')}`;
}
function extractCodexModelIds(payload: unknown): string[] {
const collection = (() => {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
const record = payload as Record<string, unknown>;
if (Array.isArray(record.models)) return record.models;
if (Array.isArray(record.data)) return record.data;
if (Array.isArray(record.items)) return record.items;
return [];
})();
return collection.flatMap((item) => {
if (typeof item === 'string') return [item];
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const record = item as Record<string, unknown>;
const id = typeof record.id === 'string'
? record.id
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
return id ? [id] : [];
});
}
function extractClaudeModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [];
const record = payload as { data?: unknown };
if (!Array.isArray(record.data)) return [];
return record.data.flatMap((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function extractAntigravityModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return [];
const record = payload as { models?: unknown };
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
}
if (!Array.isArray(record.models)) return [];
return record.models.flatMap((item) => {
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? [trimmed] : [];
}
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
const seen = new Set<string>();
return [
siteUrl,
ANTIGRAVITY_UPSTREAM_BASE_URL,
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
].flatMap((candidate) => {
const normalized = normalizeBaseUrl(candidate);
if (!normalized || seen.has(normalized)) return [];
seen.add(normalized);
return [normalized];
});
}
export async function discoverCodexModelsFromCloud(input: {
site: PlatformDiscoverySite;
account: PlatformDiscoveryAccount;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('codex oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
Originator: 'codex_cli_rs',
};
if (oauth?.accountId) {
headers['Chatgpt-Account-Id'] = oauth.accountId;
}
const response = await fetch(
buildCodexModelsEndpoint(input.site.url),
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
}
return normalizeDiscoveredModels(extractCodexModelIds(await response.json()));
}
export async function discoverClaudeModelsFromCloud(input: {
site: PlatformDiscoverySite;
account: PlatformDiscoveryAccount;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('claude oauth access token missing');
}
const response = await fetch(
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
},
}),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
}
return normalizeDiscoveredModels(extractClaudeModelIds(await response.json()));
}
export async function validateGeminiCliOauthConnection(input: {
account: PlatformDiscoveryAccount;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
}, true),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
}
const payload = await response.json() as { state?: unknown };
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
export async function discoverAntigravityModelsFromCloud(input: {
site: PlatformDiscoverySite;
account: PlatformDiscoveryAccount;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('antigravity oauth access token missing');
}
const oauth = getOauthInfoFromAccount(input.account);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
let lastError = '';
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
const payload = await response.json();
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
}
lastError = '未获取到可用模型';
}
throw new Error(lastError || '未获取到可用模型');
}