Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/server/proxy-core/surfaces/modelsSurface.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { describe, expect, it, vi } from 'vitest';

import { listModelsSurface } from './modelsSurface.js';
import {
buildAccountModelContextLengthScope,
clearModelContextLengthCache,
setModelContextLength,
} from '../../services/modelContextLengthCache.js';

describe('listModelsSurface', () => {
it('returns OpenAI list shape and hides models without a resolvable channel', async () => {
clearModelContextLengthCache();
setModelContextLength('routable-model', 128000, buildAccountModelContextLengthScope(11));

const result = await listModelsSurface({
downstreamPolicy: { type: 'all' },
responseFormat: 'openai',
tokenRouter: {
getAvailableModels: vi.fn().mockResolvedValue(['routable-model', 'orphan-model']),
explainSelection: vi.fn()
.mockResolvedValueOnce({ selectedChannelId: null })
.mockResolvedValueOnce({ selectedChannelId: 11 }),
explainSelection: vi.fn(async (modelName: string) => (
modelName === 'routable-model'
? { selectedChannelId: 11, selectedAccountId: 11 }
: { selectedChannelId: null }
)),
},
refreshModelsAndRebuildRoutes: vi.fn(),
isModelAllowed: vi.fn().mockResolvedValue(true),
Expand All @@ -26,18 +36,22 @@ describe('listModelsSurface', () => {
object: 'model',
created: 1773878400,
owned_by: 'metapi',
context_length: 128000,
},
],
});
});

it('returns Claude list shape when requested', async () => {
clearModelContextLengthCache();
setModelContextLength('claude-opus-4-6', 200000, buildAccountModelContextLengthScope(22));

const result = await listModelsSurface({
downstreamPolicy: { type: 'all' },
responseFormat: 'claude',
tokenRouter: {
getAvailableModels: vi.fn().mockResolvedValue(['claude-opus-4-6']),
explainSelection: vi.fn().mockResolvedValue({ selectedChannelId: 22 }),
explainSelection: vi.fn().mockResolvedValue({ selectedChannelId: 22, selectedAccountId: 22 }),
},
refreshModelsAndRebuildRoutes: vi.fn(),
isModelAllowed: vi.fn().mockResolvedValue(true),
Expand All @@ -51,6 +65,7 @@ describe('listModelsSurface', () => {
type: 'model',
display_name: 'claude-opus-4-6',
created_at: '2026-03-19T00:00:00.000Z',
context_length: 200000,
},
],
first_id: 'claude-opus-4-6',
Expand All @@ -60,14 +75,17 @@ describe('listModelsSurface', () => {
});

it('applies downstream policy filtering before selection checks and refreshes once when the first read is empty', async () => {
clearModelContextLengthCache();
setModelContextLength('allowed-model', 64000, buildAccountModelContextLengthScope(33));

const getAvailableModels = vi.fn()
.mockResolvedValueOnce(['blocked-model'])
.mockResolvedValueOnce(['allowed-model']);
const refreshModelsAndRebuildRoutes = vi.fn().mockResolvedValue(undefined);
const isModelAllowed = vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const explainSelection = vi.fn().mockResolvedValue({ selectedChannelId: 33 });
const explainSelection = vi.fn().mockResolvedValue({ selectedChannelId: 33, selectedAccountId: 33 });

const result = await listModelsSurface({
downstreamPolicy: { type: 'whitelist' },
Expand All @@ -90,6 +108,7 @@ describe('listModelsSurface', () => {
object: 'model',
created: 1773878400,
owned_by: 'metapi',
context_length: 64000,
},
],
});
Expand Down
54 changes: 43 additions & 11 deletions src/server/proxy-core/surfaces/modelsSurface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
buildAccountModelContextLengthScope,
getModelContextLength,
} from '../../services/modelContextLengthCache.js';

function isSearchPseudoModel(modelName: string): boolean {
const normalized = (modelName || '').trim().toLowerCase();
if (!normalized) return false;
Expand All @@ -11,25 +16,41 @@ type ModelsSurfaceInput = {
getAvailableModels(): Promise<string[]>;
explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{
selectedChannelId?: number | null;
selectedAccountId?: number | null;
}>;
};
refreshModelsAndRebuildRoutes(): Promise<unknown>;
isModelAllowed(modelName: string, downstreamPolicy: unknown): Promise<boolean>;
now?: () => Date;
};

async function readVisibleModels(input: ModelsSurfaceInput): Promise<string[]> {
function resolveModelContextLength(modelName: string, selectedAccountId?: number | null): number {
if (typeof selectedAccountId === 'number' && selectedAccountId > 0) {
return getModelContextLength(
modelName,
buildAccountModelContextLengthScope(selectedAccountId),
);
}
return getModelContextLength(modelName);
}

async function readVisibleModels(
input: ModelsSurfaceInput,
): Promise<Array<{ id: string; selectedAccountId?: number | null }>> {
const deduped = Array.from(new Set(await input.tokenRouter.getAvailableModels()))
.filter((modelName) => !isSearchPseudoModel(modelName))
.sort();
const allowed: string[] = [];
const allowed: Array<{ id: string; selectedAccountId?: number | null }> = [];
for (const modelName of deduped) {
if (!await input.isModelAllowed(modelName, input.downstreamPolicy)) {
continue;
}
const decision = await input.tokenRouter.explainSelection(modelName, [], input.downstreamPolicy);
if (typeof decision.selectedChannelId === 'number') {
allowed.push(modelName);
allowed.push({
id: modelName,
selectedAccountId: decision.selectedAccountId,
});
}
}
return allowed;
Expand All @@ -44,12 +65,22 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {

const now = input.now?.() ?? new Date();
if (input.responseFormat === 'claude') {
const data = models.map((id) => ({
id,
type: 'model' as const,
display_name: id,
created_at: now.toISOString(),
}));
const data: Array<{
id: string;
type: 'model';
display_name: string;
created_at: string;
context_length: number;
}> = [];
for (const model of models) {
data.push({
id: model.id,
type: 'model' as const,
display_name: model.id,
created_at: now.toISOString(),
context_length: resolveModelContextLength(model.id, model.selectedAccountId),
});
}
return {
data,
first_id: data[0]?.id || null,
Expand All @@ -60,11 +91,12 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {

return {
object: 'list' as const,
data: models.map((id) => ({
id,
data: models.map((model) => ({
id: model.id,
object: 'model' as const,
created: Math.floor(now.getTime() / 1000),
owned_by: 'metapi',
context_length: resolveModelContextLength(model.id, model.selectedAccountId),
})),
};
}
104 changes: 101 additions & 3 deletions src/server/routes/api/accounts.manual-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('accounts manual models endpoint', () => {

app = Fastify();
await app.register(routesModule.accountsRoutes);
});
}, 30_000);

beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
Expand All @@ -40,9 +40,9 @@ describe('accounts manual models endpoint', () => {
});

afterAll(async () => {
await app.close();
await app?.close();
delete process.env.DATA_DIR;
});
}, 30_000);

it('adds manual models and sets isManual to true', async () => {
const site = await db.insert(schema.sites).values({
Expand Down Expand Up @@ -179,4 +179,102 @@ describe('accounts manual models endpoint', () => {
message: 'Invalid models. Expected string[].',
});
});

it('deletes only manual models for the target account', async () => {
const site = await db.insert(schema.sites).values({
name: 'Test Site',
url: 'https://test.example.com',
platform: 'new-api',
}).returning().get();

const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'test-token',
}).returning().get();

await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'manual-a',
available: true,
isManual: true,
},
{
accountId: account.id,
modelName: 'manual-b',
available: true,
isManual: true,
},
{
accountId: account.id,
modelName: 'synced-model',
available: true,
isManual: false,
},
]);

const response = await app.inject({
method: 'DELETE',
url: `/api/accounts/${account.id}/models/manual`,
payload: {
models: ['manual-a'],
},
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });

const models = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();

expect(models.map((model) => `${model.modelName}:${model.isManual}`).sort()).toEqual([
'manual-b:true',
'synced-model:false',
]);
});

it('ignores duplicate and whitespace model names when deleting manual models', async () => {
const site = await db.insert(schema.sites).values({
name: 'Test Site',
url: 'https://test.example.com',
platform: 'new-api',
}).returning().get();

const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'test-token',
}).returning().get();

await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'manual-a',
available: true,
isManual: true,
},
{
accountId: account.id,
modelName: 'manual-b',
available: true,
isManual: true,
},
]);

const response = await app.inject({
method: 'DELETE',
url: `/api/accounts/${account.id}/models/manual`,
payload: {
models: [' manual-a ', 'manual-a', ' '],
},
});

expect(response.statusCode).toBe(200);

const models = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();

expect(models.map((model) => model.modelName)).toEqual(['manual-b']);
});
});
47 changes: 47 additions & 0 deletions src/server/routes/api/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ import {
parseBatchApiKeys,
} from "../../services/apiKeyBatch.js";
import { createManualAccount } from "../../services/manualAccountCreationService.js";
import {
AccountManualModelServiceError,
removeManualModelsFromAccount,
} from "../../services/accountManualModelService.js";

type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
Expand Down Expand Up @@ -1927,4 +1931,47 @@ export async function accountsRoutes(app: FastifyInstance) {
}
},
);

// Remove manually added models from an account
app.delete<{ Params: { id: string }; Body: unknown }>(
"/api/accounts/:id/models/manual",
async (request, reply) => {
const parsedBody = parseAccountManualModelsPayload(request.body);
if (!parsedBody.success) {
return reply.code(400).send({ message: parsedBody.error });
}

const accountId = parseInt(request.params.id, 10);
if (!Number.isFinite(accountId) || accountId <= 0) {
return reply.code(400).send({ message: "账号 ID 无效" });
}

const { models } = parsedBody.data;
if (!Array.isArray(models) || models.length === 0) {
return reply.code(400).send({ message: "模型列表不能为空" });
}

const normalizedModels = Array.from(
new Set(
models.map((m) => String(m).trim()).filter((m) => m.length > 0),
),
);
if (normalizedModels.length === 0) {
return reply.code(400).send({ message: "模型列表不能为空" });
}

try {
await removeManualModelsFromAccount(accountId, normalizedModels);

return { success: true };
} catch (err: any) {
if (err instanceof AccountManualModelServiceError) {
return reply.code(err.statusCode).send({ message: err.message });
}
return reply
.code(500)
.send({ success: false, message: err?.message || "删除失败" });
}
},
);
}
Loading
Loading