diff --git a/src/server/proxy-core/surfaces/modelsSurface.ts b/src/server/proxy-core/surfaces/modelsSurface.ts index c560da28..7e7c0b0b 100644 --- a/src/server/proxy-core/surfaces/modelsSurface.ts +++ b/src/server/proxy-core/surfaces/modelsSurface.ts @@ -1,3 +1,5 @@ +import { getModelContextLength } from '../../services/modelContextLengthCache.js'; + function isSearchPseudoModel(modelName: string): boolean { const normalized = (modelName || '').trim().toLowerCase(); if (!normalized) return false; @@ -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, @@ -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), })), }; } diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 0d36b3c6..66239787 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -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(); + + return { success: true }; + } catch (err: any) { + return reply + .code(500) + .send({ success: false, message: err?.message || "删除失败" }); + } + }, + ); } diff --git a/src/server/services/modelContextLengthCache.test.ts b/src/server/services/modelContextLengthCache.test.ts new file mode 100644 index 00000000..3754758f --- /dev/null +++ b/src/server/services/modelContextLengthCache.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/services/modelContextLengthCache.ts b/src/server/services/modelContextLengthCache.ts new file mode 100644 index 00000000..b4500154 --- /dev/null +++ b/src/server/services/modelContextLengthCache.ts @@ -0,0 +1,118 @@ +/** + * In-memory cache for model context length metadata. + * + * Populated during upstream model discovery when the upstream /v1/models + * response includes per-model context_length (or similar fields). + * Used by the /v1/models surface to enrich the downstream response. + * + * Default context length: 1_000_000 (1M tokens) when upstream does not provide one. + */ + +const DEFAULT_CONTEXT_LENGTH = 1_000_000; + +const cache = new Map(); + +function normalizeKey(modelName: string): string { + return modelName.trim().toLowerCase(); +} + +/** + * Store context length for a single model. + */ +export function setModelContextLength(modelName: string, contextLength: number): void { + if (!modelName || !Number.isFinite(contextLength) || contextLength <= 0) return; + cache.set(normalizeKey(modelName), Math.round(contextLength)); +} + +/** + * Bulk-store context lengths from a map (e.g. extracted from upstream payload). + */ +export function setModelContextLengths(entries: Map): void { + for (const [name, length] of entries) { + if (name && Number.isFinite(length) && length > 0) { + cache.set(normalizeKey(name), Math.round(length)); + } + } +} + +/** + * Get context length for a model. Returns the default if not found. + */ +export function getModelContextLength(modelName: string): number { + return cache.get(normalizeKey(modelName)) ?? DEFAULT_CONTEXT_LENGTH; +} + +/** + * Check if a model has an explicit context length in the cache. + */ +export function hasModelContextLength(modelName: string): boolean { + return cache.has(normalizeKey(modelName)); +} + +/** + * Get all cached entries (for diagnostics). + */ +export function getAllModelContextLengths(): ReadonlyMap { + return cache; +} + +/** + * Clear the cache (for testing or refresh). + */ +export function clearModelContextLengthCache(): void { + cache.clear(); +} + +/** + * Extract context lengths from an OpenAI-compatible /v1/models payload. + * + * Looks for context_length on each item in data[]. If none of the items + * carry context_length, returns an empty map (caller should fall back to default). + */ +export function extractContextLengthsFromPayload(payload: unknown): Map { + const result = new Map(); + if (!payload || typeof payload !== 'object') return result; + + const data = (payload as Record).data; + if (!Array.isArray(data)) return result; + + for (const item of data) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + + const id = typeof record.id === 'string' ? record.id.trim() : ''; + if (!id) continue; + + // Try multiple field names that upstreams may use + const contextLength = pickPositiveInt(record, [ + 'context_length', + 'contextLength', + 'max_context_length', + 'maxContextLength', + 'context_window', + 'contextWindow', + ]); + + if (contextLength > 0) { + result.set(id, contextLength); + } + } + + return result; +} + +function pickPositiveInt(obj: Record, keys: string[]): number { + for (const key of keys) { + const value = obj[key]; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.round(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.round(parsed); + } + } + } + return 0; +} diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 9d1b3447..f7f4ca16 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -3,6 +3,7 @@ import type { RequestInit as UndiciRequestInit } from 'undici'; import { createContext, runInContext } from 'node:vm'; import { withSiteProxyRequestInit } from '../siteProxy.js'; import { fetchJsonWithShieldCookieRetry } from './newApiShield.js'; +import { extractContextLengthsFromPayload, setModelContextLengths } from '../modelContextLengthCache.js'; export class NewApiAdapter extends BasePlatformAdapter { readonly platformName: string = 'new-api'; @@ -839,6 +840,11 @@ export class NewApiAdapter extends BasePlatformAdapter { private extractOpenAiModels(payload: any): string[] { if (!Array.isArray(payload?.data)) return []; + // Also extract and cache context_length from upstream when available + const contextLengths = extractContextLengthsFromPayload(payload); + if (contextLengths.size > 0) { + setModelContextLengths(contextLengths); + } return payload.data.map((m: any) => m?.id).filter(Boolean); } diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts index 76c516bf..ddc827eb 100644 --- a/src/server/services/platforms/standardApiProvider.ts +++ b/src/server/services/platforms/standardApiProvider.ts @@ -4,6 +4,7 @@ import { type CheckinResult, type UserInfo, } from './base.js'; +import { extractContextLengthsFromPayload, setModelContextLengths } from '../modelContextLengthCache.js'; type FetchModelsOptions = { baseUrl: string; @@ -74,6 +75,11 @@ export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter : Array.isArray(payload?.data) ? payload.data.map((item: any) => item?.id) : null; + // Also extract and cache context_length from upstream when available + const contextLengths = extractContextLengthsFromPayload(payload); + if (contextLengths.size > 0) { + setModelContextLengths(contextLengths); + } if (!Array.isArray(rows)) { throw new Error('invalid standard models payload'); diff --git a/src/web/api.ts b/src/web/api.ts index 6d42185c..b6eeb078 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -854,6 +854,11 @@ export const api = { method: "POST", body: JSON.stringify({ models }), }), + removeAccountManualModels: (accountId: number, models: string[]) => + request(`/api/accounts/${accountId}/models/manual`, { + method: "DELETE", + body: JSON.stringify({ models }), + }), refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request("/api/accounts/health/refresh", { method: "POST", diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index c842442d..5489870e 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -3477,6 +3477,16 @@ export default function Accounts() { setModelModal((state) => ({ ...state, manualModelsInput: value })) } onAddManualModels={handleAddManualModels} + onRemoveManualModel={async (modelName) => { + if (!modelModal.account) return; + try { + await api.removeAccountManualModels(modelModal.account.id, [modelName]); + toast.success(`已删除模型 ${modelName}`); + await loadModelModalModels(modelModal.account, {}); + } catch (err: any) { + toast.error(err?.message || "删除失败"); + } + }} /> ); diff --git a/src/web/pages/accounts/AccountModelsModal.tsx b/src/web/pages/accounts/AccountModelsModal.tsx index a5302624..ccad3f61 100644 --- a/src/web/pages/accounts/AccountModelsModal.tsx +++ b/src/web/pages/accounts/AccountModelsModal.tsx @@ -30,6 +30,7 @@ type AccountModelsModalProps = { onSetPendingDisabled: (pendingDisabled: Set) => void; onManualInputChange: (value: string) => void; onAddManualModels: () => Promise | void; + onRemoveManualModel?: (modelName: string) => Promise | void; }; export default function AccountModelsModal({ @@ -42,6 +43,7 @@ export default function AccountModelsModal({ onSetPendingDisabled, onManualInputChange, onAddManualModels, + onRemoveManualModel, }: AccountModelsModalProps) { return ( ) : null} {model.isManual ? ( - 手动 + ) : null} {isDisabled ? ( 禁用