diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 4d0f01bf..887c2a28 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -26,6 +26,8 @@ import { type AccountStatus, } from "./auth.js"; import { + EXTERNAL_CODEX_PROVIDERS, + isExternalModelProvider, mergedCodexConfig, scanModelConfiguration, type CodexSecurityConfig, @@ -171,7 +173,7 @@ export type ScanAuthMode = "auto" | "chatgpt" | "api-key"; export type ScanAuthentication = | { method: "api_key"; - source: "OPENAI_API_KEY" | "CODEX_API_KEY"; + source: "OPENAI_API_KEY" | "CODEX_API_KEY" | "MINIMAX_API_KEY"; verified: false; } | { @@ -203,6 +205,7 @@ export interface ScanPreflight extends DeepScanOptions { archiveDir?: string; authentication: ScanAuthentication; model: string; + modelProvider?: string; reasoningEffort: string; maxCostUsd?: number; } @@ -317,8 +320,12 @@ export class CodexSecurity { authentication: scanAuthentication( this.#dependencies.environment, options.auth, + configuration["model_provider"], ), ...model, + ...(typeof configuration["model_provider"] === "string" + ? { modelProvider: configuration["model_provider"] } + : {}), ...(options.maxCostUsd === undefined ? {} : { maxCostUsd: options.maxCostUsd }), @@ -389,13 +396,29 @@ export class CodexSecurity { } checkOpen(); + const requestedConfig = await mergedCodexConfig(this.config); + const modelProvider = requestedConfig["model_provider"]; + const externalProvider = isExternalModelProvider(modelProvider) + ? EXTERNAL_CODEX_PROVIDERS[modelProvider] + : null; const authentication = scanAuthentication( this.#dependencies.environment, options.auth, + modelProvider, ); + const apiKey = + authentication.method === "api_key" + ? environmentApiKey(this.#dependencies.environment, modelProvider) + : null; + if (externalProvider !== null && apiKey === null) { + throw new AuthenticationRequiredError( + `Set ${externalProvider.env_key} to run a scan through ${externalProvider.name}.`, + ); + } const scanEnvironment = selectedScanEnvironment( this.#dependencies.environment, options.auth, + modelProvider, ); if ( authentication.method === "stored_credentials" && @@ -418,6 +441,7 @@ export class CodexSecurity { (path) => requireOutputOutsideRepository(protectedRoot, path, "runtime"), options.auth, + modelProvider, ); if ( runtime === previousRuntime && @@ -426,8 +450,7 @@ export class CodexSecurity { ) { await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal); } - const effectiveConfig = - runtime.effectiveConfig ?? (await mergedCodexConfig(this.config)); + const effectiveConfig = runtime.effectiveConfig ?? requestedConfig; if (runtime.configPath !== undefined) { await writeCodexConfig( runtime.configPath, @@ -468,11 +491,7 @@ export class CodexSecurity { ? "stored_credentials" : null; } - const apiKey = - authentication.method === "api_key" - ? environmentApiKey(this.#dependencies.environment) - : null; - if (apiKey !== null) { + if (externalProvider === null && apiKey !== null) { this.#runtimeCredentialSource = "api_key"; } if ( @@ -620,7 +639,11 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment(runtime.environment, options.auth), + ...selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ), CODEX_SECURITY_STATE_DIR: stateDirectory, }, signal, @@ -775,14 +798,21 @@ export class CodexSecurity { ...pluginExecutionEnvironment( python, withoutCodexHome( - selectedScanEnvironment(runtime.environment, options.auth), + selectedScanEnvironment( + runtime.environment, + options.auth, + modelProvider, + ), ), ), + ...(externalProvider === null + ? {} + : { [externalProvider.env_key]: apiKey! }), CODEX_HOME: runtime.codexHome, ...runtimePaths, }; const codex = this.#dependencies.createCodex({ - ...(apiKey === null ? {} : { apiKey }), + ...(externalProvider !== null || apiKey === null ? {} : { apiKey }), env: definedEnvironment( selectedScanEnvironment(environment, "chatgpt"), ), @@ -1143,12 +1173,13 @@ export class CodexSecurity { temporaryRoot?: string, validateLocation?: (path: string) => void, auth: ScanAuthMode = "auto", + modelProvider?: unknown, ): Promise { this.#requireOpen(); if (this.#runtime !== null) { const usePersistentCredentials = - scanAuthentication(this.#dependencies.environment, auth).method === - "stored_credentials"; + scanAuthentication(this.#dependencies.environment, auth, modelProvider) + .method === "stored_credentials"; if ( this.#dependencies.prepareRuntime !== undefined || this.#runtime.persistentCredentialHome === undefined || @@ -1168,6 +1199,7 @@ export class CodexSecurity { temporaryRoot, validateLocation, auth, + modelProvider, ); this.#runtimePromise = runtimePromise; void runtimePromise.catch(() => { @@ -1277,6 +1309,7 @@ export class CodexSecurity { temporaryRoot?: string, validateLocation?: (path: string) => void, auth: ScanAuthMode = "auto", + modelProvider?: unknown, ): Promise { if (this.#dependencies.prepareRuntime !== undefined) { return await this.#dependencies.prepareRuntime(this.config, signal); @@ -1284,10 +1317,11 @@ export class CodexSecurity { const processEnvironment = selectedScanEnvironment( this.#dependencies.environment, auth, + modelProvider, ); const persistentCredentialHome = - scanAuthentication(this.#dependencies.environment, auth).method === - "stored_credentials"; + scanAuthentication(this.#dependencies.environment, auth, modelProvider) + .method === "stored_credentials"; const codexHome = persistentCredentialHome ? await prepareCodexSecurityCredentialHome( processEnvironment, @@ -1332,11 +1366,13 @@ export class CodexSecurity { environment: withoutCodexHome(processEnvironment), signal, }); - const credentialsAvailable = await initialCredentialsAvailable( - processEnvironment, - ambientHome, - codexHome, - ); + const credentialsAvailable = isExternalModelProvider(modelProvider) + ? false + : await initialCredentialsAvailable( + processEnvironment, + ambientHome, + codexHome, + ); return { codexHome, persistentCredentialHome, @@ -1828,12 +1864,17 @@ async function collectResult( export function scanAuthentication( environment: ProcessEnvironment, auth: ScanAuthMode = "auto", + modelProvider?: unknown, ): ScanAuthentication { - if (auth === "chatgpt") { + if (auth === "chatgpt" && !isExternalModelProvider(modelProvider)) { return { method: "stored_credentials", verified: false }; } - const key = environmentApiKeyEntry(environment); - if (auth === "api-key" && key === null) { + const key = environmentApiKeyEntry(environment, modelProvider); + if ( + auth === "api-key" && + key === null && + !isExternalModelProvider(modelProvider) + ) { throw new AuthenticationRequiredError( "API-key authentication requires OPENAI_API_KEY or CODEX_API_KEY. " + "Set a valid API key or use '--auth chatgpt'.", @@ -1847,14 +1888,23 @@ export function scanAuthentication( function selectedScanEnvironment( environment: ProcessEnvironment, auth: ScanAuthMode = "auto", + modelProvider?: unknown, ): ProcessEnvironment { - if (auth !== "chatgpt") return environment; + const selectedProviderKey = isExternalModelProvider(modelProvider) + ? EXTERNAL_CODEX_PROVIDERS[modelProvider].env_key + : null; + if (auth !== "chatgpt" && selectedProviderKey === null) return environment; + const externalProviderKeys = new Set( + Object.values(EXTERNAL_CODEX_PROVIDERS).map((provider) => provider.env_key), + ); return Object.fromEntries( - Object.entries(environment).filter( - ([name]) => - name.toUpperCase() !== "OPENAI_API_KEY" && - name.toUpperCase() !== "CODEX_API_KEY", - ), + Object.entries(environment).filter(([name]) => { + const key = name.toUpperCase(); + if (key === "OPENAI_API_KEY" || key === "CODEX_API_KEY") return false; + if (selectedProviderKey === null) return true; + if (externalProviderKeys.has(key)) return key === selectedProviderKey; + return true; + }), ); } @@ -1872,15 +1922,24 @@ function notifyObserver( .catch(() => {}); } -function environmentApiKey(environment: ProcessEnvironment): string | null { - return environmentApiKeyEntry(environment)?.value ?? null; +function environmentApiKey( + environment: ProcessEnvironment, + modelProvider?: unknown, +): string | null { + return environmentApiKeyEntry(environment, modelProvider)?.value ?? null; } -function environmentApiKeyEntry(environment: ProcessEnvironment): { - source: "OPENAI_API_KEY" | "CODEX_API_KEY"; +function environmentApiKeyEntry( + environment: ProcessEnvironment, + modelProvider?: unknown, +): { + source: "OPENAI_API_KEY" | "CODEX_API_KEY" | "MINIMAX_API_KEY"; value: string; } | null { - for (const requested of ["OPENAI_API_KEY", "CODEX_API_KEY"] as const) { + const keys = isExternalModelProvider(modelProvider) + ? [EXTERNAL_CODEX_PROVIDERS[modelProvider].env_key] + : (["OPENAI_API_KEY", "CODEX_API_KEY"] as const); + for (const requested of keys) { const canonical = environment[requested]?.trim(); if (canonical) return { source: requested, value: canonical }; for (const [name, value] of Object.entries(environment)) { @@ -2107,6 +2166,12 @@ export function scanPreflightCodexConfig( }; const result = executionConfig(config); + const modelProvider = result["model_provider"]; + if (isExternalModelProvider(modelProvider)) { + result["model_providers"] = { + [modelProvider]: { ...EXTERNAL_CODEX_PROVIDERS[modelProvider] }, + }; + } const selectedProfile = safeProfileName(config["profile"]) ? config["profile"] : undefined; diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 2747672f..8d37970b 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -47,9 +47,12 @@ import { } from "./bulk-scan-discovery.js"; import { DEFAULT_CODEX_CONFIG, + EXTERNAL_CODEX_PROVIDERS, + isExternalModelProvider, mergedCodexConfig, scanModelConfiguration, type CodexSecurityConfig, + type ExternalModelProvider, type JsonObject, type JsonValue, } from "./config.js"; @@ -168,6 +171,7 @@ const VALUE_OPTIONS = new Set([ "--mode", "--model", "--effort", + "--provider", "--output-dir", "--plugin-path", "--python", @@ -189,6 +193,10 @@ const VALUE_OPTIONS = new Set([ "--scan-root", "--reason", ]); +const PROVIDER_OPTION = z + .enum(["openai", "minimax", "minimax-cn"]) + .default("openai") + .describe("Inference provider for scans."); function optionValue(flag: string) { return z.string().min(1, `${flag} must not be empty.`); @@ -245,6 +253,7 @@ interface ScanArguments extends DeepScanOptions { mode: ScanMode; model?: string; effort?: ModelReasoningEffort; + provider?: "openai" | ExternalModelProvider; outputDir?: string; archiveExisting: boolean; pluginPath?: string; @@ -1049,6 +1058,7 @@ export async function main( `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ), effort: effortOption(), + provider: PROVIDER_OPTION, outputDir: optionValue("--output-dir") .optional() .describe( @@ -1162,6 +1172,7 @@ export async function main( maxDiscoveryRuns: options.maxDiscoveryRuns, model: options.model, effort: options.effort, + provider: options.provider, outputDir: options.outputDir, archiveExisting: options.archiveExisting, pluginPath: options.pluginPath, @@ -1308,6 +1319,7 @@ export async function main( `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ), effort: effortOption(), + provider: PROVIDER_OPTION, maxAttempts: z .number() .int() @@ -1361,6 +1373,7 @@ export async function main( if ( argument === "--model" || argument === "--effort" || + argument === "--provider" || argument === "--codex" || argument === "--knowledge-base" ) { @@ -1368,6 +1381,7 @@ export async function main( } else if ( argument.startsWith("--model=") || argument.startsWith("--effort=") || + argument.startsWith("--provider=") || argument.startsWith("--codex=") || argument.startsWith("--knowledge-base=") ) { @@ -1378,7 +1392,7 @@ export async function main( } if (argv[0] !== "bulk-scan" || optionIndex !== argv.length) { throw new Error( - "Run 'codex-security bulk-scan [--model MODEL] [--effort EFFORT] [--codex KEY=VALUE] [--knowledge-base PATH]' to discover repositories, or provide a CSV and --output-dir.", + "Run 'codex-security bulk-scan [--provider PROVIDER] [--model MODEL] [--effort EFFORT] [--codex KEY=VALUE] [--knowledge-base PATH]' to discover repositories, or provide a CSV and --output-dir.", ); } const wizard = await runBulkScanWizard( @@ -1418,6 +1432,7 @@ export async function main( options.codex, options.model, options.effort, + options.provider, ), }, createSecurity: dependencies.createSecurity, @@ -2689,6 +2704,7 @@ async function runScan( arguments_.codex, arguments_.model, arguments_.effort, + arguments_.provider, ), }; const selectedProfileName = config.codexOverrides?.["profile"]; @@ -2698,8 +2714,14 @@ async function runScan( ...config.codexOverrides, })); let auth = arguments_.auth; - selectedAuthentication = scanAuthentication(dependencies.environment, auth); + const provider = config.codexOverrides?.["model_provider"]; + selectedAuthentication = scanAuthentication( + dependencies.environment, + auth, + provider, + ); if ( + !isExternalModelProvider(provider) && (auth === undefined || auth === "auto") && !arguments_.dryRun && interactive && @@ -2731,6 +2753,7 @@ async function runScan( selectedAuthentication = scanAuthentication( dependencies.environment, auth, + provider, ); } } @@ -3337,10 +3360,17 @@ export function parseCodexOverrides( values: readonly string[], model?: string, effort?: ModelReasoningEffort, + provider?: "openai" | ExternalModelProvider, ): JsonObject { const result = Object.create(null) as JsonObject; if (model !== undefined) result["model"] = model; if (effort !== undefined) result["model_reasoning_effort"] = effort; + if (isExternalModelProvider(provider)) { + result["model_provider"] = provider; + result["model_providers"] = { + [provider]: { ...EXTERNAL_CODEX_PROVIDERS[provider] }, + }; + } for (const value of values) { const separator = value.indexOf("="); const key = separator < 0 ? "" : value.slice(0, separator); @@ -3396,10 +3426,20 @@ export function parseCodexOverrides( "--effort conflicts with --codex model_reasoning_effort", ); } + if (isExternalModelProvider(provider) && key === "model_provider") { + throw new CodexSecurityError( + "--provider conflicts with --codex model_provider", + ); + } throw new CodexSecurityError("Duplicate --codex key"); } cursor[final] = parsed; } + if (isExternalModelProvider(provider) && !("model" in result)) { + throw new CodexSecurityError( + `--model is required when using --provider ${provider}`, + ); + } return result; } diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 2527ce20..ebbfeee0 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -21,6 +21,36 @@ export interface ScanModelConfiguration { reasoningEffort: string; } +export const MINIMAX_CODEX_PROVIDER = { + name: "MiniMax", + base_url: "https://api.minimax.io/v1", + env_key: "MINIMAX_API_KEY", + wire_api: "chat", +} as const satisfies JsonObject; + +export const MINIMAX_CN_CODEX_PROVIDER = { + name: "MiniMax", + base_url: "https://api.minimaxi.com/v1", + env_key: "MINIMAX_API_KEY", + wire_api: "chat", +} as const satisfies JsonObject; + +export const EXTERNAL_CODEX_PROVIDERS = { + minimax: MINIMAX_CODEX_PROVIDER, + "minimax-cn": MINIMAX_CN_CODEX_PROVIDER, +} as const; + +export type ExternalModelProvider = keyof typeof EXTERNAL_CODEX_PROVIDERS; + +export function isExternalModelProvider( + provider: unknown, +): provider is ExternalModelProvider { + return ( + typeof provider === "string" && + Object.hasOwn(EXTERNAL_CODEX_PROVIDERS, provider) + ); +} + export const DEFAULT_CODEX_CONFIG: Readonly = { cli_auth_credentials_store: "auto", model: "gpt-5.6-sol", diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index fdb66e07..64cec33e 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -54,6 +54,8 @@ const MODEL_PRICING_NANODOLLARS: Readonly> = { "gpt-5.6-sol": [5_000, 500, 6_250, 30_000], "gpt-5.6-terra": [2_500, 250, 3_125, 15_000], "gpt-5.6-luna": [1_000, 100, 1_250, 6_000], + "MiniMax-M3": [600, 120, 0, 2_400], + "MiniMax-M2.7": [300, 60, 375, 1_200], }; const COST_POLL_INTERVAL_MS = 100; diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 4a09a894..5fe3d9df 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -609,7 +609,8 @@ export async function runWorkbench( Object.entries(options.environment).filter( ([name]) => name.toUpperCase() !== "OPENAI_API_KEY" && - name.toUpperCase() !== "CODEX_API_KEY", + name.toUpperCase() !== "CODEX_API_KEY" && + name.toUpperCase() !== "MINIMAX_API_KEY", ), ), encoding: "utf8", diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 1e0c947c..68d97532 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -39,7 +39,12 @@ import { scanPreflightCodexConfig, scanRuntimeCodexConfig, } from "../src/api.js"; -import { writeCodexConfig, type JsonObject } from "../src/config.js"; +import { + MINIMAX_CN_CODEX_PROVIDER, + MINIMAX_CODEX_PROVIDER, + writeCodexConfig, + type JsonObject, +} from "../src/config.js"; import { estimateScanCost, type ScanCost } from "../src/cost.js"; import { runWorkbench, @@ -57,6 +62,22 @@ const REPOSITORY_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const EXAMPLE = join(PLUGIN_ROOT, "examples", "completed-scan"); const temporaryDirectories: string[] = []; const TEST_SNAPSHOT_DIGEST = `codex-security-snapshot/v1:sha256:${"a".repeat(64)}`; +const EXTERNAL_PROVIDER_CASES = [ + [ + "MiniMax", + "minimax", + "MINIMAX_API_KEY", + "MiniMax-M3", + MINIMAX_CODEX_PROVIDER, + ], + [ + "MiniMax China", + "minimax-cn", + "MINIMAX_API_KEY", + "MiniMax-M2.7", + MINIMAX_CN_CODEX_PROVIDER, + ], +] as const; const TestClientBase = CodexSecurity as unknown as new ( config: Record, dependencies: Record, @@ -1049,6 +1070,146 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test.each(EXTERNAL_PROVIDER_CASES)( + "keeps the %s provider in sanitized scan configuration", + (_name, provider, _apiKey, model, providerConfig) => { + expect( + scanPreflightCodexConfig({ + model, + model_provider: provider, + model_providers: { + [provider]: providerConfig, + private: { api_key: "redacted" }, + }, + }), + ).toEqual({ + model, + model_provider: provider, + model_providers: { [provider]: providerConfig }, + }); + }, + ); + + test.each(EXTERNAL_PROVIDER_CASES)( + "requires the %s API key instead of accepting OpenAI credentials", + async (_name, provider, apiKey, model, providerConfig) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + let runtimeStarted = false; + const client = new TestClient( + { + codexOverrides: { + model, + model_provider: provider, + model_providers: { [provider]: providerConfig }, + }, + }, + { + environment: { + OPENAI_API_KEY: "synthetic-openai-key", + }, + prepareRuntime: async () => { + runtimeStarted = true; + throw new Error("runtime must not start"); + }, + }, + ); + + await expect(client.run(repository)).rejects.toThrow( + `Set ${apiKey} to run a scan through ${providerConfig.name}.`, + ); + expect(runtimeStarted).toBe(false); + await client.close(); + }, + ); + + test.each(EXTERNAL_PROVIDER_CASES)( + "runs %s scans without signing in to OpenAI", + async (_name, provider, apiKey, model, providerConfig) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + let codexOptions: CodexOptions | null = null; + let authentication: ScanAuthentication | undefined; + const environment = { + OPENAI_API_KEY: "synthetic-openai-key", + [apiKey]: `synthetic-${provider}-key`, + }; + const client = new TestClient( + { + codexOverrides: { + model, + model_provider: provider, + model_providers: { [provider]: providerConfig }, + }, + }, + { + environment, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment, + credentialsAvailable: false, + }), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + resolveCodexCommand: () => { + throw new Error(`${provider} must not sign in to OpenAI`); + }, + createCodex: (options: CodexOptions) => { + codexOptions = options; + return { + startThread: () => ({ + id: null, + async runStreamed() { + await copyCompletedScan(root); + return { events: completedEvents() }; + }, + }), + }; + }, + }, + ); + + const preflight = await client.preflight(repository); + expect(preflight).toMatchObject({ + model, + modelProvider: provider, + authentication: { + method: "api_key", + source: apiKey, + verified: false, + }, + }); + expect(JSON.stringify(preflight)).not.toContain("synthetic-"); + await expect( + client.run(repository, { + onAuthentication: (selected) => { + authentication = selected; + }, + }), + ).resolves.toMatchObject({ threadId: "thread-1" }); + expect(authentication).toEqual({ + method: "api_key", + source: apiKey, + verified: false, + }); + expect((codexOptions as CodexOptions | null)?.env).toMatchObject({ + [apiKey]: `synthetic-${provider}-key`, + }); + expect((codexOptions as CodexOptions | null)?.env).not.toHaveProperty( + "OPENAI_API_KEY", + ); + expect((codexOptions as CodexOptions | null)?.apiKey).toBeUndefined(); + await client.close(); + }, + ); + test("isolates authentication observer failures from scan startup", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index b1eb8841..aa907e03 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -36,7 +36,12 @@ import { VERSION, } from "../src/index.js"; import { main, parseCodexOverrides, Progress } from "../src/cli.js"; -import { DEFAULT_CODEX_CONFIG, scanModelConfiguration } from "../src/config.js"; +import { + DEFAULT_CODEX_CONFIG, + MINIMAX_CN_CODEX_PROVIDER, + MINIMAX_CODEX_PROVIDER, + scanModelConfiguration, +} from "../src/config.js"; import { FakeSignals, REDACTED_CREDENTIALS, @@ -116,6 +121,7 @@ describe("CLI", () => { model: { type: "string" }, verbose: { type: "boolean" }, effort: { enum: ["minimal", "low", "medium", "high", "xhigh"] }, + provider: { enum: ["openai", "minimax", "minimax-cn"] }, failOnSeverity: { enum: ["critical", "high", "medium", "low"] }, }, }, @@ -1611,7 +1617,7 @@ describe("CLI", () => { expect(help.text()).toContain( "codex-security scan . --model gpt-5.6-terra --effort high", ); - expect(help.text()).not.toContain("--provider"); + expect(help.text()).toContain("--provider "); expect(help.text()).not.toContain("openai:gpt"); expect(help.text()).not.toContain("codex-security scan . --path src,tests"); expect(help.text()).toContain("--format "); @@ -1659,7 +1665,7 @@ describe("CLI", () => { ); expect(help.text()).not.toContain("--outputDir"); expect(help.text()).not.toContain("--maxAttempts"); - expect(help.text()).not.toContain("--provider"); + expect(help.text()).toContain("--provider "); expect(stderr.text()).toBe(""); }); @@ -1705,6 +1711,79 @@ describe("CLI", () => { } }); + test.each([ + [ + "MiniMax", + "minimax", + "MiniMax-M3", + "MiniMax-M2.7", + MINIMAX_CODEX_PROVIDER, + ], + [ + "MiniMax China", + "minimax-cn", + "MiniMax-M2.7", + "MiniMax-M3", + MINIMAX_CN_CODEX_PROVIDER, + ], + ] as const)( + "routes scans through %s", + async (_name, provider, selectedModel, codexModel, providerConfig) => { + for (const [options, expectedModel] of [ + [[`--provider=${provider}`, "--model", selectedModel], selectedModel], + [ + ["--provider", provider, "--codex", `model="${codexModel}"`], + codexModel, + ], + ] as const) { + let config: CodexSecurityConfig | undefined; + expect( + await main( + ["scan", ".", ...options], + capture().stream, + capture().stream, + dependencies({ onConfig: (value) => (config = value) }), + ), + ).toBe(0); + expect(config?.codexOverrides).toEqual({ + model: expectedModel, + model_provider: provider, + model_providers: { [provider]: providerConfig }, + }); + } + }, + ); + + test("registers MiniMax providers and rejects conflicting overrides", () => { + expect(parseCodexOverrides([], "MiniMax-M3", undefined, "minimax")).toEqual( + { + model: "MiniMax-M3", + model_provider: "minimax", + model_providers: { minimax: MINIMAX_CODEX_PROVIDER }, + }, + ); + expect( + parseCodexOverrides([], "MiniMax-M2.7", undefined, "minimax-cn"), + ).toEqual({ + model: "MiniMax-M2.7", + model_provider: "minimax-cn", + model_providers: { "minimax-cn": MINIMAX_CN_CODEX_PROVIDER }, + }); + for (const provider of ["minimax", "minimax-cn"] as const) { + expect(() => + parseCodexOverrides([], undefined, undefined, provider), + ).toThrow(`--model is required when using --provider ${provider}`); + expect(() => + parseCodexOverrides( + ['model_provider="other"'], + "MiniMax-M3", + undefined, + provider, + ), + ).toThrow("--provider conflicts with --codex model_provider"); + } + }); + test("parses repeatable options and every scan target through Incur", async () => { const pathOutput = capture(); let pathOptions: unknown; @@ -1893,6 +1972,14 @@ describe("CLI", () => { "--knowledge-base must not be empty", ], [["scan", ".", "--model="], "--model must not be empty"], + [ + ["scan", ".", "--provider", "minimax"], + "--model is required when using --provider minimax", + ], + [ + ["scan", ".", "--provider", "minimax-cn"], + "--model is required when using --provider minimax-cn", + ], [ ["scan", ".", "--effort", "ultra"], "--effort must be minimal, low, medium, high, or xhigh", diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 107adb7c..5df29b6e 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -87,6 +87,41 @@ describe("scan cost", () => { expect(estimateScanCost("gpt-5.6-luna", usage)?.estimatedUsd).toBe(7); }); + test("uses published MiniMax model rates", () => { + const usage = { input_tokens: 1_000_000, output_tokens: 1_000_000 }; + + expect(estimateScanCost("MiniMax-M3", usage)?.estimatedUsd).toBe(3); + expect(estimateScanCost("MiniMax-M2.7", usage)?.estimatedUsd).toBe(1.5); + }); + + test("charges MiniMax cached input at its discounted rate", () => { + expect( + estimateScanCost("MiniMax-M3", { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }), + ).toEqual({ + model: "MiniMax-M3", + inputTokens: 1_250, + cachedInputTokens: 200, + cacheWriteInputTokens: 0, + outputTokens: 30, + estimatedUsd: 0.000726, + }); + }); + + test("charges MiniMax M2.7 cache writes at their published rate", () => { + expect( + estimateScanCost("MiniMax-M2.7", { + input_tokens: 1_000, + cached_input_tokens: 100, + cache_write_input_tokens: 200, + output_tokens: 10, + })?.estimatedUsd, + ).toBe(0.000303); + }); + test("charges cached input at its discounted rate", () => { expect( estimateScanCost("gpt-5.6-sol", { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 7cb55da3..f9252a48 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -1839,6 +1839,7 @@ describe("runtime directories and plugin Python boundary", () => { "assert sys.argv[1] == 'test-command'", "assert os.environ.get('OPENAI_API_KEY') is None", "assert os.environ.get('CODEX_API_KEY') is None", + "assert os.environ.get('MINIMAX_API_KEY') is None", "print(json.dumps({'ok': True}))", ].join("\n"), ); @@ -1852,6 +1853,7 @@ describe("runtime directories and plugin Python boundary", () => { PATH: process.env["PATH"], OPENAI_API_KEY: "must-not-reach-python", CODEX_API_KEY: "also-must-not-reach-python", + MINIMAX_API_KEY: "minimax-must-not-reach-python", }, }, ["test-command"],