diff --git a/docs/configuration.md b/docs/configuration.md index a525fe4f..a4ad31d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,6 +175,9 @@ requires the complete access-token triple. |---|---|---| | `OPENAI_API_KEY` | `--agent codex`, `--agent pi --model openai/...` | Codex SDK token or Pi OpenAI-provider token. Unset is fine if `AI_GATEWAY_API_KEY` is set. | | `OPENAI_BASE_URL` | `--agent codex` | Default (when `AI_GATEWAY_API_KEY` is set): `https://ai-gateway.vercel.sh/v1`. | +| `MINIMAX_API_KEY` | `--agent pi --model minimax/...` | Auth for the built-in MiniMax provider preset. | +| `MINIMAX_REGION` | `--agent pi --model minimax/...` | Endpoint preset: `global` (default) or `cn`. See [models.md](models.md). | +| `MINIMAX_BASE_URL` | `--agent pi --model minimax/...` | Override the region preset with a custom base URL. | | `PI_CODING_AGENT_DIR` | `--agent pi` | Optional Pi config/auth directory. Defaults to `~/.pi/agent`; local non-sandbox runs can reuse `auth.json` there. | | `DEEPSEC_AGENT_DEBUG` | both backends | Set to `1` to enable verbose agent logging. | | `DEEPSEC_DATA_ROOT` | core | Override the data directory location. Equivalent to `dataDir` in config. | diff --git a/docs/models.md b/docs/models.md index 791c33ae..5d673e56 100644 --- a/docs/models.md +++ b/docs/models.md @@ -171,6 +171,36 @@ Later `process`, `revalidate`, and Sandbox commands resolve the persisted route. Per-command `--ai-provider`, `--ai-base-url`, `--ai-api-key-env`, and repeatable `--ai-header name=value` remain available as Pi runtime overrides. +### MiniMax (`minimax/…`) presets + +MiniMax ships as a built-in Pi provider preset, so you can select its +models by id without spelling out the base URL. The provider registers +`minimax/MiniMax-M3.1-Flash` first and `minimax/MiniMax-M3` second; both +have a 1M-token context window: + +```bash +MINIMAX_API_KEY=... +pnpm deepsec process --project-id my-app --agent pi --model minimax/MiniMax-M3.1-Flash +``` + +Two regional endpoint presets are built in. The default is the global +endpoint (`https://api.minimax.io/v1`); set `MINIMAX_REGION=cn` to use +the CN endpoint (`https://api.minimaxi.com/v1`) instead: + +```bash +MINIMAX_API_KEY=... +MINIMAX_REGION=cn +pnpm deepsec process --project-id my-app --agent pi --model minimax/MiniMax-M3 +``` + +`MINIMAX_BASE_URL` (or `--ai-base-url`) overrides the region preset for a +custom endpoint. Auth comes from `MINIMAX_API_KEY`, or point +`--ai-api-key-env` at a different variable. + +Flash uses adaptive reasoning with Pi's `xhigh` level mapped to `max`; +M3 uses its adaptive default and ignores reasoning-effort fields. In sandbox +mode, add the endpoint host to `allowedHosts` so worker egress can reach it. + ### `claude-sonnet-4-6` for `triage` Triage buckets findings into P0/P1/P2/skip without re-reading the code. diff --git a/packages/processor/src/__tests__/pi-sdk-tools.test.ts b/packages/processor/src/__tests__/pi-sdk-tools.test.ts index e294060a..51175624 100644 --- a/packages/processor/src/__tests__/pi-sdk-tools.test.ts +++ b/packages/processor/src/__tests__/pi-sdk-tools.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { configureProviderOverrides, createPiReadOnlyToolDefinitions, + registerMiniMaxProvider, resolvePiModelWithDynamicGateway, } from "../agents/pi-sdk.js"; @@ -71,6 +72,10 @@ describe("Pi model resolution", () => { "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + "MINIMAX_REGION", + "SAVED_MINIMAX_KEY", "ACME_MODEL_KEY", ] as const; const originalEnv = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); @@ -212,4 +217,139 @@ describe("Pi model resolution", () => { headers: { "x-api-key": "secret" }, }); }); + + it("registers the Flash-first MiniMax presets and resolves them offline", async () => { + for (const key of KEYS) delete process.env[key]; + let called = false; + globalThis.fetch = async () => { + called = true; + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, {}); + + const flash = await resolvePiModelWithDynamicGateway( + registry, + "minimax/MiniMax-M3.1-Flash", + {}, + ); + expect(flash.provider).toBe("minimax"); + expect(flash.id).toBe("MiniMax-M3.1-Flash"); + expect(flash.api).toBe("openai-completions"); + expect(flash.reasoning).toBe(true); + expect(flash.thinkingLevelMap).toEqual({ xhigh: "max" }); + expect(flash.input).toEqual(["text", "image"]); + expect(flash.contextWindow).toBe(1_000_000); + expect(flash.maxTokens).toBe(524_288); + expect(flash.cost).toMatchObject({ input: 0.15, output: 0.6, cacheRead: 0.015 }); + + const m3 = await resolvePiModelWithDynamicGateway(registry, "minimax/MiniMax-M3", {}); + expect(m3.provider).toBe("minimax"); + expect(m3.id).toBe("MiniMax-M3"); + expect(m3.contextWindow).toBe(1_000_000); + expect(m3.thinkingLevelMap).toBeUndefined(); + expect(m3.cost).toMatchObject({ input: 0.3, output: 1.2, cacheRead: 0.06 }); + + expect(called).toBe(false); + }); + + it("selects the CN MiniMax endpoint preset via MINIMAX_REGION", async () => { + for (const key of KEYS) delete process.env[key]; + process.env.MINIMAX_REGION = "cn"; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, {}); + + expect(registry.getRegisteredProviderConfig("minimax")?.baseUrl).toBe( + "https://api.minimaxi.com/v1", + ); + }); + + it("defaults MiniMax to the global endpoint preset", async () => { + for (const key of KEYS) delete process.env[key]; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, {}); + + expect(registry.getRegisteredProviderConfig("minimax")?.baseUrl).toBe( + "https://api.minimax.io/v1", + ); + }); + + it("lets --ai-base-url override the MiniMax region preset", async () => { + for (const key of KEYS) delete process.env[key]; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, { aiBaseUrl: "https://example.test/v1" }); + + expect(registry.getRegisteredProviderConfig("minimax")?.baseUrl).toBe( + "https://example.test/v1", + ); + }); + + it("keeps a saved custom MiniMax model route and key override intact", async () => { + for (const key of KEYS) delete process.env[key]; + process.env.SAVED_MINIMAX_KEY = "saved-key"; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, { + model: "minimax/custom-saved", + aiProvider: "minimax", + aiBaseUrl: "https://saved.example/v1", + aiApiKeyEnv: "SAVED_MINIMAX_KEY", + aiHeaders: { "x-saved": "yes" }, + }); + + expect(registry.getRegisteredProviderConfig("minimax")).toMatchObject({ + baseUrl: "https://saved.example/v1", + apiKey: "saved-key", + headers: { "x-saved": "yes" }, + }); + }); + + it("does not leak another provider's custom route or credential into MiniMax", async () => { + for (const key of KEYS) delete process.env[key]; + process.env.ACME_MODEL_KEY = "acme-key"; + process.env.MINIMAX_API_KEY = "minimax-key"; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, { + aiProvider: "acme", + aiBaseUrl: "https://models.acme.test/v1", + aiApiKeyEnv: "ACME_MODEL_KEY", + aiHeaders: { "x-acme": "yes" }, + }); + + expect(registry.getRegisteredProviderConfig("minimax")).toMatchObject({ + baseUrl: "https://api.minimax.io/v1", + apiKey: "minimax-key", + }); + expect(registry.getRegisteredProviderConfig("minimax")?.headers).toBeUndefined(); + }); + + it("keeps request discovery offline for MiniMax presets", async () => { + for (const key of KEYS) delete process.env[key]; + process.env.MINIMAX_API_KEY = "fixture-key"; + let called = false; + globalThis.fetch = async () => { + called = true; + throw new Error("network boundary must not be reached"); + }; + + const registry = await freshRegistry(); + registerMiniMaxProvider(registry, {}); + const model = await resolvePiModelWithDynamicGateway( + registry, + "minimax/MiniMax-M3.1-Flash", + {}, + ); + + expect(model.provider).toBe("minimax"); + expect(registry.getRegisteredProviderConfig("minimax")).toMatchObject({ + baseUrl: "https://api.minimax.io/v1", + apiKey: "fixture-key", + }); + expect(called).toBe(false); + }); }); diff --git a/packages/processor/src/agents/pi-sdk.ts b/packages/processor/src/agents/pi-sdk.ts index fe9a0524..fe0d6fc9 100644 --- a/packages/processor/src/agents/pi-sdk.ts +++ b/packages/processor/src/agents/pi-sdk.ts @@ -88,6 +88,83 @@ type PiModel = ReturnType[number]; type PiProviderConfig = Parameters[1]; type PiProviderModelConfig = NonNullable[number]; +// MiniMax exposes an OpenAI-compatible endpoint on two regional hosts that +// share the same model catalog. Registering the provider locally keeps model +// resolution offline; the selected route is still used by Pi at request time. +const MINIMAX_PROVIDER = "minimax"; +const MINIMAX_API = "openai-completions" as const; +const MINIMAX_REGION_BASE_URLS = { + global: "https://api.minimax.io/v1", + cn: "https://api.minimaxi.com/v1", +} as const; +type MiniMaxRegion = keyof typeof MINIMAX_REGION_BASE_URLS; +const MINIMAX_DEFAULT_REGION: MiniMaxRegion = "global"; +// Output cap applied on the deepsec side; matches the generic pass-through +// default. deepsec's investigation JSON fits well under this. +const MINIMAX_FLASH_MAX_TOKENS = 524_288; +const MINIMAX_DEEPSEC_MAX_TOKENS = 32_000; + +// Costs are USD per million tokens, matching pi's provider model contract. +// Pi only exposes text/image input labels; the MiniMax API's video capability +// remains outside this text-agent integration. +const MINIMAX_MODELS: PiProviderModelConfig[] = [ + { + id: "MiniMax-M3.1-Flash", + name: "MiniMax-M3.1-Flash", + api: MINIMAX_API, + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 0.15, output: 0.6, cacheRead: 0.015, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: MINIMAX_FLASH_MAX_TOKENS, + }, + { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: MINIMAX_API, + reasoning: true, + input: ["text", "image"], + cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: MINIMAX_DEEPSEC_MAX_TOKENS, + }, +]; + +function isMiniMaxRoute(cfg: PiAgentConfig): boolean { + return (cfg.aiProvider ?? modelProviderFromName(cfg.model)) === MINIMAX_PROVIDER; +} + +function resolveMiniMaxBaseUrl(cfg: PiAgentConfig): string { + if (cfg.aiBaseUrl && isMiniMaxRoute(cfg)) return cfg.aiBaseUrl; + if (process.env.MINIMAX_BASE_URL) return process.env.MINIMAX_BASE_URL; + const region = process.env.MINIMAX_REGION as MiniMaxRegion | undefined; + if (region && region in MINIMAX_REGION_BASE_URLS) return MINIMAX_REGION_BASE_URLS[region]; + return MINIMAX_REGION_BASE_URLS[MINIMAX_DEFAULT_REGION]; +} + +/** + * Register the built-in MiniMax provider preset (regional base URL + model + * catalog). Auth is layered separately in configureRuntimeAuth, or via the + * generic --ai-api-key-env override; a missing key just leaves the provider + * unauthenticated. Registering an unused provider is cheap. + */ +export function registerMiniMaxProvider(registry: ModelRegistry, cfg: PiAgentConfig): void { + const override: PiProviderConfig = { + baseUrl: resolveMiniMaxBaseUrl(cfg), + api: MINIMAX_API, + models: MINIMAX_MODELS, + }; + const key = isMiniMaxRoute(cfg) && cfg.aiApiKeyEnv + ? process.env[cfg.aiApiKeyEnv] + : process.env.MINIMAX_API_KEY; + if (key) override.apiKey = key; + if (isMiniMaxRoute(cfg) && cfg.aiHeaders && Object.keys(cfg.aiHeaders).length > 0) { + override.headers = cfg.aiHeaders; + } + registry.registerProvider(MINIMAX_PROVIDER, override); +} + interface PiSessionSetup { session: AgentSession; modelLabel: string; @@ -342,6 +419,9 @@ async function configureRuntimeAuth(runtime: ModelRuntime, cfg: PiAgentConfig): const openaiKey = process.env.OPENAI_API_KEY; if (openaiKey) await runtime.setRuntimeApiKey("openai", openaiKey, offline); + const minimaxKey = process.env.MINIMAX_API_KEY; + if (minimaxKey) await runtime.setRuntimeApiKey(MINIMAX_PROVIDER, minimaxKey, offline); + const customProvider = cfg.aiProvider ?? modelProviderFromName(cfg.model); if (customProvider && cfg.aiApiKeyEnv) { const key = process.env[cfg.aiApiKeyEnv]; @@ -542,6 +622,7 @@ async function createPiSession(projectRoot: string, cfg: PiAgentConfig): Promise // their tests) on the same ModelRegistry API as before. const modelRegistry = new ModelRegistry(runtime); configureProviderOverrides(modelRegistry, cfg); + registerMiniMaxProvider(modelRegistry, cfg); const modelName = cfg.model ?? DEFAULT_MODEL; const model = await resolvePiModelWithDynamicGateway(modelRegistry, modelName, cfg);