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
4 changes: 4 additions & 0 deletions src/server/proxy-core/surfaces/modelsSurface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getModelContextLength } from '../../services/modelContextLengthCache.js';

function isSearchPseudoModel(modelName: string): boolean {
const normalized = (modelName || '').trim().toLowerCase();
if (!normalized) return false;
Expand Down Expand Up @@ -49,6 +51,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {
type: 'model' as const,
display_name: id,
created_at: now.toISOString(),
context_length: getModelContextLength(id),
}));
return {
data,
Expand All @@ -65,6 +68,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {
object: 'model' as const,
created: Math.floor(now.getTime() / 1000),
owned_by: 'metapi',
context_length: getModelContextLength(id),
})),
};
}
62 changes: 62 additions & 0 deletions src/server/routes/api/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,4 +1927,66 @@ 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: "模型列表不能为空" });
}

const account = await db
.select()
.from(schema.accounts)
.where(eq(schema.accounts.id, accountId))
.get();

if (!account) {
return reply.code(404).send({ message: "账号不存在" });
}

try {
for (const modelName of normalizedModels) {
await db
.delete(schema.modelAvailability)
.where(
and(
eq(schema.modelAvailability.accountId, accountId),
eq(schema.modelAvailability.modelName, modelName),
eq(schema.modelAvailability.isManual, true),
),
)
.run();
}
await rebuildRoutesBestEffort();

Comment on lines +1969 to +1983
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Extract deletion workflow to a service and execute it atomically.

Line 1970-Line 1981 performs DB mutation orchestration directly in the route, and a mid-loop failure can leave partial deletions while returning an error.

Proposed refactor direction
-      try {
-        for (const modelName of normalizedModels) {
-          await db
-            .delete(schema.modelAvailability)
-            .where(
-              and(
-                eq(schema.modelAvailability.accountId, accountId),
-                eq(schema.modelAvailability.modelName, modelName),
-                eq(schema.modelAvailability.isManual, true),
-              ),
-            )
-            .run();
-        }
+      try {
+        await removeManualModelsFromAccount({
+          accountId,
+          modelNames: normalizedModels,
+        });
         await rebuildRoutesBestEffort();

         return { success: true };
       } catch (err: any) {
// src/server/services/accountManualModelsService.ts
export async function removeManualModelsFromAccount(input: {
  accountId: number;
  modelNames: string[];
}) {
  await db.transaction(async (tx) => {
    for (const modelName of input.modelNames) {
      await tx
        .delete(schema.modelAvailability)
        .where(
          and(
            eq(schema.modelAvailability.accountId, input.accountId),
            eq(schema.modelAvailability.modelName, modelName),
            eq(schema.modelAvailability.isManual, true),
          ),
        )
        .run();
    }
  });
}

As per coding guidelines: "Route files in src/server/routes/** are adapters, not owners... must not own ... persistence."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/routes/api/accounts.ts` around lines 1969 - 1983, Extract the
deletion loop into a new service function (e.g., removeManualModelsFromAccount
in src/server/services/accountManualModelsService.ts) that accepts { accountId,
modelNames } and runs the deletes inside a single db.transaction using the
transaction handle (tx) for the .delete(schema.modelAvailability) calls and the
same where(...) predicate (use input.accountId and each modelName); then replace
the loop in the route with a call to this service and only call
rebuildRoutesBestEffort() after the service resolves successfully; ensure errors
are propagated so the route can return the appropriate error response.

return { success: true };
} catch (err: any) {
return reply
.code(500)
.send({ success: false, message: err?.message || "删除失败" });
}
},
);
}
205 changes: 205 additions & 0 deletions src/server/services/modelContextLengthCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expect, it, beforeEach } from 'vitest';
import {
setModelContextLength,
setModelContextLengths,
getModelContextLength,
hasModelContextLength,
clearModelContextLengthCache,
extractContextLengthsFromPayload,
getAllModelContextLengths,
} from './modelContextLengthCache.js';

describe('modelContextLengthCache', () => {
beforeEach(() => {
clearModelContextLengthCache();
});

describe('setModelContextLength / getModelContextLength', () => {
it('stores and retrieves context length for a model', () => {
setModelContextLength('gpt-4o', 128000);
expect(getModelContextLength('gpt-4o')).toBe(128000);
});

it('returns default 1_000_000 when model is not in cache', () => {
expect(getModelContextLength('unknown-model')).toBe(1_000_000);
});

it('normalizes model name case-insensitively', () => {
setModelContextLength('GPT-4o', 128000);
expect(getModelContextLength('gpt-4o')).toBe(128000);
expect(getModelContextLength('GPT-4O')).toBe(128000);
});

it('ignores invalid values', () => {
setModelContextLength('', 128000);
expect(hasModelContextLength('')).toBe(false);

setModelContextLength('model-a', NaN);
expect(hasModelContextLength('model-a')).toBe(false);

setModelContextLength('model-b', -100);
expect(hasModelContextLength('model-b')).toBe(false);

setModelContextLength('model-c', 0);
expect(hasModelContextLength('model-c')).toBe(false);
});

it('rounds fractional values', () => {
setModelContextLength('model', 128000.7);
expect(getModelContextLength('model')).toBe(128001);
});
});

describe('setModelContextLengths (bulk)', () => {
it('stores multiple entries at once', () => {
const entries = new Map([
['model-a', 128000],
['model-b', 200000],
['model-c', 1_000_000],
]);
setModelContextLengths(entries);

expect(getModelContextLength('model-a')).toBe(128000);
expect(getModelContextLength('model-b')).toBe(200000);
expect(getModelContextLength('model-c')).toBe(1_000_000);
});

it('ignores invalid entries in bulk', () => {
const entries = new Map([
['valid-model', 128000],
['', 200000],
['nan-model', NaN],
]);
setModelContextLengths(entries);

expect(getModelContextLength('valid-model')).toBe(128000);
expect(hasModelContextLength('')).toBe(false);
expect(hasModelContextLength('nan-model')).toBe(false);
});
});

describe('hasModelContextLength', () => {
it('returns true only for cached models', () => {
expect(hasModelContextLength('gpt-4o')).toBe(false);
setModelContextLength('gpt-4o', 128000);
expect(hasModelContextLength('gpt-4o')).toBe(true);
});
});

describe('clearModelContextLengthCache', () => {
it('clears all entries', () => {
setModelContextLength('model-a', 128000);
setModelContextLength('model-b', 200000);
clearModelContextLengthCache();
expect(hasModelContextLength('model-a')).toBe(false);
expect(hasModelContextLength('model-b')).toBe(false);
});
});

describe('extractContextLengthsFromPayload', () => {
it('extracts context_length from OpenAI-compatible payload', () => {
const payload = {
data: [
{ id: 'gpt-4o', context_length: 128000 },
{ id: 'claude-3', context_length: 200000 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.size).toBe(2);
expect(result.get('gpt-4o')).toBe(128000);
expect(result.get('claude-3')).toBe(200000);
});

it('extracts contextLength (camelCase)', () => {
const payload = {
data: [
{ id: 'model-a', contextLength: 256000 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.get('model-a')).toBe(256000);
});

it('extracts max_context_length', () => {
const payload = {
data: [
{ id: 'model-b', max_context_length: 512000 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.get('model-b')).toBe(512000);
});

it('extracts context_window', () => {
const payload = {
data: [
{ id: 'model-c', context_window: 1_000_000 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.get('model-c')).toBe(1_000_000);
});

it('parses string values as numbers', () => {
const payload = {
data: [
{ id: 'model-str', context_length: '128000' },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.get('model-str')).toBe(128000);
});

it('returns empty map for payload without data array', () => {
expect(extractContextLengthsFromPayload(null).size).toBe(0);
expect(extractContextLengthsFromPayload({}).size).toBe(0);
expect(extractContextLengthsFromPayload({ data: 'not-array' }).size).toBe(0);
});

it('returns empty map when no items have context_length', () => {
const payload = {
data: [
{ id: 'model-a' },
{ id: 'model-b' },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.size).toBe(0);
});

it('skips items without id', () => {
const payload = {
data: [
{ context_length: 128000 },
{ id: '', context_length: 200000 },
{ id: 'valid', context_length: 300000 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.size).toBe(1);
expect(result.get('valid')).toBe(300000);
});

it('skips zero or negative context_length', () => {
const payload = {
data: [
{ id: 'zero', context_length: 0 },
{ id: 'negative', context_length: -100 },
],
};
const result = extractContextLengthsFromPayload(payload);
expect(result.size).toBe(0);
});
});

describe('getAllModelContextLengths', () => {
it('returns all cached entries', () => {
setModelContextLength('a', 100);
setModelContextLength('b', 200);
const all = getAllModelContextLengths();
expect(all.size).toBe(2);
expect(all.get('a')).toBe(100);
expect(all.get('b')).toBe(200);
});
});
});
Loading
Loading