forked from boos/BoosAPI
[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:
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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$',
|
||||
|
||||
@@ -1022,21 +1022,26 @@ function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: Downst
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
||||
if (!modelMapping) return requestedModel;
|
||||
|
||||
let parsed: unknown;
|
||||
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 {
|
||||
parsed = JSON.parse(modelMapping);
|
||||
const parsed = JSON.parse(modelMapping);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return requestedModel;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return requestedModel;
|
||||
}
|
||||
function resolveMappedModel(requestedModel: string, modelMapping?: string | Record<string, unknown> | null): string {
|
||||
const parsed = parseModelMappingRecord(modelMapping);
|
||||
if (!parsed) return requestedModel;
|
||||
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
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);
|
||||
@@ -2255,3 +2260,7 @@ export class TokenRouter {
|
||||
|
||||
export const tokenRouter = new TokenRouter();
|
||||
|
||||
export const __tokenRouterTestUtils = {
|
||||
resolveMappedModel,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user