From 1ebac44d091a3e4109f34762301253ad76a2ce1d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 29 Apr 2026 12:11:45 +0800 Subject: [PATCH 1/7] feat: add context_length to /v1/models response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New modelContextLengthCache service for in-memory model→context_length mapping - Platform adapters (newApi, standardApiProvider) extract context_length from upstream /v1/models - modelsSurface injects context_length into both OpenAI and Claude response formats - Default 1,000,000 when upstream does not provide context_length - Supports field names: context_length, contextLength, max_context_length, contextWindow, etc. --- .../proxy-core/surfaces/modelsSurface.ts | 4 + .../services/modelContextLengthCache.test.ts | 205 ++++++++++++++++++ .../services/modelContextLengthCache.ts | 118 ++++++++++ src/server/services/platforms/newApi.ts | 6 + .../services/platforms/standardApiProvider.ts | 6 + 5 files changed, 339 insertions(+) create mode 100644 src/server/services/modelContextLengthCache.test.ts create mode 100644 src/server/services/modelContextLengthCache.ts 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/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'); From af9aac3ad7a8f0670a1cf5ccf73b672f180f4f15 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 29 Apr 2026 12:26:44 +0800 Subject: [PATCH 2/7] feat: add delete button for manually added models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: DELETE /api/accounts/:id/models/manual endpoint - Only deletes models where isManual=true (safe against auto-discovered models) - Frontend: AccountModelsModal shows '✕ 删除' button next to each manual model - Frontend: api.removeAccountManualModels() function - Accounts.tsx: wires up delete handler with toast feedback --- src/server/routes/api/accounts.ts | 62 +++++++++++++++++++ src/web/api.ts | 5 ++ src/web/pages/Accounts.tsx | 10 +++ src/web/pages/accounts/AccountModelsModal.tsx | 15 ++++- 4 files changed, 91 insertions(+), 1 deletion(-) 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/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 ? ( 禁用 From 0b2201e8c280e6c0b3f1cc55aa39d88fc08f430d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 12 May 2026 19:27:41 +0800 Subject: [PATCH 3/7] fix: address PR review feedback for model metadata --- .../proxy-core/surfaces/modelsSurface.test.ts | 29 ++++- .../proxy-core/surfaces/modelsSurface.ts | 56 ++++++-- .../routes/api/accounts.manual-models.test.ts | 104 ++++++++++++++- src/server/routes/api/accounts.ts | 15 +-- .../services/accountManualModelService.ts | 33 +++++ .../services/modelContextLengthCache.test.ts | 121 +++++++++++++----- .../services/modelContextLengthCache.ts | 99 +++++++++++--- src/server/services/modelService.ts | 6 +- src/server/services/platforms/base.ts | 4 +- src/server/services/platforms/claude.ts | 3 +- src/server/services/platforms/cliproxyapi.ts | 3 +- src/server/services/platforms/gemini.ts | 9 +- src/server/services/platforms/newApi.ts | 37 ++++-- src/server/services/platforms/oneApi.ts | 16 ++- src/server/services/platforms/oneHub.ts | 9 +- src/server/services/platforms/openai.ts | 3 +- .../services/platforms/standardApiProvider.ts | 14 +- src/server/services/platforms/veloera.ts | 16 ++- src/web/pages/Accounts.tsx | 12 +- 19 files changed, 471 insertions(+), 118 deletions(-) create mode 100644 src/server/services/accountManualModelService.ts diff --git a/src/server/proxy-core/surfaces/modelsSurface.test.ts b/src/server/proxy-core/surfaces/modelsSurface.test.ts index 2fe84271..e9d19e45 100644 --- a/src/server/proxy-core/surfaces/modelsSurface.test.ts +++ b/src/server/proxy-core/surfaces/modelsSurface.test.ts @@ -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), @@ -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), @@ -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', @@ -60,6 +75,9 @@ 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']); @@ -67,7 +85,7 @@ describe('listModelsSurface', () => { 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' }, @@ -90,6 +108,7 @@ describe('listModelsSurface', () => { object: 'model', created: 1773878400, owned_by: 'metapi', + context_length: 64000, }, ], }); diff --git a/src/server/proxy-core/surfaces/modelsSurface.ts b/src/server/proxy-core/surfaces/modelsSurface.ts index 7e7c0b0b..55ba7713 100644 --- a/src/server/proxy-core/surfaces/modelsSurface.ts +++ b/src/server/proxy-core/surfaces/modelsSurface.ts @@ -1,4 +1,7 @@ -import { getModelContextLength } from '../../services/modelContextLengthCache.js'; +import { + buildAccountModelContextLengthScope, + getModelContextLength, +} from '../../services/modelContextLengthCache.js'; function isSearchPseudoModel(modelName: string): boolean { const normalized = (modelName || '').trim().toLowerCase(); @@ -13,6 +16,7 @@ type ModelsSurfaceInput = { getAvailableModels(): Promise; explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{ selectedChannelId?: number | null; + selectedAccountId?: number | null; }>; }; refreshModelsAndRebuildRoutes(): Promise; @@ -20,18 +24,33 @@ type ModelsSurfaceInput = { now?: () => Date; }; -async function readVisibleModels(input: ModelsSurfaceInput): Promise { +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> { 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; @@ -46,13 +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(), - context_length: getModelContextLength(id), - })); + 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, @@ -63,12 +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: getModelContextLength(id), + context_length: resolveModelContextLength(model.id, model.selectedAccountId), })), }; } diff --git a/src/server/routes/api/accounts.manual-models.test.ts b/src/server/routes/api/accounts.manual-models.test.ts index 09ec0a4d..a9987bdb 100644 --- a/src/server/routes/api/accounts.manual-models.test.ts +++ b/src/server/routes/api/accounts.manual-models.test.ts @@ -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(); @@ -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({ @@ -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']); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 66239787..d5af654c 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -58,6 +58,7 @@ import { parseBatchApiKeys, } from "../../services/apiKeyBatch.js"; import { createManualAccount } from "../../services/manualAccountCreationService.js"; +import { removeManualModelsFromAccount } from "../../services/accountManualModelService.js"; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -1967,19 +1968,7 @@ export async function accountsRoutes(app: FastifyInstance) { } 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(); + await removeManualModelsFromAccount(accountId, normalizedModels); return { success: true }; } catch (err: any) { diff --git a/src/server/services/accountManualModelService.ts b/src/server/services/accountManualModelService.ts new file mode 100644 index 00000000..ffcb9f5d --- /dev/null +++ b/src/server/services/accountManualModelService.ts @@ -0,0 +1,33 @@ +import { and, eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { rebuildRoutesBestEffort } from './accountMutationWorkflow.js'; + +export async function removeManualModelsFromAccount( + accountId: number, + modelNames: string[], +): Promise<{ deletedCount: number }> { + const normalizedModelNames = Array.from(new Set( + modelNames.map((modelName) => String(modelName || '').trim()).filter((modelName) => modelName.length > 0), + )); + + if (normalizedModelNames.length === 0) { + return { deletedCount: 0 }; + } + + const deletedCount = await db.transaction(async (tx) => { + const result = await tx + .delete(schema.modelAvailability) + .where(and( + eq(schema.modelAvailability.accountId, accountId), + eq(schema.modelAvailability.isManual, true), + inArray(schema.modelAvailability.modelName, normalizedModelNames), + )) + .run(); + + return result.changes ?? 0; + }); + + await rebuildRoutesBestEffort(); + + return { deletedCount }; +} diff --git a/src/server/services/modelContextLengthCache.test.ts b/src/server/services/modelContextLengthCache.test.ts index 3754758f..034c4dad 100644 --- a/src/server/services/modelContextLengthCache.test.ts +++ b/src/server/services/modelContextLengthCache.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, beforeEach } from 'vitest'; import { + buildAccountModelContextLengthScope, + buildEndpointModelContextLengthScope, setModelContextLength, setModelContextLengths, getModelContextLength, @@ -10,43 +12,60 @@ import { } from './modelContextLengthCache.js'; describe('modelContextLengthCache', () => { + const primaryAccountScope = buildAccountModelContextLengthScope(101); + const secondaryAccountScope = buildAccountModelContextLengthScope(202); + const endpointScope = buildEndpointModelContextLengthScope('https://api.example.com/v1'); + beforeEach(() => { clearModelContextLengthCache(); }); describe('setModelContextLength / getModelContextLength', () => { it('stores and retrieves context length for a model', () => { - setModelContextLength('gpt-4o', 128000); - expect(getModelContextLength('gpt-4o')).toBe(128000); + setModelContextLength('gpt-4o', 128000, primaryAccountScope); + expect(getModelContextLength('gpt-4o', primaryAccountScope)).toBe(128000); }); it('returns default 1_000_000 when model is not in cache', () => { - expect(getModelContextLength('unknown-model')).toBe(1_000_000); + expect(getModelContextLength('unknown-model', primaryAccountScope)).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); + setModelContextLength('GPT-4o', 128000, primaryAccountScope); + expect(getModelContextLength('gpt-4o', primaryAccountScope)).toBe(128000); + expect(getModelContextLength('GPT-4O', primaryAccountScope)).toBe(128000); }); it('ignores invalid values', () => { - setModelContextLength('', 128000); - expect(hasModelContextLength('')).toBe(false); + setModelContextLength('', 128000, primaryAccountScope); + expect(hasModelContextLength('', primaryAccountScope)).toBe(false); + + setModelContextLength('model-a', NaN, primaryAccountScope); + expect(hasModelContextLength('model-a', primaryAccountScope)).toBe(false); - setModelContextLength('model-a', NaN); - expect(hasModelContextLength('model-a')).toBe(false); + setModelContextLength('model-b', -100, primaryAccountScope); + expect(hasModelContextLength('model-b', primaryAccountScope)).toBe(false); - setModelContextLength('model-b', -100); - expect(hasModelContextLength('model-b')).toBe(false); + setModelContextLength('model-c', 0, primaryAccountScope); + expect(hasModelContextLength('model-c', primaryAccountScope)).toBe(false); + }); - setModelContextLength('model-c', 0); - expect(hasModelContextLength('model-c')).toBe(false); + it('rejects whitespace-only model names after normalization', () => { + setModelContextLength(' ', 128000, primaryAccountScope); + expect(hasModelContextLength(' ', primaryAccountScope)).toBe(false); }); it('rounds fractional values', () => { - setModelContextLength('model', 128000.7); - expect(getModelContextLength('model')).toBe(128001); + setModelContextLength('model', 128000.7, primaryAccountScope); + expect(getModelContextLength('model', primaryAccountScope)).toBe(128001); + }); + + it('scopes entries per account to avoid cross-account overwrites', () => { + setModelContextLength('gpt-4o', 128000, primaryAccountScope); + setModelContextLength('gpt-4o', 200000, secondaryAccountScope); + + expect(getModelContextLength('gpt-4o', primaryAccountScope)).toBe(128000); + expect(getModelContextLength('gpt-4o', secondaryAccountScope)).toBe(200000); }); }); @@ -57,11 +76,11 @@ describe('modelContextLengthCache', () => { ['model-b', 200000], ['model-c', 1_000_000], ]); - setModelContextLengths(entries); + setModelContextLengths(entries, primaryAccountScope); - expect(getModelContextLength('model-a')).toBe(128000); - expect(getModelContextLength('model-b')).toBe(200000); - expect(getModelContextLength('model-c')).toBe(1_000_000); + expect(getModelContextLength('model-a', primaryAccountScope)).toBe(128000); + expect(getModelContextLength('model-b', primaryAccountScope)).toBe(200000); + expect(getModelContextLength('model-c', primaryAccountScope)).toBe(1_000_000); }); it('ignores invalid entries in bulk', () => { @@ -69,30 +88,64 @@ describe('modelContextLengthCache', () => { ['valid-model', 128000], ['', 200000], ['nan-model', NaN], + [' ', 180000], ]); - setModelContextLengths(entries); + setModelContextLengths(entries, primaryAccountScope); - expect(getModelContextLength('valid-model')).toBe(128000); - expect(hasModelContextLength('')).toBe(false); - expect(hasModelContextLength('nan-model')).toBe(false); + expect(getModelContextLength('valid-model', primaryAccountScope)).toBe(128000); + expect(hasModelContextLength('', primaryAccountScope)).toBe(false); + expect(hasModelContextLength('nan-model', primaryAccountScope)).toBe(false); + expect(hasModelContextLength(' ', primaryAccountScope)).toBe(false); + }); + + it('replaces previous values for the same source scope to clear stale metadata', () => { + setModelContextLengths(new Map([ + ['model-a', 128000], + ['model-b', 200000], + ]), primaryAccountScope); + + setModelContextLengths(new Map([ + ['model-a', 256000], + ]), primaryAccountScope); + + expect(getModelContextLength('model-a', primaryAccountScope)).toBe(256000); + expect(getModelContextLength('model-b', primaryAccountScope)).toBe(1_000_000); + }); + + it('can use endpoint scopes when no account scope is available', () => { + setModelContextLengths(new Map([ + ['model-a', 64000], + ]), endpointScope); + + expect(getModelContextLength('model-a', endpointScope)).toBe(64000); }); }); 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); + expect(hasModelContextLength('gpt-4o', primaryAccountScope)).toBe(false); + setModelContextLength('gpt-4o', 128000, primaryAccountScope); + expect(hasModelContextLength('gpt-4o', primaryAccountScope)).toBe(true); }); }); describe('clearModelContextLengthCache', () => { it('clears all entries', () => { - setModelContextLength('model-a', 128000); - setModelContextLength('model-b', 200000); + setModelContextLength('model-a', 128000, primaryAccountScope); + setModelContextLength('model-b', 200000, secondaryAccountScope); clearModelContextLengthCache(); - expect(hasModelContextLength('model-a')).toBe(false); - expect(hasModelContextLength('model-b')).toBe(false); + expect(hasModelContextLength('model-a', primaryAccountScope)).toBe(false); + expect(hasModelContextLength('model-b', secondaryAccountScope)).toBe(false); + }); + + it('can clear a single source scope without affecting others', () => { + setModelContextLength('model-a', 128000, primaryAccountScope); + setModelContextLength('model-a', 256000, secondaryAccountScope); + + clearModelContextLengthCache(primaryAccountScope); + + expect(hasModelContextLength('model-a', primaryAccountScope)).toBe(false); + expect(getModelContextLength('model-a', secondaryAccountScope)).toBe(256000); }); }); @@ -194,9 +247,9 @@ describe('modelContextLengthCache', () => { describe('getAllModelContextLengths', () => { it('returns all cached entries', () => { - setModelContextLength('a', 100); - setModelContextLength('b', 200); - const all = getAllModelContextLengths(); + setModelContextLength('a', 100, primaryAccountScope); + setModelContextLength('b', 200, primaryAccountScope); + const all = getAllModelContextLengths(primaryAccountScope); 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 index b4500154..1d19caa2 100644 --- a/src/server/services/modelContextLengthCache.ts +++ b/src/server/services/modelContextLengthCache.ts @@ -9,58 +9,123 @@ */ const DEFAULT_CONTEXT_LENGTH = 1_000_000; +const DEFAULT_SOURCE_SCOPE = '__default__'; -const cache = new Map(); +const cache = new Map>(); function normalizeKey(modelName: string): string { return modelName.trim().toLowerCase(); } +function normalizeSourceScope(sourceScope?: string): string { + const normalized = String(sourceScope || '').trim().toLowerCase(); + return normalized || DEFAULT_SOURCE_SCOPE; +} + +function getOrCreateScopeCache(sourceScope?: string): Map { + const scopeKey = normalizeSourceScope(sourceScope); + const scopedCache = cache.get(scopeKey); + if (scopedCache) return scopedCache; + const next = new Map(); + cache.set(scopeKey, next); + return next; +} + +function getNormalizedModelKey(modelName: string): string { + return normalizeKey(modelName); +} + +function isValidContextLength(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +function buildScopedEntryKey(sourceScope: string | undefined, modelName: string): [string, string] | null { + const normalizedModelName = getNormalizedModelKey(modelName); + if (!normalizedModelName) return null; + return [normalizeSourceScope(sourceScope), normalizedModelName]; +} + +export function buildAccountModelContextLengthScope(accountId: number): string { + return `account:${accountId}`; +} + +export function buildEndpointModelContextLengthScope(baseUrl: string): string { + return `endpoint:${normalizeKey(baseUrl)}`; +} + /** * 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)); +export function setModelContextLength( + modelName: string, + contextLength: number, + sourceScope?: string, +): void { + const scopedEntry = buildScopedEntryKey(sourceScope, modelName); + if (!scopedEntry || !isValidContextLength(contextLength)) return; + const [scopeKey, normalizedModelName] = scopedEntry; + getOrCreateScopeCache(scopeKey).set(normalizedModelName, Math.round(contextLength)); } /** * Bulk-store context lengths from a map (e.g. extracted from upstream payload). + * Replaces the existing cache for the provided source scope so stale values + * do not linger when an upstream stops sending context metadata. */ -export function setModelContextLengths(entries: Map): void { +export function setModelContextLengths( + entries: Map, + sourceScope?: string, +): void { + const scopeKey = normalizeSourceScope(sourceScope); + const nextScopeCache = new Map(); for (const [name, length] of entries) { - if (name && Number.isFinite(length) && length > 0) { - cache.set(normalizeKey(name), Math.round(length)); - } + const scopedEntry = buildScopedEntryKey(scopeKey, name); + if (!scopedEntry || !isValidContextLength(length)) continue; + nextScopeCache.set(scopedEntry[1], Math.round(length)); } + + if (nextScopeCache.size === 0) { + cache.delete(scopeKey); + return; + } + + cache.set(scopeKey, nextScopeCache); } /** * 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; +export function getModelContextLength(modelName: string, sourceScope?: string): number { + const scopedEntry = buildScopedEntryKey(sourceScope, modelName); + if (!scopedEntry) return DEFAULT_CONTEXT_LENGTH; + return cache.get(scopedEntry[0])?.get(scopedEntry[1]) ?? 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)); +export function hasModelContextLength(modelName: string, sourceScope?: string): boolean { + const scopedEntry = buildScopedEntryKey(sourceScope, modelName); + if (!scopedEntry) return false; + return cache.get(scopedEntry[0])?.has(scopedEntry[1]) ?? false; } /** - * Get all cached entries (for diagnostics). + * Get cached entries for a specific source scope. */ -export function getAllModelContextLengths(): ReadonlyMap { - return cache; +export function getAllModelContextLengths(sourceScope?: string): ReadonlyMap { + return cache.get(normalizeSourceScope(sourceScope)) ?? new Map(); } /** * Clear the cache (for testing or refresh). */ -export function clearModelContextLengthCache(): void { - cache.clear(); +export function clearModelContextLengthCache(sourceScope?: string): void { + if (sourceScope === undefined) { + cache.clear(); + return; + } + cache.delete(normalizeSourceScope(sourceScope)); } /** diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 4cca0d12..4fb9cbbc 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -27,6 +27,7 @@ import { isCodexPlatform } from './oauth/codexAccount.js'; import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js'; import { listEnabledOauthRouteUnitsWithMembers } from './oauth/routeUnitService.js'; +import { buildAccountModelContextLengthScope } from './modelContextLengthCache.js'; import { requireSiteApiBaseUrl } from './siteApiEndpointService.js'; import { discoverAntigravityModelsFromCloud, @@ -885,6 +886,7 @@ export async function refreshModelsForAccount( const accountModels = new Map(); // lowercase key → original name (first-wins) const modelLatency = new Map(); + const modelContextScope = buildAccountModelContextLengthScope(account.id); let scannedTokenCount = 0; let discoveredByCredential = false; const attemptedCredentials = new Set(); @@ -921,7 +923,7 @@ export async function refreshModelsForAccount( models = normalizeModels( await withTimeout( () => withAccountProxyOverride(accountProxyUrl, - () => adapter.getModels(aiBaseUrl, credential, platformUserId)), + () => adapter.getModels(aiBaseUrl, credential, platformUserId, modelContextScope)), MODEL_DISCOVERY_TIMEOUT_MS, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ), @@ -949,7 +951,7 @@ export async function refreshModelsForAccount( models = normalizeModels( await withTimeout( () => withAccountProxyOverride(accountProxyUrl, - () => adapter.getModels(aiBaseUrl, token.token, platformUserId)), + () => adapter.getModels(aiBaseUrl, token.token, platformUserId, modelContextScope)), MODEL_DISCOVERY_TIMEOUT_MS, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ), diff --git a/src/server/services/platforms/base.ts b/src/server/services/platforms/base.ts index 6b87351a..06ba2f14 100644 --- a/src/server/services/platforms/base.ts +++ b/src/server/services/platforms/base.ts @@ -98,7 +98,7 @@ export interface PlatformAdapter { verifyToken(baseUrl: string, token: string, platformUserId?: number): Promise; checkin(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getBalance(baseUrl: string, accessToken: string, platformUserId?: number): Promise; - getModels(baseUrl: string, token: string, platformUserId?: number): Promise; + getModels(baseUrl: string, token: string, platformUserId?: number, contextSourceScope?: string): Promise; getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise; @@ -113,7 +113,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { abstract detect(url: string): Promise; abstract checkin(baseUrl: string, accessToken: string): Promise; abstract getBalance(baseUrl: string, accessToken: string): Promise; - abstract getModels(baseUrl: string, token: string, platformUserId?: number): Promise; + abstract getModels(baseUrl: string, token: string, platformUserId?: number, contextSourceScope?: string): Promise; async verifyToken(baseUrl: string, token: string, _platformUserId?: number): Promise { // 1. Try as session/access token first (for management APIs) diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index d56a5282..35698e78 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -9,13 +9,14 @@ export class ClaudeAdapter extends StandardApiProviderAdapterBase { return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1'); } - async getModels(baseUrl: string, apiToken: string): Promise { + async getModels(baseUrl: string, apiToken: string, _platformUserId?: number, contextSourceScope?: string): Promise { return this.fetchModelsFromStandardEndpoint({ baseUrl, headers: { 'x-api-key': apiToken, 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, }, + contextSourceScope, }); } } diff --git a/src/server/services/platforms/cliproxyapi.ts b/src/server/services/platforms/cliproxyapi.ts index dca644b1..3a48ddd1 100644 --- a/src/server/services/platforms/cliproxyapi.ts +++ b/src/server/services/platforms/cliproxyapi.ts @@ -50,11 +50,12 @@ export class CliProxyApiAdapter extends StandardApiProviderAdapterBase { } } - async getModels(baseUrl: string, apiToken: string): Promise { + async getModels(baseUrl: string, apiToken: string, _platformUserId?: number, contextSourceScope?: string): Promise { return this.fetchModelsFromStandardEndpoint({ baseUrl, headers: { Authorization: `Bearer ${apiToken}` }, resolveUrl: resolveVersionedModelsUrl, + contextSourceScope, }); } } diff --git a/src/server/services/platforms/gemini.ts b/src/server/services/platforms/gemini.ts index 381b35b7..ecc23cb1 100644 --- a/src/server/services/platforms/gemini.ts +++ b/src/server/services/platforms/gemini.ts @@ -49,7 +49,12 @@ export class GeminiAdapter extends StandardApiProviderAdapterBase { ); } - async getModels(baseUrl: string, apiToken: string): Promise { + async getModels( + baseUrl: string, + apiToken: string, + _platformUserId?: number, + contextSourceScope?: string, + ): Promise { const normalizedBase = normalizePlatformBaseUrl(baseUrl); if (isOpenAiCompatGeminiBase(normalizedBase)) { @@ -57,6 +62,7 @@ export class GeminiAdapter extends StandardApiProviderAdapterBase { baseUrl: normalizedBase, headers: { Authorization: `Bearer ${apiToken}` }, resolveUrl: resolveGeminiOpenAiModelsUrl, + contextSourceScope, }); if (openAiModels.length > 0) return normalizeModelList(openAiModels); } @@ -73,6 +79,7 @@ export class GeminiAdapter extends StandardApiProviderAdapterBase { const openAiModels = await this.fetchModelsFromStandardEndpoint({ baseUrl: `${normalizedBase}/v1beta/openai`, headers: { Authorization: `Bearer ${apiToken}` }, + contextSourceScope, }); if (openAiModels.length > 0) return normalizeModelList(openAiModels); } diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index f7f4ca16..4a3b03c8 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -3,7 +3,11 @@ 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'; +import { + buildEndpointModelContextLengthScope, + extractContextLengthsFromPayload, + setModelContextLengths, +} from '../modelContextLengthCache.js'; export class NewApiAdapter extends BasePlatformAdapter { readonly platformName: string = 'new-api'; @@ -838,17 +842,18 @@ export class NewApiAdapter extends BasePlatformAdapter { return []; } - private extractOpenAiModels(payload: any): string[] { + private extractOpenAiModels(payload: any, sourceScope: string): 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); - } + setModelContextLengths(contextLengths, sourceScope); return payload.data.map((m: any) => m?.id).filter(Boolean); } - private async getOpenAiModelsViaShieldCookie(baseUrl: string, token: string): Promise { + private async getOpenAiModelsViaShieldCookie( + baseUrl: string, + token: string, + sourceScope: string, + ): Promise { for (const cookie of this.buildCookieCandidates(token)) { try { const { data } = await fetchJsonWithShieldCookieRetry(`${baseUrl}/v1/models`, { @@ -857,17 +862,18 @@ export class NewApiAdapter extends BasePlatformAdapter { Cookie: cookie, }, }); - const models = this.extractOpenAiModels(data); + const models = this.extractOpenAiModels(data, sourceScope); if (models.length > 0) return models; } catch {} } return []; } - private async getOpenAiModels(baseUrl: string, token: string): Promise { + private async getOpenAiModels(baseUrl: string, token: string, contextSourceScope?: string): Promise { + const sourceScope = contextSourceScope || buildEndpointModelContextLengthScope(baseUrl); const shouldTryShieldCookie = this.platformName === 'anyrouter' || token.includes('='); if (shouldTryShieldCookie) { - const shieldModels = await this.getOpenAiModelsViaShieldCookie(baseUrl, token); + const shieldModels = await this.getOpenAiModelsViaShieldCookie(baseUrl, token, sourceScope); if (shieldModels.length > 0) return shieldModels; } @@ -875,7 +881,7 @@ export class NewApiAdapter extends BasePlatformAdapter { const res = await this.fetchJson(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${token}` }, }); - return this.extractOpenAiModels(res); + return this.extractOpenAiModels(res, sourceScope); } catch { return []; } @@ -1221,8 +1227,13 @@ export class NewApiAdapter extends BasePlatformAdapter { throw new Error(failureMessage || 'failed to fetch balance'); } - async getModels(baseUrl: string, token: string, platformUserId?: number): Promise { - const openAiModels = await this.getOpenAiModels(baseUrl, token); + async getModels( + baseUrl: string, + token: string, + platformUserId?: number, + contextSourceScope?: string, + ): Promise { + const openAiModels = await this.getOpenAiModels(baseUrl, token, contextSourceScope); if (openAiModels.length > 0) return openAiModels; const userId = platformUserId || await this.discoverUserId(baseUrl, token); diff --git a/src/server/services/platforms/oneApi.ts b/src/server/services/platforms/oneApi.ts index 1b0851ac..318b3240 100644 --- a/src/server/services/platforms/oneApi.ts +++ b/src/server/services/platforms/oneApi.ts @@ -1,4 +1,9 @@ import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, CreateApiTokenOptions } from './base.js'; +import { + buildEndpointModelContextLengthScope, + extractContextLengthsFromPayload, + setModelContextLengths, +} from '../modelContextLengthCache.js'; type CreateApiTokenPayload = { name: string; @@ -76,10 +81,19 @@ export class OneApiAdapter extends BasePlatformAdapter { return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption }; } - async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise { + async getModels( + baseUrl: string, + apiToken: string, + _platformUserId?: number, + contextSourceScope?: string, + ): Promise { const res = await this.fetchJson(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${apiToken}` }, }); + setModelContextLengths( + extractContextLengthsFromPayload(res), + contextSourceScope || buildEndpointModelContextLengthScope(baseUrl), + ); return (res?.data || []).map((m: any) => m.id).filter(Boolean); } diff --git a/src/server/services/platforms/oneHub.ts b/src/server/services/platforms/oneHub.ts index 55da00b0..07c50e7a 100644 --- a/src/server/services/platforms/oneHub.ts +++ b/src/server/services/platforms/oneHub.ts @@ -13,10 +13,15 @@ export class OneHubAdapter extends OneApiAdapter { * The /api/available_model endpoint returns { data: { model_name: { price: ... }, ... } } * where the keys are model names. */ - override async getModels(baseUrl: string, apiToken: string, platformUserId?: number): Promise { + override async getModels( + baseUrl: string, + apiToken: string, + platformUserId?: number, + contextSourceScope?: string, + ): Promise { let openAiModels: string[] = []; try { - openAiModels = await super.getModels(baseUrl, apiToken, platformUserId); + openAiModels = await super.getModels(baseUrl, apiToken, platformUserId, contextSourceScope); } catch {} if (openAiModels.length > 0) return openAiModels; diff --git a/src/server/services/platforms/openai.ts b/src/server/services/platforms/openai.ts index 8af3a0c3..7fa0a958 100644 --- a/src/server/services/platforms/openai.ts +++ b/src/server/services/platforms/openai.ts @@ -8,10 +8,11 @@ export class OpenAiAdapter extends StandardApiProviderAdapterBase { return normalized.includes('api.openai.com'); } - async getModels(baseUrl: string, apiToken: string): Promise { + async getModels(baseUrl: string, apiToken: string, _platformUserId?: number, contextSourceScope?: string): Promise { return this.fetchModelsFromStandardEndpoint({ baseUrl, headers: { Authorization: `Bearer ${apiToken}` }, + contextSourceScope, }); } } diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts index ddc827eb..6b41e41b 100644 --- a/src/server/services/platforms/standardApiProvider.ts +++ b/src/server/services/platforms/standardApiProvider.ts @@ -4,13 +4,18 @@ import { type CheckinResult, type UserInfo, } from './base.js'; -import { extractContextLengthsFromPayload, setModelContextLengths } from '../modelContextLengthCache.js'; +import { + buildEndpointModelContextLengthScope, + extractContextLengthsFromPayload, + setModelContextLengths, +} from '../modelContextLengthCache.js'; type FetchModelsOptions = { baseUrl: string; headers?: Record; resolveUrl?: (normalizedBaseUrl: string) => string; mapResponse?: (payload: any) => unknown[]; + contextSourceScope?: string; }; export function normalizePlatformBaseUrl(baseUrl: string): string { @@ -77,9 +82,10 @@ export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter : null; // Also extract and cache context_length from upstream when available const contextLengths = extractContextLengthsFromPayload(payload); - if (contextLengths.size > 0) { - setModelContextLengths(contextLengths); - } + setModelContextLengths( + contextLengths, + options.contextSourceScope || buildEndpointModelContextLengthScope(normalizedBaseUrl), + ); if (!Array.isArray(rows)) { throw new Error('invalid standard models payload'); diff --git a/src/server/services/platforms/veloera.ts b/src/server/services/platforms/veloera.ts index 6257127a..864393f5 100644 --- a/src/server/services/platforms/veloera.ts +++ b/src/server/services/platforms/veloera.ts @@ -1,4 +1,9 @@ import { BasePlatformAdapter, CheckinResult, BalanceInfo } from './base.js'; +import { + buildEndpointModelContextLengthScope, + extractContextLengthsFromPayload, + setModelContextLengths, +} from '../modelContextLengthCache.js'; export class VeloeraAdapter extends BasePlatformAdapter { readonly platformName = 'veloera'; @@ -53,10 +58,19 @@ export class VeloeraAdapter extends BasePlatformAdapter { return { balance: quota - used, used, quota, todayIncome, todayQuotaConsumption }; } - async getModels(baseUrl: string, apiToken: string, _platformUserId?: number): Promise { + async getModels( + baseUrl: string, + apiToken: string, + _platformUserId?: number, + contextSourceScope?: string, + ): Promise { const res = await this.fetchJson(`${baseUrl}/v1/models`, { headers: { Authorization: `Bearer ${apiToken}` }, }); + setModelContextLengths( + extractContextLengthsFromPayload(res), + contextSourceScope || buildEndpointModelContextLengthScope(baseUrl), + ); return (res?.data || []).map((m: any) => m.id).filter(Boolean); } } diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 5489870e..a3857414 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -715,6 +715,8 @@ export default function Accounts() { const handleAddManualModels = async () => { if (!modelModal.account || !modelModal.manualModelsInput.trim()) return; + const account = modelModal.account; + const requestId = modelModalRequestSeqRef.current; const modelsToAdd = modelModal.manualModelsInput .split(",") .map((m) => m.trim()) @@ -729,8 +731,9 @@ export default function Accounts() { ); if (res.success) { toast.success("模型已手动添加"); + if (modelModalRequestSeqRef.current !== requestId) return; setModelModal((s) => ({ ...s, manualModelsInput: "" })); - await loadModelModalModels(modelModal.account, { + await loadModelModalModels(account, { refreshUpstream: false, }); } else { @@ -3479,10 +3482,13 @@ export default function Accounts() { onAddManualModels={handleAddManualModels} onRemoveManualModel={async (modelName) => { if (!modelModal.account) return; + const account = modelModal.account; + const requestId = modelModalRequestSeqRef.current; try { - await api.removeAccountManualModels(modelModal.account.id, [modelName]); + await api.removeAccountManualModels(account.id, [modelName]); toast.success(`已删除模型 ${modelName}`); - await loadModelModalModels(modelModal.account, {}); + if (modelModalRequestSeqRef.current !== requestId) return; + await loadModelModalModels(account, {}); } catch (err: any) { toast.error(err?.message || "删除失败"); } From 3c689a4df0fc158f247b1232116d6b228af648cf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 12 May 2026 20:30:52 +0800 Subject: [PATCH 4/7] fix: preserve model context metadata across credential scans --- .../services/modelService.discovery.test.ts | 79 ++++++++++++++++++- src/server/services/modelService.ts | 29 ++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 9de3f748..c87f9345 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -3,6 +3,12 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; +import { + buildAccountModelContextLengthScope, + clearModelContextLengthCache, + setModelContextLengths, + getModelContextLength, +} from './modelContextLengthCache.js'; const getApiTokenMock = vi.fn(); const getModelsMock = vi.fn(); @@ -70,6 +76,7 @@ describe('refreshModelsForAccount credential discovery', () => { undiciFetchMock.mockReset(); proxyAgentCtorMock.mockReset(); refreshOauthAccessTokenSingleflightMock.mockReset(); + clearModelContextLengthCache(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); @@ -137,6 +144,71 @@ describe('refreshModelsForAccount credential discovery', () => { expect(tokenRows).toHaveLength(0); }); + it('preserves earlier context-length entries when later credential scans return only a subset', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string, _platformUserId: unknown, contextScope?: string) => { + if (!contextScope) return []; + + if (token === 'session-token') { + setModelContextLengths(new Map([ + ['model-a', 128000], + ['model-b', 256000], + ]), contextScope); + return ['model-a', 'model-b']; + } + + if (token === 'managed-token-subset') { + setModelContextLengths(new Map([ + ['model-a', 128000], + ]), contextScope); + return ['model-a']; + } + + return []; + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-context-merge', + url: 'https://site-context-merge.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'context-merge-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'subset-token', + token: 'managed-token-subset', + source: 'manual', + enabled: true, + isDefault: true, + valueStatus: 'ready', + }).run(); + + const result = await refreshModelsForAccount(account.id); + const contextScope = buildAccountModelContextLengthScope(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 2, + modelsPreview: ['model-a', 'model-b'], + tokenScanned: 1, + discoveredByCredential: true, + }); + expect(getModelContextLength('model-a', contextScope)).toBe(128000); + expect(getModelContextLength('model-b', contextScope)).toBe(256000); + }); + it('uses the configured ai endpoint for direct model discovery credentials', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockImplementation(async (baseUrl: string, token: string) => ( @@ -177,7 +249,12 @@ describe('refreshModelsForAccount credential discovery', () => { modelCount: 1, modelsPreview: ['gpt-4.1'], }); - expect(getModelsMock).toHaveBeenCalledWith('https://api.example.com', 'session-token', undefined); + expect(getModelsMock).toHaveBeenCalledWith( + 'https://api.example.com', + 'session-token', + undefined, + expect.stringMatching(/^account:\d+:scan:\d+$/), + ); }); it('deduplicates discovered model names before writing availability rows', async () => { diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 4fb9cbbc..757cdb12 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -27,7 +27,12 @@ import { isCodexPlatform } from './oauth/codexAccount.js'; import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js'; import { listEnabledOauthRouteUnitsWithMembers } from './oauth/routeUnitService.js'; -import { buildAccountModelContextLengthScope } from './modelContextLengthCache.js'; +import { + buildAccountModelContextLengthScope, + clearModelContextLengthCache, + getAllModelContextLengths, + setModelContextLengths, +} from './modelContextLengthCache.js'; import { requireSiteApiBaseUrl } from './siteApiEndpointService.js'; import { discoverAntigravityModelsFromCloud, @@ -887,6 +892,8 @@ export async function refreshModelsForAccount( const accountModels = new Map(); // lowercase key → original name (first-wins) const modelLatency = new Map(); const modelContextScope = buildAccountModelContextLengthScope(account.id); + const discoveredContextLengths = new Map(); + let modelContextScanCounter = 0; let scannedTokenCount = 0; let discoveredByCredential = false; const attemptedCredentials = new Set(); @@ -910,6 +917,15 @@ export async function refreshModelsForAccount( } }; + const beginModelContextScanScope = () => `${modelContextScope}:scan:${modelContextScanCounter += 1}`; + + const collectModelContextLengthsFromScope = (sourceScope: string) => { + for (const [modelName, contextLength] of getAllModelContextLengths(sourceScope)) { + discoveredContextLengths.set(modelName, contextLength); + } + clearModelContextLengthCache(sourceScope); + }; + const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => { const credential = (credentialRaw || '').trim(); if (!credential) return; @@ -918,12 +934,13 @@ export async function refreshModelsForAccount( attemptedCredentials.add(credential); const startedAt = Date.now(); + const credentialContextScope = beginModelContextScanScope(); let models: string[] = []; try { models = normalizeModels( await withTimeout( () => withAccountProxyOverride(accountProxyUrl, - () => adapter.getModels(aiBaseUrl, credential, platformUserId, modelContextScope)), + () => adapter.getModels(aiBaseUrl, credential, platformUserId, credentialContextScope)), MODEL_DISCOVERY_TIMEOUT_MS, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ), @@ -931,6 +948,8 @@ export async function refreshModelsForAccount( } catch (err) { recordFailure(err); models = []; + } finally { + collectModelContextLengthsFromScope(credentialContextScope); } if (models.length === 0) return; discoveredByCredential = true; @@ -945,13 +964,14 @@ export async function refreshModelsForAccount( for (const token of enabledTokens) { const startedAt = Date.now(); + const tokenContextScope = beginModelContextScanScope(); let models: string[] = []; try { models = normalizeModels( await withTimeout( () => withAccountProxyOverride(accountProxyUrl, - () => adapter.getModels(aiBaseUrl, token.token, platformUserId, modelContextScope)), + () => adapter.getModels(aiBaseUrl, token.token, platformUserId, tokenContextScope)), MODEL_DISCOVERY_TIMEOUT_MS, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ), @@ -959,6 +979,8 @@ export async function refreshModelsForAccount( } catch (err) { recordFailure(err); models = []; + } finally { + collectModelContextLengthsFromScope(tokenContextScope); } if (models.length === 0) continue; @@ -1014,6 +1036,7 @@ export async function refreshModelsForAccount( })), ).run(); } + setModelContextLengths(discoveredContextLengths, modelContextScope); await setAccountRuntimeHealth(account.id, { state: 'healthy', From 039d0d92dc92421beaff65365a5c5d2c5d7761f1 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 12 May 2026 20:36:06 +0800 Subject: [PATCH 5/7] fix: canonicalize endpoint model context scope Closes #507 --- .../services/modelContextLengthCache.test.ts | 14 +++++++++++++ .../services/modelContextLengthCache.ts | 21 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/server/services/modelContextLengthCache.test.ts b/src/server/services/modelContextLengthCache.test.ts index 034c4dad..d5bb1620 100644 --- a/src/server/services/modelContextLengthCache.test.ts +++ b/src/server/services/modelContextLengthCache.test.ts @@ -119,6 +119,20 @@ describe('modelContextLengthCache', () => { expect(getModelContextLength('model-a', endpointScope)).toBe(64000); }); + + it('canonicalizes semantically equivalent endpoint urls to the same scope', () => { + const canonicalScope = buildEndpointModelContextLengthScope('https://API.EXAMPLE.com:443/v1/'); + const equivalentScope = buildEndpointModelContextLengthScope(' https://api.example.com/v1 '); + + expect(canonicalScope).toBe(equivalentScope); + }); + + it('keeps distinct endpoint paths in separate scopes', () => { + const rootScope = buildEndpointModelContextLengthScope('https://api.example.com'); + const versionedScope = buildEndpointModelContextLengthScope('https://api.example.com/v1/'); + + expect(rootScope).not.toBe(versionedScope); + }); }); describe('hasModelContextLength', () => { diff --git a/src/server/services/modelContextLengthCache.ts b/src/server/services/modelContextLengthCache.ts index 1d19caa2..f52b466b 100644 --- a/src/server/services/modelContextLengthCache.ts +++ b/src/server/services/modelContextLengthCache.ts @@ -49,8 +49,27 @@ export function buildAccountModelContextLengthScope(accountId: number): string { return `account:${accountId}`; } +function canonicalizeEndpointUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) return ''; + + try { + const url = new URL(trimmed); + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase(); + const isDefaultPort = + (protocol === 'http:' && url.port === '80') + || (protocol === 'https:' && url.port === '443'); + const port = !url.port || isDefaultPort ? '' : `:${url.port}`; + const pathname = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, ''); + return `${protocol}//${hostname}${port}${pathname}`; + } catch { + return trimmed; + } +} + export function buildEndpointModelContextLengthScope(baseUrl: string): string { - return `endpoint:${normalizeKey(baseUrl)}`; + return `endpoint:${normalizeKey(canonicalizeEndpointUrl(baseUrl))}`; } /** From eaa327983389b8b7c9bc616e6e504fed1465e4a6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 10:06:46 +0800 Subject: [PATCH 6/7] fix: isolate concurrent model context scan scopes --- .../services/modelService.discovery.test.ts | 53 ++++++++++++++++++- src/server/services/modelService.ts | 4 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index c87f9345..8b290b49 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -209,6 +209,57 @@ describe('refreshModelsForAccount credential discovery', () => { expect(getModelContextLength('model-b', contextScope)).toBe(256000); }); + it('uses unique temporary context scopes across concurrent refreshes for the same account', async () => { + getApiTokenMock.mockResolvedValue(null); + + const seenScopes: string[] = []; + let releaseGate: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + getModelsMock.mockImplementation(async (_baseUrl: string, token: string, _platformUserId: unknown, contextScope?: string) => { + if (token === 'shared-session-token' && contextScope) { + seenScopes.push(contextScope); + if (seenScopes.length <= 2) { + await gate; + } + } + return []; + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-concurrent-context-scope', + url: 'https://site-concurrent-context-scope.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'concurrent-context-user', + accessToken: 'shared-session-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const firstRefresh = refreshModelsForAccount(account.id); + const secondRefresh = refreshModelsForAccount(account.id); + await Promise.resolve(); + await Promise.resolve(); + releaseGate?.(); + + const [firstResult, secondResult] = await Promise.all([firstRefresh, secondRefresh]); + + expect(firstResult.status).toBe('failed'); + expect(secondResult.status).toBe('failed'); + expect(seenScopes).toHaveLength(2); + expect(seenScopes[0]).not.toBe(seenScopes[1]); + expect(seenScopes[0]).toMatch(/^account:\d+:refresh:[^:]+:scan:1$/); + expect(seenScopes[1]).toMatch(/^account:\d+:refresh:[^:]+:scan:1$/); + }); + it('uses the configured ai endpoint for direct model discovery credentials', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockImplementation(async (baseUrl: string, token: string) => ( @@ -253,7 +304,7 @@ describe('refreshModelsForAccount credential discovery', () => { 'https://api.example.com', 'session-token', undefined, - expect.stringMatching(/^account:\d+:scan:\d+$/), + expect.stringMatching(/^account:\d+:refresh:[^:]+:scan:\d+$/), ); }); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 757cdb12..36c19459 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,4 +1,5 @@ import { and, eq } from 'drizzle-orm'; +import { randomUUID } from 'node:crypto'; import { db, schema } from '../db/index.js'; import { getInsertedRowId } from '../db/insertHelpers.js'; import { getAdapter } from './platforms/index.js'; @@ -892,6 +893,7 @@ export async function refreshModelsForAccount( const accountModels = new Map(); // lowercase key → original name (first-wins) const modelLatency = new Map(); const modelContextScope = buildAccountModelContextLengthScope(account.id); + const modelContextRefreshScope = `${modelContextScope}:refresh:${randomUUID()}`; const discoveredContextLengths = new Map(); let modelContextScanCounter = 0; let scannedTokenCount = 0; @@ -917,7 +919,7 @@ export async function refreshModelsForAccount( } }; - const beginModelContextScanScope = () => `${modelContextScope}:scan:${modelContextScanCounter += 1}`; + const beginModelContextScanScope = () => `${modelContextRefreshScope}:scan:${modelContextScanCounter += 1}`; const collectModelContextLengthsFromScope = (sourceScope: string) => { for (const [modelName, contextLength] of getAllModelContextLengths(sourceScope)) { From edcbce76a026961c27a8047c08d6a2f3e2e5d6c5 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 11:08:12 +0800 Subject: [PATCH 7/7] fix: use conservative model context lengths in models surface --- .../proxy-core/surfaces/modelsSurface.test.ts | 38 ++++++++++++++++ .../proxy-core/surfaces/modelsSurface.ts | 44 ++++++++++++++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/server/proxy-core/surfaces/modelsSurface.test.ts b/src/server/proxy-core/surfaces/modelsSurface.test.ts index e9d19e45..e4b467d5 100644 --- a/src/server/proxy-core/surfaces/modelsSurface.test.ts +++ b/src/server/proxy-core/surfaces/modelsSurface.test.ts @@ -74,6 +74,44 @@ describe('listModelsSurface', () => { }); }); + it('uses the most conservative context length across eligible routing candidates', async () => { + clearModelContextLengthCache(); + setModelContextLength('shared-model', 200000, buildAccountModelContextLengthScope(41)); + setModelContextLength('shared-model', 128000, buildAccountModelContextLengthScope(42)); + + const result = await listModelsSurface({ + downstreamPolicy: { type: 'all' }, + responseFormat: 'openai', + tokenRouter: { + getAvailableModels: vi.fn().mockResolvedValue(['shared-model']), + explainSelection: vi.fn().mockResolvedValue({ + selectedChannelId: 4101, + selectedAccountId: 41, + candidates: [ + { accountId: 41, eligible: true }, + { accountId: 42, eligible: true }, + ], + }), + }, + refreshModelsAndRebuildRoutes: vi.fn(), + isModelAllowed: vi.fn().mockResolvedValue(true), + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(result).toEqual({ + object: 'list', + data: [ + { + id: 'shared-model', + object: 'model', + created: 1773878400, + owned_by: 'metapi', + context_length: 128000, + }, + ], + }); + }); + 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)); diff --git a/src/server/proxy-core/surfaces/modelsSurface.ts b/src/server/proxy-core/surfaces/modelsSurface.ts index 55ba7713..6024e058 100644 --- a/src/server/proxy-core/surfaces/modelsSurface.ts +++ b/src/server/proxy-core/surfaces/modelsSurface.ts @@ -17,6 +17,10 @@ type ModelsSurfaceInput = { explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{ selectedChannelId?: number | null; selectedAccountId?: number | null; + candidates?: Array<{ + accountId?: number | null; + eligible?: boolean; + }>; }>; }; refreshModelsAndRebuildRoutes(): Promise; @@ -24,7 +28,28 @@ type ModelsSurfaceInput = { now?: () => Date; }; -function resolveModelContextLength(modelName: string, selectedAccountId?: number | null): number { +function resolveModelContextLength( + modelName: string, + selectedAccountId?: number | null, + candidates?: Array<{ accountId?: number | null; eligible?: boolean }>, +): number { + const eligibleAccountIds = Array.from(new Set( + (Array.isArray(candidates) ? candidates : []) + .filter((candidate) => candidate?.eligible !== false) + .map((candidate) => candidate?.accountId) + .filter((accountId): accountId is number => typeof accountId === 'number' && accountId > 0), + )); + + if (eligibleAccountIds.length > 0) { + return eligibleAccountIds.reduce((minValue, accountId) => { + const currentValue = getModelContextLength( + modelName, + buildAccountModelContextLengthScope(accountId), + ); + return Math.min(minValue, currentValue); + }, Number.POSITIVE_INFINITY); + } + if (typeof selectedAccountId === 'number' && selectedAccountId > 0) { return getModelContextLength( modelName, @@ -36,11 +61,19 @@ function resolveModelContextLength(modelName: string, selectedAccountId?: number async function readVisibleModels( input: ModelsSurfaceInput, -): Promise> { +): Promise; +}>> { const deduped = Array.from(new Set(await input.tokenRouter.getAvailableModels())) .filter((modelName) => !isSearchPseudoModel(modelName)) .sort(); - const allowed: Array<{ id: string; selectedAccountId?: number | null }> = []; + const allowed: Array<{ + id: string; + selectedAccountId?: number | null; + candidates?: Array<{ accountId?: number | null; eligible?: boolean }>; + }> = []; for (const modelName of deduped) { if (!await input.isModelAllowed(modelName, input.downstreamPolicy)) { continue; @@ -50,6 +83,7 @@ async function readVisibleModels( allowed.push({ id: modelName, selectedAccountId: decision.selectedAccountId, + candidates: decision.candidates, }); } } @@ -78,7 +112,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) { type: 'model' as const, display_name: model.id, created_at: now.toISOString(), - context_length: resolveModelContextLength(model.id, model.selectedAccountId), + context_length: resolveModelContextLength(model.id, model.selectedAccountId, model.candidates), }); } return { @@ -96,7 +130,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) { object: 'model' as const, created: Math.floor(now.getTime() / 1000), owned_by: 'metapi', - context_length: resolveModelContextLength(model.id, model.selectedAccountId), + context_length: resolveModelContextLength(model.id, model.selectedAccountId, model.candidates), })), }; }