[codex] fix cross-database JSON boundary handling (#283)

* fix: normalize cross-database json boundaries

* fix: address json boundary review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
Cita
2026-03-26 20:21:19 +08:00
committed by GitHub
parent 7e1fb8eb1b
commit b3387dfba7
30 changed files with 1134 additions and 97 deletions
+47 -2
View File
@@ -1,7 +1,7 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
type DbModule = typeof import('./index.js');
@@ -14,6 +14,10 @@ describe('db proxy query wrapper', () => {
testUtils = dbModule.__dbProxyTestUtils;
});
beforeEach(() => {
testUtils.resetPostgresJsonTextParsersInstallStateForTests();
});
it('wraps thenable query builders and provides all/get shims', async () => {
const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]);
const queryLike = {
@@ -53,5 +57,46 @@ describe('db proxy query wrapper', () => {
expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 });
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
});
});
it('builds mysql pool options with jsonStrings enabled', () => {
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
uri: 'mysql://root:pass@db.example.com:3306/metapi',
jsonStrings: true,
});
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', true)).toMatchObject({
uri: 'mysql://root:pass@db.example.com:3306/metapi',
jsonStrings: true,
ssl: { rejectUnauthorized: false },
});
});
it('builds postgres pool options with ssl when requested', () => {
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', false)).toEqual({
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
});
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', true)).toMatchObject({
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
ssl: { rejectUnauthorized: false },
});
});
it('installs postgres JSON text parsers idempotently', () => {
const setTypeParser = vi.fn();
const fakeTypes = {
builtins: {
JSON: 114,
JSONB: 3802,
},
setTypeParser,
};
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
expect(setTypeParser).toHaveBeenCalledTimes(2);
expect(setTypeParser).toHaveBeenNthCalledWith(1, 114, 'text', expect.any(Function));
expect(setTypeParser).toHaveBeenNthCalledWith(2, 3802, 'text', expect.any(Function));
const parser = setTypeParser.mock.calls[0][2] as (value: string) => string;
expect(parser('{"ok":true}')).toBe('{"ok":true}');
});
});
+38 -9
View File
@@ -5,6 +5,10 @@ import { drizzle as drizzleSqliteProxy } from 'drizzle-orm/sqlite-proxy';
import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy';
import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
import * as schema from './schema.js';
import {
installPostgresJsonTextParsers,
resetPostgresJsonTextParsersInstallStateForTests,
} from './postgresJsonTextParsers.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
@@ -46,6 +50,31 @@ let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
let proxyLogClientColumnsAvailable: boolean | null = null;
function buildMysqlPoolOptions(
connectionString = config.dbUrl,
sslEnabled = config.dbSsl,
): mysql.PoolOptions {
const poolOptions: mysql.PoolOptions = {
uri: connectionString,
jsonStrings: true,
};
if (sslEnabled) {
poolOptions.ssl = { rejectUnauthorized: false };
}
return poolOptions;
}
function buildPostgresPoolOptions(
connectionString = config.dbUrl,
sslEnabled = config.dbSsl,
): pg.PoolConfig {
const poolOptions: pg.PoolConfig = { connectionString };
if (sslEnabled) {
poolOptions.ssl = { rejectUnauthorized: false };
}
return poolOptions;
}
function resolveSqlitePath(): string {
const raw = (config.dbUrl || '').trim();
if (!raw) {
@@ -1238,11 +1267,7 @@ function initMysqlDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=mysql');
}
const poolOptions: mysql.PoolOptions = { uri: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
mysqlPool = mysql.createPool(poolOptions);
mysqlPool = mysql.createPool(buildMysqlPoolOptions());
const rawDb = drizzleMysqlProxy(
(sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod),
@@ -1274,10 +1299,8 @@ function initPostgresDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=postgres');
}
const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const poolOptions = buildPostgresPoolOptions();
pgPool = new pg.Pool(poolOptions);
const rawDb = drizzlePgProxy(
@@ -1375,4 +1398,10 @@ export const __dbProxyTestUtils = {
shouldWrapObject,
resolveSqlitePath,
resolveVitestSqlitePath,
buildMysqlPoolOptions,
buildPostgresPoolOptions,
installPostgresJsonTextParsers,
ensurePostgresJsonTextParsers: installPostgresJsonTextParsers,
resetPostgresJsonTextParsersInstallStateForTests,
pg,
};
+23
View File
@@ -0,0 +1,23 @@
import pg from 'pg';
export type PgTypesLike = {
builtins: {
JSON: number;
JSONB: number;
};
setTypeParser(oid: number, format: 'text', parser: (value: string) => string): void;
};
let postgresJsonTextParsersInstalled = false;
export function installPostgresJsonTextParsers(typesRegistry: PgTypesLike = pg.types as PgTypesLike): void {
if (postgresJsonTextParsersInstalled) return;
const identity = (value: string) => value;
typesRegistry.setTypeParser(typesRegistry.builtins.JSON, 'text', identity);
typesRegistry.setTypeParser(typesRegistry.builtins.JSONB, 'text', identity);
postgresJsonTextParsersInstalled = true;
}
export function resetPostgresJsonTextParsersInstallStateForTests(): void {
postgresJsonTextParsersInstalled = false;
}
+2
View File
@@ -12,6 +12,7 @@ import {
generateUpgradeSql,
type MysqlIndexPrefixRequirementMap,
} from './schemaArtifactGenerator.js';
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
import { introspectLiveSchema } from './schemaIntrospection.js';
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
@@ -324,6 +325,7 @@ async function createPostgresClient(connectionString: string, ssl: boolean): Pro
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
+3
View File
@@ -19,6 +19,7 @@ import type {
SchemaContractUnique,
} from './schemaContract.js';
import { resolveMigrationsFolder } from './schemaContract.js';
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
import {
normalizeLogicalColumnType,
type LogicalColumnType,
@@ -535,6 +536,7 @@ async function introspectPostgresSchema(input: SchemaIntrospectionInput): Promis
if (input.ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
@@ -785,6 +787,7 @@ async function applyPostgresStatements(
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
try {
@@ -120,7 +120,7 @@ function createSelectedChannel(options?: {
siteUrl?: string;
sitePlatform?: string;
username?: string;
extraConfig?: string;
extraConfig?: unknown;
tokenValue?: string;
actualModel?: string;
}) {
@@ -1435,6 +1435,114 @@ describe('responses websocket transport', () => {
it('falls back to the HTTP responses route when codex upstream websocket is globally disabled', async () => {
(config as any).codexUpstreamWebsocketEnabled = false;
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = createClientSocket(baseUrl);
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_1',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [],
}));
const firstMessage = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_2',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: 'resp_http_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
const secondMessage = await secondResponsePromise;
socket.close();
expect(firstMessage).toMatchObject({
type: 'response.completed',
response: {
id: 'resp_http_1',
},
});
expect(secondMessage).toMatchObject({
type: 'response.completed',
response: {
id: 'resp_http_2',
},
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({
model: 'gpt-5.4',
instructions: 'be helpful',
input: [],
stream: true,
store: false,
}),
});
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
const secondBody = JSON.parse(String(secondOptions.body));
expect(secondBody.previous_response_id).toBeUndefined();
expect(secondBody.instructions).toBe('be helpful');
expect(secondBody.stream).toBe(true);
expect(secondBody.store).toBe(false);
expect(secondBody.input).toEqual([
{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'first' }],
},
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
]);
});
it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => {
const selectedChannel = createSelectedChannel({
extraConfig: {
credentialMode: 'session',
websockets: false,
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
},
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
+44 -1
View File
@@ -57,6 +57,32 @@ function headerValueToTrimmedString(value: unknown): string {
return '';
}
function toBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
}
return null;
}
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
if (isRecord(extraConfig)) return extraConfig;
if (typeof extraConfig !== 'string') return null;
try {
const parsed = JSON.parse(extraConfig);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const nested = value[key];
return isRecord(nested) ? nested : null;
}
function selectedChannelModelMatches(
selectedChannel: SelectedChannel | null,
requestModel: string,
@@ -76,7 +102,24 @@ function selectedChannelSupportsCodexWebsocketTransport(
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
if (platform !== 'codex') return false;
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
return !!config.codexUpstreamWebsocketEnabled;
if (!config.codexUpstreamWebsocketEnabled) return false;
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
const oauth = readNestedRecord(extraConfig, 'oauth');
const providerData = readNestedRecord(oauth, 'providerData');
const candidateFlags = [
extraConfig?.websockets,
readNestedRecord(extraConfig, 'attributes')?.websockets,
readNestedRecord(extraConfig, 'metadata')?.websockets,
providerData?.websockets,
readNestedRecord(providerData, 'attributes')?.websockets,
readNestedRecord(providerData, 'metadata')?.websockets,
];
for (const candidate of candidateFlags) {
const parsed = toBooleanLike(candidate);
if (parsed !== null) return parsed;
}
return true;
}
function selectedChannelSupportsIncrementalInput(
@@ -18,6 +18,7 @@ describe('accountExtraConfig', () => {
it('reads platformUserId from extra config when present', () => {
expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: 11494 }))).toBe(11494);
expect(getPlatformUserIdFromExtraConfig(JSON.stringify({ platformUserId: '7659' }))).toBe(7659);
expect(getPlatformUserIdFromExtraConfig({ platformUserId: 2233 })).toBe(2233);
});
it('guesses platformUserId from username suffix digits', () => {
@@ -47,6 +48,27 @@ describe('accountExtraConfig', () => {
expect(parsed.platformUserId).toBe(7659);
});
it('merges object extra config without dropping existing keys', () => {
const merged = mergeAccountExtraConfig(
{
foo: 'bar',
credentialMode: 'session',
autoRelogin: { username: 'demo', passwordCipher: 'cipher' },
},
{ platformUserId: 9001 },
);
expect(JSON.parse(merged)).toEqual(expect.objectContaining({
foo: 'bar',
credentialMode: 'session',
platformUserId: 9001,
autoRelogin: expect.objectContaining({
username: 'demo',
passwordCipher: 'cipher',
}),
}));
});
it('parses credential mode from extra config', () => {
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'apikey' }))).toBe('apikey');
expect(getCredentialModeFromExtraConfig(JSON.stringify({ credentialMode: 'session' }))).toBe('session');
+21 -13
View File
@@ -39,11 +39,19 @@ type AccountExtraConfig = {
[key: string]: unknown;
};
function parseExtraConfig(extraConfig?: string | null): AccountExtraConfig {
type ExtraConfigInput = string | Record<string, unknown> | null | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseExtraConfig(extraConfig?: ExtraConfigInput): AccountExtraConfig {
if (!extraConfig) return {};
if (isRecord(extraConfig)) return extraConfig as AccountExtraConfig;
if (typeof extraConfig !== 'string') return {};
try {
const parsed = JSON.parse(extraConfig) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
if (!isRecord(parsed)) return {};
return parsed as AccountExtraConfig;
} catch {
return {};
@@ -114,34 +122,34 @@ export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | u
return normalized as AccountCredentialMode;
}
export function getProxyUrlFromExtraConfig(extraConfig?: string | null): string | null {
export function getProxyUrlFromExtraConfig(extraConfig?: ExtraConfigInput): string | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeNonEmptyString(parsed.proxyUrl) ?? null;
}
export function getPlatformUserIdFromExtraConfig(extraConfig?: string | null): number | undefined {
export function getPlatformUserIdFromExtraConfig(extraConfig?: ExtraConfigInput): number | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeUserId(parsed.platformUserId);
}
export function getCredentialModeFromExtraConfig(extraConfig?: string | null): AccountCredentialMode | undefined {
export function getCredentialModeFromExtraConfig(extraConfig?: ExtraConfigInput): AccountCredentialMode | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeCredentialMode(parsed.credentialMode);
}
export function getOauthProviderFromExtraConfig(extraConfig?: string | null): string | undefined {
export function getOauthProviderFromExtraConfig(extraConfig?: ExtraConfigInput): string | undefined {
const parsed = parseExtraConfig(extraConfig);
return normalizeNonEmptyString(parsed.oauth?.provider);
}
export function hasOauthProvider(extraConfig?: string | null): boolean {
export function hasOauthProvider(extraConfig?: ExtraConfigInput): boolean {
return !!getOauthProviderFromExtraConfig(extraConfig);
}
type DirectAccountRoutingInput = {
accessToken?: string | null;
apiToken?: string | null;
extraConfig?: string | null;
extraConfig?: ExtraConfigInput;
};
function hasCredentialValue(value: string | null | undefined): boolean {
@@ -181,7 +189,7 @@ export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
updatedAt: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
export function getSub2ApiAuthFromExtraConfig(extraConfig?: ExtraConfigInput): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -284,7 +292,7 @@ export function buildStoredSub2ApiSubscriptionSummary(
}
export function getSub2ApiSubscriptionFromExtraConfig(
extraConfig?: string | null,
extraConfig?: ExtraConfigInput,
): StoredSub2ApiSubscriptionSummary | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
@@ -298,12 +306,12 @@ export function guessPlatformUserIdFromUsername(username?: string | null): numbe
return normalizeUserId(match[1]);
}
export function resolvePlatformUserId(extraConfig?: string | null, username?: string | null): number | undefined {
export function resolvePlatformUserId(extraConfig?: ExtraConfigInput, username?: string | null): number | undefined {
return getPlatformUserIdFromExtraConfig(extraConfig) || guessPlatformUserIdFromUsername(username);
}
export function mergeAccountExtraConfig(
extraConfig: string | null | undefined,
extraConfig: ExtraConfigInput,
patch: Record<string, unknown>,
): string {
const merged: Record<string, unknown> = {
@@ -313,7 +321,7 @@ export function mergeAccountExtraConfig(
return JSON.stringify(merged);
}
export function getAutoReloginConfig(extraConfig?: string | null): {
export function getAutoReloginConfig(extraConfig?: ExtraConfigInput): {
username: string;
passwordCipher: string;
} | null {
@@ -79,6 +79,28 @@ describe('accountHealthService', () => {
});
});
it('returns stored runtime health when extra config is already a parsed object', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
siteStatus: 'active',
extraConfig: {
runtimeHealth: {
state: 'healthy',
reason: '余额刷新成功',
source: 'balance',
checkedAt: '2026-02-25T12:00:00.000Z',
},
},
});
expect(health).toMatchObject({
state: 'healthy',
reason: '余额刷新成功',
source: 'balance',
checkedAt: '2026-02-25T12:00:00.000Z',
});
});
it('falls back to unknown when no runtime health info exists', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
+10 -5
View File
@@ -19,12 +19,17 @@ const VALID_RUNTIME_HEALTH_STATES = new Set<RuntimeHealthState>([
'disabled',
]);
function parseObject(value: string | null | undefined): Record<string, unknown> {
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseObject(value: string | Record<string, unknown> | null | undefined): Record<string, unknown> {
if (!value) return {};
if (isRecord(value)) return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
@@ -77,7 +82,7 @@ function defaultHealthReason(state: RuntimeHealthState): string {
}
}
export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealthInfo | null {
export function extractRuntimeHealth(extraConfig?: string | Record<string, unknown> | null): RuntimeHealthInfo | null {
const parsed = parseObject(extraConfig);
return normalizeRuntimeHealthRecord(parsed.runtimeHealth);
}
@@ -94,7 +99,7 @@ function isProxyOnlyAuthFailure(
export function buildRuntimeHealthForAccount(input: {
accountStatus?: string | null;
siteStatus?: string | null;
extraConfig?: string | null;
extraConfig?: string | Record<string, unknown> | null;
sessionCapable?: boolean;
hasDiscoveredModels?: boolean;
}): RuntimeHealthInfo {
@@ -245,6 +245,452 @@ describe('databaseMigrationService', () => {
expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}');
});
it('serializes parsed JSON-column values when building migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
customHeaders: { 'x-site-scope': 'internal' },
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [{
id: 2,
siteId: 1,
username: 'user-1',
accessToken: 'access-1',
extraConfig: { platformUserId: 42 },
status: 'active',
}],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [{
id: 3,
modelPattern: '*',
modelMapping: { '*': 'gpt-4o-mini' },
decisionSnapshot: { channels: [1] },
enabled: true,
}],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [{
id: 4,
billingDetails: { total: 1.25 },
}],
proxyVideoTasks: [{
id: 5,
publicId: 'video-public-id',
upstreamVideoId: 'upstream-video-id',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
statusSnapshot: { status: 'done' },
upstreamResponseMeta: { id: 'video' },
}],
proxyFiles: [],
downstreamApiKeys: [{
id: 6,
name: 'managed-key',
key: 'mk-demo',
supportedModels: ['gpt-4o-mini'],
allowedRouteIds: [3],
siteWeightMultipliers: { 1: 1.5 },
enabled: true,
}],
events: [],
},
preferences: {
settings: [],
},
} as any);
const siteStatement = statements.find((statement) => statement.table === 'sites');
const accountStatement = statements.find((statement) => statement.table === 'accounts');
const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes');
const proxyLogStatement = statements.find((statement) => statement.table === 'proxy_logs');
const proxyVideoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks');
const downstreamKeyStatement = statements.find((statement) => statement.table === 'downstream_api_keys');
expect(siteStatement?.values[siteStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}');
expect(accountStatement?.values[accountStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42}');
expect(tokenRouteStatement?.values[tokenRouteStatement.columns.indexOf('model_mapping')]).toBe('{"*":"gpt-4o-mini"}');
expect(tokenRouteStatement?.values[tokenRouteStatement.columns.indexOf('decision_snapshot')]).toBe('{"channels":[1]}');
expect(proxyLogStatement?.values[proxyLogStatement.columns.indexOf('billing_details')]).toBe('{"total":1.25}');
expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}');
expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4o-mini"]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}');
});
it('uses schema logical types to serialize JSON columns instead of String(value)', () => {
expect(__databaseMigrationServiceTestUtils.serializeColumnValue('sites', 'custom_headers', {
'x-site-scope': 'internal',
})).toBe('{"x-site-scope":"internal"}');
expect(__databaseMigrationServiceTestUtils.serializeColumnValue('downstream_api_keys', 'supported_models', [
'gpt-4o-mini',
])).toBe('["gpt-4o-mini"]');
expect(__databaseMigrationServiceTestUtils.serializeColumnValue('sites', 'name', {
demo: true,
})).toBe('[object Object]');
});
it('serializes object-backed JSON columns when building migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
customHeaders: { 'x-site-scope': 'internal' },
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [{
id: 2,
siteId: 1,
accessToken: 'access',
extraConfig: { platformUserId: 1234 },
status: 'active',
}],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [{
id: 3,
modelPattern: 'gpt-*',
modelMapping: { 'gpt-*': 'gpt-5-mini' },
decisionSnapshot: { candidates: [1, 2] },
enabled: true,
}],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [{
id: 4,
billingDetails: { currency: 'usd', total: 1.23 },
}],
proxyVideoTasks: [{
id: 5,
publicId: 'video-public-id',
upstreamVideoId: 'upstream-video-id',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
statusSnapshot: { status: 'done' },
upstreamResponseMeta: { id: 'video' },
}],
proxyFiles: [],
downstreamApiKeys: [{
id: 6,
name: 'managed',
key: 'sk-managed',
enabled: true,
supportedModels: ['gpt-5', 'gpt-5-mini'],
allowedRouteIds: [10, 11],
siteWeightMultipliers: { 1: 2, 2: 0.5 },
}],
events: [],
},
preferences: {
settings: [],
},
} as any);
const sitesStatement = statements.find((statement) => statement.table === 'sites');
expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}');
const accountsStatement = statements.find((statement) => statement.table === 'accounts');
expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":1234}');
const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-*":"gpt-5-mini"}');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"candidates":[1,2]}');
const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs');
expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"currency":"usd","total":1.23}');
const proxyVideoTasksStatement = statements.find((statement) => statement.table === 'proxy_video_tasks');
expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}');
expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}');
const downstreamApiKeysStatement = statements.find((statement) => statement.table === 'downstream_api_keys');
expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('supported_models')]).toBe('["gpt-5","gpt-5-mini"]');
expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('allowed_route_ids')]).toBe('[10,11]');
expect(downstreamApiKeysStatement?.values[downstreamApiKeysStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2,"2":0.5}');
});
it('serializes JSON logical-type columns from object and array values', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
customHeaders: { 'x-site-scope': 'internal' },
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [{
id: 2,
siteId: 1,
username: 'user-1',
accessToken: 'token-1',
extraConfig: { platformUserId: 1001, credentialMode: 'session' },
status: 'active',
}],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [{
id: 3,
modelPattern: 'gpt-*',
modelMapping: { 'gpt-*': 'gpt-4.1' },
decisionSnapshot: { matched: true, channels: [1] },
enabled: true,
}],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [{
id: 4,
billingDetails: { source: 'pricing', total: 1.25 },
}],
proxyVideoTasks: [{
id: 5,
publicId: 'video-1',
upstreamVideoId: 'upstream-1',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
requestedModel: 'veo-3',
actualModel: 'veo-3',
channelId: 9,
accountId: 2,
statusSnapshot: { status: 'done' },
upstreamResponseMeta: { id: 'video' },
}],
proxyFiles: [],
downstreamApiKeys: [{
id: 6,
name: 'managed',
key: 'sk-managed',
supportedModels: ['gpt-4.1', 'gpt-4o'],
allowedRouteIds: [3, 8],
siteWeightMultipliers: { 1: 2 },
enabled: true,
}],
events: [],
},
preferences: {
settings: [],
},
} as any);
const siteStatement = statements.find((statement) => statement.table === 'sites');
const accountStatement = statements.find((statement) => statement.table === 'accounts');
const routeStatement = statements.find((statement) => statement.table === 'token_routes');
const proxyLogStatement = statements.find((statement) => statement.table === 'proxy_logs');
const videoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks');
const downstreamKeyStatement = statements.find((statement) => statement.table === 'downstream_api_keys');
expect(siteStatement?.values[siteStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}');
expect(accountStatement?.values[accountStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":1001,"credentialMode":"session"}');
expect(routeStatement?.values[routeStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-*":"gpt-4.1"}');
expect(routeStatement?.values[routeStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"channels":[1]}');
expect(proxyLogStatement?.values[proxyLogStatement.columns.indexOf('billing_details')]).toBe('{"source":"pricing","total":1.25}');
expect(videoStatement?.values[videoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}');
expect(videoStatement?.values[videoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1","gpt-4o"]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('allowed_route_ids')]).toBe('[3,8]');
expect(downstreamKeyStatement?.values[downstreamKeyStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2}');
});
it('serializes JSON logical-type columns from parsed objects and arrays', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
customHeaders: { 'x-site-scope': 'internal' },
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [{
id: 2,
siteId: 1,
username: 'demo-user',
accessToken: 'token',
extraConfig: { platformUserId: 42 },
status: 'active',
}],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [{
id: 3,
modelPattern: 'gpt-*',
modelMapping: { 'gpt-4.1': 'gpt-4o-mini' },
decisionSnapshot: { matched: true, routeId: 3 },
enabled: true,
}],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [{
id: 4,
billingDetails: { total: 1.25, currency: 'USD' },
}],
proxyVideoTasks: [{
id: 5,
publicId: 'vid_1',
upstreamVideoId: 'upstream_1',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
statusSnapshot: { status: 'done' },
upstreamResponseMeta: { id: 'video-1' },
}],
proxyFiles: [],
downstreamApiKeys: [{
id: 6,
name: 'managed-key',
key: 'sk-managed',
supportedModels: ['gpt-4.1', 'gpt-4o-mini'],
allowedRouteIds: [3],
siteWeightMultipliers: { 1: 1.5 },
}],
events: [],
},
preferences: {
settings: [],
},
} as any);
const sitesStatement = statements.find((statement) => statement.table === 'sites');
const accountsStatement = statements.find((statement) => statement.table === 'accounts');
const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes');
const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs');
const proxyVideoStatement = statements.find((statement) => statement.table === 'proxy_video_tasks');
const downstreamStatement = statements.find((statement) => statement.table === 'downstream_api_keys');
expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}');
expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42}');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-4.1":"gpt-4o-mini"}');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"routeId":3}');
expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"total":1.25,"currency":"USD"}');
expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}');
expect(proxyVideoStatement?.values[proxyVideoStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video-1"}');
expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1","gpt-4o-mini"]');
expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]');
expect(downstreamStatement?.values[downstreamStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":1.5}');
});
it('serializes JSON logical-type columns without coercing objects to [object Object]', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
timestamp: Date.now(),
accounts: {
sites: [{
id: 1,
name: 'demo',
url: 'https://example.com',
platform: 'openai',
customHeaders: { 'x-site-scope': 'internal' },
status: 'active',
}],
siteAnnouncements: [],
siteDisabledModels: [],
accounts: [{
id: 2,
siteId: 1,
username: 'user',
accessToken: 'access',
apiToken: 'api',
extraConfig: { platformUserId: 42, credentialMode: 'session' },
status: 'active',
}],
accountTokens: [],
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [{
id: 3,
modelPattern: '*',
modelMapping: { 'gpt-4.1': 'gpt-4o-mini' },
decisionSnapshot: { matched: true, routeId: 3 },
enabled: true,
}],
routeChannels: [],
routeGroupSources: [],
proxyLogs: [{
id: 4,
billingDetails: { source: 'pricing', usd: 1.25 },
}],
proxyVideoTasks: [{
id: 5,
publicId: 'vid_1',
upstreamVideoId: 'upstream_1',
siteUrl: 'https://example.com',
tokenValue: 'sk-video',
requestedModel: 'veo-3',
actualModel: 'veo-3',
statusSnapshot: { status: 'done' },
upstreamResponseMeta: { id: 'video' },
}],
proxyFiles: [],
downstreamApiKeys: [{
id: 6,
name: 'managed',
key: 'key-1',
supportedModels: ['gpt-4.1'],
allowedRouteIds: [3],
siteWeightMultipliers: { 1: 2 },
enabled: true,
}],
events: [],
},
preferences: {
settings: [],
},
} as any);
const sitesStatement = statements.find((statement) => statement.table === 'sites');
const accountsStatement = statements.find((statement) => statement.table === 'accounts');
const tokenRoutesStatement = statements.find((statement) => statement.table === 'token_routes');
const proxyLogsStatement = statements.find((statement) => statement.table === 'proxy_logs');
const proxyVideoTasksStatement = statements.find((statement) => statement.table === 'proxy_video_tasks');
const downstreamKeysStatement = statements.find((statement) => statement.table === 'downstream_api_keys');
expect(sitesStatement?.values[sitesStatement.columns.indexOf('custom_headers')]).toBe('{"x-site-scope":"internal"}');
expect(accountsStatement?.values[accountsStatement.columns.indexOf('extra_config')]).toBe('{"platformUserId":42,"credentialMode":"session"}');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('model_mapping')]).toBe('{"gpt-4.1":"gpt-4o-mini"}');
expect(tokenRoutesStatement?.values[tokenRoutesStatement.columns.indexOf('decision_snapshot')]).toBe('{"matched":true,"routeId":3}');
expect(proxyLogsStatement?.values[proxyLogsStatement.columns.indexOf('billing_details')]).toBe('{"source":"pricing","usd":1.25}');
expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('status_snapshot')]).toBe('{"status":"done"}');
expect(proxyVideoTasksStatement?.values[proxyVideoTasksStatement.columns.indexOf('upstream_response_meta')]).toBe('{"id":"video"}');
expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('supported_models')]).toBe('["gpt-4.1"]');
expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('allowed_route_ids')]).toBe('[3]');
expect(downstreamKeysStatement?.values[downstreamKeysStatement.columns.indexOf('site_weight_multipliers')]).toBe('{"1":2}');
});
it('includes disabled models, proxy video tasks, and proxy files in migration statements', () => {
const statements = __databaseMigrationServiceTestUtils.buildStatements({
version: 'test',
+51 -11
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3';
import currentSchemaContract from '../db/generated/schemaContract.json' with { type: 'json' };
import { db, schema } from '../db/index.js';
import {
createRuntimeSchemaClient,
@@ -86,6 +87,15 @@ interface InsertStatement {
const DIALECTS: MigrationDialect[] = ['sqlite', 'mysql', 'postgres'];
const RUNTIME_DATABASE_SETTING_KEYS = new Set(['db_type', 'db_url', 'db_ssl']);
type SchemaContractShape = {
tables: Record<string, {
columns: Record<string, {
logicalType: string | null;
}>;
}>;
};
type LogicalColumnTypeShape = string | null;
const schemaContract = currentSchemaContract as SchemaContractShape;
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
@@ -113,6 +123,32 @@ function asNullableString(value: unknown): string | null {
return String(value);
}
function getColumnLogicalType(
table: string,
column: string,
contract: SchemaContractShape = schemaContract,
): LogicalColumnTypeShape | null {
return contract.tables[table]?.columns[column]?.logicalType ?? null;
}
function serializeJsonColumnValue(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
function serializeColumnValue(
table: string,
column: string,
value: unknown,
contract: SchemaContractShape = schemaContract,
): string | null {
if (getColumnLogicalType(table, column, contract) === 'json') {
return serializeJsonColumnValue(value);
}
return asNullableString(value);
}
function toJsonString(value: unknown): string {
return JSON.stringify(value ?? null);
}
@@ -274,7 +310,10 @@ async function clearTargetData(client: SqlClient): Promise<void> {
}
}
function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
function buildStatements(
snapshot: BackupSnapshot,
contract: SchemaContractShape = schemaContract,
): InsertStatement[] {
const statements: InsertStatement[] = [];
for (const row of snapshot.accounts.sites) {
@@ -289,7 +328,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNullableString(row.platform),
asNullableString(row.proxyUrl),
asBoolean(row.useSystemProxy, false),
asNullableString(row.customHeaders),
serializeColumnValue('sites', 'custom_headers', row.customHeaders, contract),
asNullableString(row.status) ?? 'active',
asBoolean(row.isPinned, false),
asNumber(row.sortOrder, 0),
@@ -379,7 +418,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asBoolean(row.checkinEnabled, true),
asNullableString(row.lastCheckinAt),
asNullableString(row.lastBalanceRefresh),
asNullableString(row.extraConfig),
serializeColumnValue('accounts', 'extra_config', row.extraConfig, contract),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
],
@@ -460,9 +499,9 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
serializeColumnValue('token_routes', 'model_mapping', row.modelMapping, contract),
asNullableString(row.routeMode) ?? 'pattern',
asNullableString(row.decisionSnapshot),
serializeColumnValue('token_routes', 'decision_snapshot', row.decisionSnapshot, contract),
asNullableString(row.decisionRefreshedAt),
asNullableString(row.routingStrategy),
asBoolean(row.enabled, true),
@@ -531,7 +570,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNumber(row.completionTokens, null),
asNumber(row.totalTokens, null),
asNumber(row.estimatedCost, null),
asNullableString(row.billingDetails),
serializeColumnValue('proxy_logs', 'billing_details', row.billingDetails, contract),
asNullableString(row.errorMessage),
asNumber(row.retryCount, 0),
asNullableString(row.createdAt),
@@ -553,8 +592,8 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNullableString(row.actualModel),
asNumber(row.channelId, null),
asNumber(row.accountId, null),
asNullableString(row.statusSnapshot),
asNullableString(row.upstreamResponseMeta),
serializeColumnValue('proxy_video_tasks', 'status_snapshot', row.statusSnapshot, contract),
serializeColumnValue('proxy_video_tasks', 'upstream_response_meta', row.upstreamResponseMeta, contract),
asNumber(row.lastUpstreamStatus, null),
asNullableString(row.lastPolledAt),
asNullableString(row.createdAt),
@@ -600,9 +639,9 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
asNumber(row.usedCost, 0),
asNumber(row.maxRequests, null),
asNumber(row.usedRequests, 0),
asNullableString(row.supportedModels),
asNullableString(row.allowedRouteIds),
asNullableString(row.siteWeightMultipliers),
serializeColumnValue('downstream_api_keys', 'supported_models', row.supportedModels, contract),
serializeColumnValue('downstream_api_keys', 'allowed_route_ids', row.allowedRouteIds, contract),
serializeColumnValue('downstream_api_keys', 'site_weight_multipliers', row.siteWeightMultipliers, contract),
asNullableString(row.lastUsedAt),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
@@ -775,4 +814,5 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
export const __databaseMigrationServiceTestUtils = {
ensureSchema: ensureRuntimeDatabaseSchema,
buildStatements,
serializeColumnValue,
};
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
buildCodexOauthInfo,
getCodexOauthInfoFromExtraConfig,
isCodexPlatform,
} from './codexAccount.js';
describe('codexAccount', () => {
it('reads codex oauth info from parsed extra config objects', () => {
const extraConfig = {
oauth: {
provider: 'codex',
accountKey: 'chatgpt-account-123',
refreshToken: 'refresh-token',
},
};
expect(getCodexOauthInfoFromExtraConfig(extraConfig)).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'chatgpt-account-123',
accountKey: 'chatgpt-account-123',
refreshToken: 'refresh-token',
}));
});
it('recognizes parsed codex extra config objects as codex platform accounts', () => {
expect(isCodexPlatform({
oauth: {
provider: 'codex',
accountKey: 'chatgpt-account-123',
},
})).toBe(true);
});
it('builds codex oauth info from parsed extra config objects', () => {
const oauth = buildCodexOauthInfo({
oauth: {
provider: 'codex',
accountKey: 'chatgpt-account-123',
refreshToken: 'refresh-token',
},
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'chatgpt-account-123',
accountKey: 'chatgpt-account-123',
refreshToken: 'refresh-token',
}));
});
});
+30 -4
View File
@@ -10,21 +10,47 @@ export type CodexOauthInfo = OauthInfo & {
provider: 'codex';
};
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
type OauthExtraConfigInput = Parameters<typeof getOauthInfoFromExtraConfig>[0];
type BuildOauthInfoInput = Parameters<typeof buildOauthInfo>[0];
type OauthIdentityCarrierLike = Pick<
typeof schema.accounts.$inferSelect,
'extraConfig' | 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId'
>;
function isOauthIdentityCarrierLike(value: unknown): value is OauthIdentityCarrierLike {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return 'extraConfig' in value
|| 'oauthProvider' in value
|| 'oauthAccountKey' in value
|| 'oauthProjectId' in value;
}
export function getCodexOauthInfoFromExtraConfig(extraConfig?: OauthExtraConfigInput): CodexOauthInfo | null {
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
return oauth as CodexOauthInfo;
}
export function isCodexPlatform(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
account: OauthIdentityCarrierLike | OauthExtraConfigInput,
): boolean {
return isOauthProvider(account, 'codex');
if (!account || typeof account === 'string') {
return isOauthProvider(account, 'codex');
}
if (isOauthIdentityCarrierLike(account)) {
return isOauthProvider(account, 'codex');
}
return getCodexOauthInfoFromExtraConfig(account) !== null;
}
export function buildCodexOauthInfo(
extraConfig?: string | null,
extraConfig?: BuildOauthInfoInput,
patch: Partial<CodexOauthInfo> = {},
): CodexOauthInfo {
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
}
export type {
OauthExtraConfigInput,
OauthIdentityCarrierLike,
};
@@ -58,6 +58,30 @@ describe('oauth account identity helpers', () => {
}));
});
it('reads oauth metadata when extraConfig is already a parsed object', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
extraConfig: {
oauth: {
provider: 'codex',
accountKey: 'json-user',
projectId: 'json-project',
refreshToken: 'refresh-token',
},
},
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'json-user',
accountKey: 'json-user',
projectId: 'json-project',
refreshToken: 'refresh-token',
}));
});
it('reconstructs oauth runtime state from extraConfig even when identity fields are stripped', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: 'gemini-cli',
+15 -7
View File
@@ -23,6 +23,8 @@ type ParsedExtraConfig = {
oauth?: ParsedOauthInfo;
};
type ExtraConfigInput = string | Record<string, unknown> | null | undefined;
export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal';
export type OauthInfo = {
@@ -46,7 +48,7 @@ export type OauthInfo = {
export type StoredOauthState = Omit<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
type OauthIdentityCarrier = {
extraConfig?: string | null;
extraConfig?: ExtraConfigInput;
oauthProvider?: string | null;
oauthAccountKey?: string | null;
oauthProjectId?: string | null;
@@ -54,11 +56,17 @@ type OauthIdentityCarrier = {
type StoredOauthIdentity = Pick<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseExtraConfig(extraConfig?: ExtraConfigInput): ParsedExtraConfig {
if (!extraConfig) return {};
if (isRecord(extraConfig)) return extraConfig as ParsedExtraConfig;
if (typeof extraConfig !== 'string') return {};
try {
const parsed = JSON.parse(extraConfig) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
if (!isRecord(parsed)) return {};
return parsed as ParsedExtraConfig;
} catch {
return {};
@@ -115,7 +123,7 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und
return undefined;
}
function parseStoredOauthIdentity(extraConfig?: string | null): StoredOauthIdentity | null {
function parseStoredOauthIdentity(extraConfig?: ExtraConfigInput): StoredOauthIdentity | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
@@ -129,7 +137,7 @@ function parseStoredOauthIdentity(extraConfig?: string | null): StoredOauthIdent
};
}
function parseStoredOauthRuntimeState(extraConfig?: string | null): Partial<OauthInfo> | null {
function parseStoredOauthRuntimeState(extraConfig?: ExtraConfigInput): Partial<OauthInfo> | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return {
@@ -147,7 +155,7 @@ function parseStoredOauthRuntimeState(extraConfig?: string | null): Partial<Oaut
};
}
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
export function getOauthInfoFromExtraConfig(extraConfig?: ExtraConfigInput): OauthInfo | null {
const identity = parseStoredOauthIdentity(extraConfig);
const runtime = parseStoredOauthRuntimeState(extraConfig);
const provider = identity?.provider;
@@ -212,7 +220,7 @@ export function buildOauthIdentityBackfillPatch(
}
export function buildOauthInfo(
extraConfig?: string | null,
extraConfig?: ExtraConfigInput,
patch: Partial<OauthInfo> = {},
): OauthInfo {
const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider;
+12 -9
View File
@@ -21,13 +21,21 @@ import {
buildStoredOauthState,
buildStoredOauthStateFromAccount,
getOauthInfoFromAccount,
type OauthInfo,
} from './oauthAccount.js';
import { buildCodexOauthInfo } from './codexAccount.js';
import {
buildCodexOauthInfo,
type OauthExtraConfigInput,
type OauthIdentityCarrierLike,
} from './codexAccount.js';
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
const MANUAL_CALLBACK_DELAY_MS = 15_000;
type OauthProviderHeaderAccountInput = OauthIdentityCarrierLike & {
extraConfig?: OauthExtraConfigInput;
};
type OAuthStartInstructions = {
redirectUri: string;
@@ -597,13 +605,8 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
}
export function buildOauthProviderHeaders(input: {
account?: {
extraConfig?: string | null;
oauthProvider?: string | null;
oauthAccountKey?: string | null;
oauthProjectId?: string | null;
} | null;
extraConfig?: string | null;
account?: OauthProviderHeaderAccountInput | null;
extraConfig?: OauthExtraConfigInput;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = getOauthInfoFromAccount(input.account || {
@@ -619,7 +622,7 @@ export function buildOauthProviderHeaders(input: {
}
export function buildCodexOauthProviderHeaders(input: {
extraConfig?: string | null;
extraConfig?: OauthExtraConfigInput;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = buildCodexOauthInfo(input.extraConfig);
+11 -1
View File
@@ -52,7 +52,7 @@ vi.mock('../db/index.js', () => ({
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
}));
import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
import { insertProxyLog, parseProxyLogBillingDetails, withProxyLogSelectFields } from './proxyLogStore.js';
describe('proxyLogStore', () => {
beforeEach(() => {
@@ -89,6 +89,16 @@ describe('proxyLogStore', () => {
expect(runner.mock.calls[1][0].fields.billingDetails).toBeUndefined();
});
it('accepts parsed billing details objects for helper-level callers', () => {
expect(parseProxyLogBillingDetails({
source: 'pricing',
usd: 1.25,
})).toEqual({
source: 'pricing',
usd: 1.25,
});
});
it('retries proxy log inserts without billing details when the column is missing', async () => {
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true);
dbInsertRunMock
+6 -1
View File
@@ -143,10 +143,15 @@ export async function withProxyLogSelectFields<T>(
}
export function parseProxyLogBillingDetails(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { __proxyVideoTaskStoreTestUtils } from './proxyVideoTaskStore.js';
describe('proxyVideoTaskStore', () => {
it('accepts parsed object input for JSON column helpers', () => {
expect(__proxyVideoTaskStoreTestUtils.parseJsonColumn({
status: 'done',
id: 'video-1',
})).toEqual({
status: 'done',
id: 'video-1',
});
});
});
+6 -1
View File
@@ -117,7 +117,8 @@ export async function refreshProxyVideoTaskSnapshot(
.run();
}
function parseJsonColumn(value: string | null | undefined): unknown | null {
function parseJsonColumn(value: unknown): unknown | null {
if (value && typeof value === 'object') return value;
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
@@ -125,3 +126,7 @@ function parseJsonColumn(value: string | null | undefined): unknown | null {
return value;
}
}
export const __proxyVideoTaskStoreTestUtils = {
parseJsonColumn,
};
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { parseRouteDecisionSnapshot } from './routeDecisionSnapshotStore.js';
describe('routeDecisionSnapshotStore', () => {
it('accepts parsed decision snapshot objects', () => {
expect(parseRouteDecisionSnapshot({
matched: true,
candidates: [{ routeId: 1 }],
})).toEqual({
matched: true,
candidates: [{ routeId: 1 }],
});
});
});
@@ -7,7 +7,8 @@ function serializeSnapshot(snapshot: unknown): string | null {
return JSON.stringify(snapshot);
}
export function parseRouteDecisionSnapshot(value: string | null | undefined): unknown | null {
export function parseRouteDecisionSnapshot(value: unknown): unknown | null {
if (value && typeof value === 'object') return value;
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
+20
View File
@@ -218,6 +218,26 @@ describe('siteProxy', () => {
expect('dispatcher' in requestInit).toBe(true);
});
it('merges parsed-object site custom headers from site records', async () => {
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
const requestInit = withSiteRecordProxyRequestInit({
proxyUrl: 'http://127.0.0.1:7890',
useSystemProxy: false,
customHeaders: {
'x-site-scope': 'site-level',
},
}, {
method: 'POST',
headers: {
'X-Request-Id': 'req-1',
},
});
const headers = new Headers(requestInit.headers);
expect(headers.get('x-site-scope')).toBe('site-level');
expect(headers.get('x-request-id')).toBe('req-1');
});
it('resolveChannelProxyUrl prefers account proxy over site proxy', async () => {
const { resolveChannelProxyUrl } = await import('./siteProxy.js');
+4 -4
View File
@@ -35,7 +35,7 @@ type SiteProxyRow = {
siteUrl: string;
proxyUrl: string | null;
useSystemProxy: boolean;
customHeaders: string | null;
customHeaders: unknown;
};
type ParsedSiteProxyInput = {
@@ -47,7 +47,7 @@ type ParsedSiteProxyInput = {
export type SiteProxyConfigLike = {
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
customHeaders?: unknown;
};
let siteProxyCache: {
@@ -147,7 +147,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
siteUrl: normalizeSiteUrl(row.siteUrl),
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
useSystemProxy: !!row.useSystemProxy,
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
customHeaders: row.customHeaders ?? null,
})),
systemProxyUrl: parsedSystemProxyUrl,
};
@@ -380,7 +380,7 @@ function findBestMatchingSiteRow(rows: SiteProxyRow[], normalizedRequestUrl: str
async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise<{
proxyUrl: string | null;
customHeaders: string | null;
customHeaders: unknown;
}> {
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
if (!normalizedRequestUrl) {
@@ -62,4 +62,27 @@ describe('today income reward service', () => {
});
expect(preferParsed).toBe(1.5);
});
it('reads today income snapshots from parsed extraConfig objects', () => {
const day = formatLocalDate(new Date('2026-02-25T08:00:00.000Z'));
const extraConfig = {
todayIncomeSnapshot: {
day,
baseline: 8,
latest: 10.2,
updatedAt: '2026-02-25T12:00:00.000Z',
},
};
expect(getTodayIncomeValue(extraConfig, day)).toBeCloseTo(10.2, 6);
expect(getTodayIncomeDelta(extraConfig, day)).toBeCloseTo(2.2, 6);
});
it('preserves missing extraConfig when income is invalid', () => {
expect(updateTodayIncomeSnapshot(null, Number.NaN)).toBeNull();
expect(updateTodayIncomeSnapshot(undefined, -1)).toBeNull();
expect(updateTodayIncomeSnapshot('', Number.NaN)).toBeNull();
expect(updateTodayIncomeSnapshot('{"demo":true}', Number.NaN)).toBe('{"demo":true}');
expect(updateTodayIncomeSnapshot({ demo: true }, Number.NaN)).toBe('{"demo":true}');
});
});
+25 -10
View File
@@ -13,15 +13,20 @@ type EstimateRewardInput = {
successCount: number;
parsedRewardCount: number;
rewardSum: number;
extraConfig?: string | null;
extraConfig?: string | Record<string, unknown> | null;
};
function parseObject(value: string | null | undefined): Record<string, unknown> {
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseObject(value: string | Record<string, unknown> | null | undefined): Record<string, unknown> {
if (!value) return {};
if (isRecord(value)) return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
@@ -52,12 +57,15 @@ function normalizeSnapshot(raw: unknown): TodayIncomeSnapshot | null {
};
}
function extractTodayIncomeSnapshot(extraConfig?: string | null): TodayIncomeSnapshot | null {
function extractTodayIncomeSnapshot(extraConfig?: string | Record<string, unknown> | null): TodayIncomeSnapshot | null {
const parsed = parseObject(extraConfig);
return normalizeSnapshot(parsed.todayIncomeSnapshot);
}
export function getTodayIncomeDelta(extraConfig: string | null | undefined, day: string): number {
export function getTodayIncomeDelta(
extraConfig: string | Record<string, unknown> | null | undefined,
day: string,
): number {
if (!day) return 0;
const snapshot = extractTodayIncomeSnapshot(extraConfig);
if (!snapshot || snapshot.day !== day) return 0;
@@ -68,7 +76,10 @@ export function getTodayIncomeDelta(extraConfig: string | null | undefined, day:
return delta;
}
export function getTodayIncomeValue(extraConfig: string | null | undefined, day: string): number {
export function getTodayIncomeValue(
extraConfig: string | Record<string, unknown> | null | undefined,
day: string,
): number {
if (!day) return 0;
const snapshot = extractTodayIncomeSnapshot(extraConfig);
if (!snapshot || snapshot.day !== day) return 0;
@@ -76,12 +87,16 @@ export function getTodayIncomeValue(extraConfig: string | null | undefined, day:
}
export function updateTodayIncomeSnapshot(
extraConfig: string | null | undefined,
extraConfig: string | Record<string, unknown> | null | undefined,
todayIncome: number,
now = new Date(),
): string {
): string | null {
const income = toNonNegativeNumber(todayIncome);
if (income == null) return extraConfig || '{}';
if (income == null) {
if (typeof extraConfig === 'string') return extraConfig || null;
if (extraConfig == null) return null;
return JSON.stringify(parseObject(extraConfig));
}
const day = formatLocalDate(now);
const existing = extractTodayIncomeSnapshot(extraConfig);
@@ -11,6 +11,7 @@ describe('TokenRouter patterns and model mapping', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let tokenRouterTestUtils: TokenRouterModule['__tokenRouterTestUtils'];
let dataDir = '';
let idSeed = 0;
@@ -30,6 +31,7 @@ describe('TokenRouter patterns and model mapping', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
tokenRouterTestUtils = tokenRouterModule.__tokenRouterTestUtils;
});
beforeEach(async () => {
@@ -155,6 +157,17 @@ describe('TokenRouter patterns and model mapping', () => {
expect(regex?.actualModel).toBe('target-regex');
});
it('resolves mapped models from parsed object input for helper-level callers', () => {
expect(tokenRouterTestUtils.resolveMappedModel('claude-sonnet-4-6', {
'claude-sonnet-4-6': 'target-exact',
'claude-sonnet-*': 'target-glob',
})).toBe('target-exact');
expect(tokenRouterTestUtils.resolveMappedModel('claude-sonnet-4-7', {
'claude-sonnet-4-6': 'target-exact',
'claude-sonnet-*': 'target-glob',
})).toBe('target-glob');
});
it('matches a route by display name alias as an exposed model', async () => {
await createRouteWithSingleChannel(
're:^claude-(opus|sonnet)-4-5$',
+26 -17
View File
@@ -1022,22 +1022,27 @@ function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: Downst
return false;
}
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
if (!modelMapping) return requestedModel;
let parsed: unknown;
try {
parsed = JSON.parse(modelMapping);
} catch {
return requestedModel;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return requestedModel;
}
const entries = Object.entries(parsed as Record<string, unknown>)
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>;
function parseModelMappingRecord(modelMapping?: string | Record<string, unknown> | null): Record<string, unknown> | null {
if (!modelMapping) return null;
if (typeof modelMapping === 'object' && !Array.isArray(modelMapping)) {
return modelMapping as Record<string, unknown>;
}
if (typeof modelMapping !== 'string') return null;
try {
const parsed = JSON.parse(modelMapping);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function resolveMappedModel(requestedModel: string, modelMapping?: string | Record<string, unknown> | null): string {
const parsed = parseModelMappingRecord(modelMapping);
if (!parsed) return requestedModel;
const entries = Object.entries(parsed)
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>;
const exact = entries.find(([pattern]) => pattern === requestedModel);
if (exact) return exact[1].trim();
@@ -2253,5 +2258,9 @@ export class TokenRouter {
}
}
export const tokenRouter = new TokenRouter();
export const tokenRouter = new TokenRouter();
export const __tokenRouterTestUtils = {
resolveMappedModel,
};