Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,18 @@ availability is reported (`credentialAvailable`) but not gated, so the contract
stays inspectable even when the credential is not currently exported. Exit code:
`0` on success, `2` on a contract/usage error.

MiniMax has built-in global and China entries, defaulting to
`MiniMax-M3.1-Flash` with `MiniMax-M3` as the other catalog choice. Declare
`{ "id": "minimax" }` for `https://api.minimax.io/v1` or
`{ "id": "minimax-cn" }` for `https://api.minimaxi.com/v1`, then export the
credential as `MINIMAX_API_KEY`. An explicit `baseUrl`, `model`,
`models`, or `apiKeyEnv` in the entry still overrides the corresponding preset,
including saved legacy model IDs. Requests use the existing Chat Completions
transport with the API's default thinking behavior. Separate streamed reasoning
is retained in assistant history for subsequent tool turns. Cost estimates use Flash's
standard input/output rates; M3 uses its upper standard tier conservatively
because run totals do not retain per-request context sizes or cache usage.

```text
Provider: alt
Contract: oh-my-cli.provider-contract v1 (settings contract version 1)
Expand Down
12 changes: 11 additions & 1 deletion src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ export async function runAgent(
}

let assistantText = "";
let assistantReasoning = "";
let assistantToolCalls: Array<{ id: string; name: string; arguments: string }> = [];

// Per-message timing (#241): the wall-clock just before the request, the
Expand All @@ -649,6 +650,7 @@ export async function runAgent(
let exhaustedEmpty = false;
while (true) {
assistantText = "";
assistantReasoning = "";
assistantToolCalls = [];
roundStart = now();
firstTokenAt = null;
Expand All @@ -660,6 +662,8 @@ export async function runAgent(
if (firstTokenAt === null) firstTokenAt = now();
assistantText += event.delta;
sink.assistantDelta(event.delta);
} else if (event.type === "reasoning") {
assistantReasoning += event.delta;
} else if (event.type === "tool_call") {
assistantToolCalls.push({ id: event.id, name: event.name, arguments: event.arguments });
} else if (event.type === "usage") {
Expand Down Expand Up @@ -725,6 +729,7 @@ export async function runAgent(
!fellBack &&
transient !== null &&
assistantText.length === 0 &&
assistantReasoning.length === 0 &&
assistantToolCalls.length === 0
) {
if (hasUsage) {
Expand Down Expand Up @@ -891,7 +896,11 @@ export async function runAgent(
if (final) {
// Final answer
finalText = assistantText;
const assistantMsg: SessionMessage = { role: "assistant", content: assistantText };
const assistantMsg: SessionMessage = {
role: "assistant",
content: assistantText,
...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}),
};
messages.push(assistantMsg);
opts.onMessage(assistantMsg);
return {
Expand All @@ -914,6 +923,7 @@ export async function runAgent(
const assistantMsg: SessionMessage = {
role: "assistant",
content: assistantText || null,
...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}),
tool_calls: assistantToolCalls.map((tc) => ({
id: tc.id,
type: "function",
Expand Down
15 changes: 15 additions & 0 deletions src/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ export const MODEL_PRICES: Record<string, ModelPrice> = {
"gpt-4o-mini": { promptPerM: 0.15, completionPerM: 0.6 },
"gpt-4.1": { promptPerM: 2, completionPerM: 8 },
"gpt-4.1-mini": { promptPerM: 0.4, completionPerM: 1.6 },
"MiniMax-M3.1-Flash": {
promptPerM: 0.15,
completionPerM: 0.6,
},
// M3 uses the upper standard tier as a conservative estimate: the run tally
// does not retain per-request context sizes or cache usage for tiered billing.
"MiniMax-M3": {
promptPerM: 0.6,
completionPerM: 2.4,
},
// Keep pricing for explicitly configured legacy models.
"MiniMax-M2.7": {
promptPerM: 0.3,
completionPerM: 1.2,
},
};

// Conservative fallback used when the model is not in the table, so cost is
Expand Down
86 changes: 78 additions & 8 deletions src/provider-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,40 @@ export interface ProviderEntry {
capabilities?: ProviderCapabilityFlags;
}

export type ProviderEndpointSource = "settings" | "builtin" | "default";

// Built-in entries keep common provider configuration concise while preserving
// the contract's trust boundary: users still opt in by declaring the provider
// id, and credentials remain in a named environment variable.
export interface ProviderPreset {
baseUrl: string;
model: string;
models: readonly string[];
apiKeyEnv: string;
capabilities: Readonly<ProviderCapabilityFlags>;
}

export const BUILTIN_PROVIDER_PRESETS = {
"minimax": {
baseUrl: "https://api.minimax.io/v1",
model: "MiniMax-M3.1-Flash",
models: ["MiniMax-M3.1-Flash", "MiniMax-M3"],
apiKeyEnv: "MINIMAX_API_KEY",
capabilities: { vision: true, tools: true, streaming: true },
},
"minimax-cn": {
baseUrl: "https://api.minimaxi.com/v1",
model: "MiniMax-M3.1-Flash",
models: ["MiniMax-M3.1-Flash", "MiniMax-M3"],
apiKeyEnv: "MINIMAX_API_KEY",
capabilities: { vision: true, tools: true, streaming: true },
},
} as const satisfies Readonly<Record<string, ProviderPreset>>;

function builtinProviderPreset(id: string): ProviderPreset | undefined {
return (BUILTIN_PROVIDER_PRESETS as Readonly<Record<string, ProviderPreset>>)[id];
}

// The validated `providers` section: a negotiated contract version, an optional
// default provider id, and the validated entries (ids guaranteed unique).
export interface ProviderContract {
Expand All @@ -83,7 +117,7 @@ const CapabilitySchema = z
const ProviderEntrySchema = z.object({
id: z.string().min(1, "providers.entries[].id must be a non-empty string"),
baseUrl: z.string().url("providers.entries[].baseUrl must be a valid URL").optional(),
model: z.string().min(1, "providers.entries[].model is required"),
model: z.string().min(1, "providers.entries[].model is required").optional(),
models: z.array(z.string().min(1, "providers.entries[].models entries must be non-empty")).optional(),
apiKeyEnv: z
.string()
Expand Down Expand Up @@ -206,11 +240,33 @@ export function parseProviderContract(section: unknown): ProviderContract {
const issues = result.error.issues.map((i) => i.message).join("; ");
throw new Error(`Settings error: ${issues}`);
}
const preset = builtinProviderPreset(result.data.id);
const model = result.data.model ?? preset?.model;
if (model === undefined) {
throw new Error("Settings error: providers.entries[].model is required");
}
if (seen.has(result.data.id)) {
throw new Error(`Settings error: duplicate provider id "${result.data.id}"`);
}
seen.add(result.data.id);
entries.push(result.data);
entries.push({
id: result.data.id,
model,
...(result.data.baseUrl !== undefined ? { baseUrl: result.data.baseUrl } : {}),
...(result.data.models !== undefined
? { models: result.data.models }
: preset
? { models: [...preset.models] }
: {}),
...(result.data.apiKeyEnv !== undefined
? { apiKeyEnv: result.data.apiKeyEnv }
: preset
? { apiKeyEnv: preset.apiKeyEnv }
: {}),
...(result.data.capabilities !== undefined || preset
? { capabilities: { ...preset?.capabilities, ...result.data.capabilities } }
: {}),
});
});

if (def !== undefined && !seen.has(def)) {
Expand Down Expand Up @@ -263,7 +319,7 @@ export function resolveProviderConfig(
opts: { env?: Record<string, string | undefined> } = {},
): Config {
const env = opts.env ?? process.env;
const baseUrl = entry.baseUrl ?? DEFAULT_BASE_URL;
const { baseUrl } = resolveProviderEndpoint(entry);
if (!isValidUrl(baseUrl)) {
throw new Error(`Provider error: provider "${entry.id}" base URL is not a valid URL`);
}
Expand All @@ -286,7 +342,7 @@ export interface ProviderContractReport {
contractVersion: number;
providerId: string;
endpoint: string;
endpointSource: "settings" | "default";
endpointSource: ProviderEndpointSource;
model: string;
modelCatalog: string[];
credentialVariable: string;
Expand All @@ -301,7 +357,7 @@ export function buildProviderContractReport(facts: {
contractVersion: number;
entry: ProviderEntry;
baseUrl: string;
endpointSource: "settings" | "default";
endpointSource: ProviderEndpointSource;
credentialVariable: string;
credentialFromSettings: boolean;
credentialAvailable: boolean;
Expand Down Expand Up @@ -375,7 +431,7 @@ export interface ProviderReadiness {
state: ProviderReadinessState;
reason: string;
baseUrl: string;
endpointSource: "settings" | "default";
endpointSource: ProviderEndpointSource;
endpointValid: boolean;
credentialVariable: string;
credentialFromSettings: boolean;
Expand All @@ -387,7 +443,7 @@ export function resolveProviderReadiness(
opts: { env?: Record<string, string | undefined> } = {},
): ProviderReadiness {
const env = opts.env ?? process.env;
const baseUrl = entry.baseUrl ?? DEFAULT_BASE_URL;
const { baseUrl, endpointSource } = resolveProviderEndpoint(entry);
const endpointValid = isValidUrl(baseUrl);
const credentialVariable = entry.apiKeyEnv ?? "OPENAI_API_KEY";
const credentialAvailable = envValue(env, credentialVariable) !== undefined;
Expand All @@ -404,14 +460,28 @@ export function resolveProviderReadiness(
state,
reason,
baseUrl,
endpointSource: entry.baseUrl ? "settings" : "default",
endpointSource,
endpointValid,
credentialVariable,
credentialFromSettings: entry.apiKeyEnv !== undefined,
credentialAvailable,
};
}

function resolveProviderEndpoint(entry: ProviderEntry): {
baseUrl: string;
endpointSource: ProviderEndpointSource;
} {
if (entry.baseUrl !== undefined) {
return { baseUrl: entry.baseUrl, endpointSource: "settings" };
}
const preset = builtinProviderPreset(entry.id);
if (preset !== undefined) {
return { baseUrl: preset.baseUrl, endpointSource: "builtin" };
}
return { baseUrl: DEFAULT_BASE_URL, endpointSource: "default" };
}

// Read the user settings file, negotiate the provider contract, select one
// provider, and build the redacted report. Throws a redacted error when no
// `providers` section exists or the contract is invalid. Credential availability
Expand Down
3 changes: 2 additions & 1 deletion src/provider-invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
resolveProviderReadiness,
resolveProviderConfig,
type ProviderEntry,
type ProviderEndpointSource,
} from "./provider-contract.js";
import {
clampInvokeTimeout,
Expand Down Expand Up @@ -308,7 +309,7 @@ export interface ProviderInvocationReport {
contractVersion: number;
providerId: string;
endpoint: string;
endpointSource: "settings" | "default";
endpointSource: ProviderEndpointSource;
model: string;
credentialVariable: string;
credentialFromSettings: boolean;
Expand Down
16 changes: 15 additions & 1 deletion src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface StreamedText {
delta: string;
}

export interface StreamedReasoning {
type: "reasoning";
delta: string;
}

export interface StreamedToolCall {
type: "tool_call";
id: string;
Expand Down Expand Up @@ -44,7 +49,7 @@ export interface StreamedRetry {
delayMs: number;
}

export type StreamEvent = StreamedText | StreamedToolCall | StreamedUsage | StreamedRetry;
export type StreamEvent = StreamedText | StreamedReasoning | StreamedToolCall | StreamedUsage | StreamedRetry;

// A provider call as a stream of events. `streamChat` is the network
// implementation; tests and the deterministic task-fixture replay (#224) supply
Expand Down Expand Up @@ -300,6 +305,11 @@ async function* streamOnce(
if (!choice) continue;

const delta = choice.delta;
// Flash returns thinking separately; retain it for subsequent tool turns.
const reasoning = (delta as { reasoning_content?: unknown })?.reasoning_content;
if (typeof reasoning === "string" && reasoning) {
yield { type: "reasoning", delta: reasoning };
}
if (delta?.content) {
yield { type: "text", delta: delta.content };
}
Expand Down Expand Up @@ -350,6 +360,7 @@ export function toOpenAIMessage(msg: SessionMessage): OpenAI.ChatCompletionMessa
return {
role: "assistant",
content: msg.content ?? null,
...(msg.reasoning_content !== undefined ? { reasoning_content: msg.reasoning_content } : {}),
tool_calls: msg.tool_calls.map((tc) => ({
id: tc.id,
type: "function" as const,
Expand Down Expand Up @@ -378,5 +389,8 @@ export function toOpenAIMessage(msg: SessionMessage): OpenAI.ChatCompletionMessa
return {
role: msg.role as "system" | "user" | "assistant",
content: msg.content ?? "",
...(msg.role === "assistant" && msg.reasoning_content !== undefined
? { reasoning_content: msg.reasoning_content }
: {}),
};
}
2 changes: 2 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { SessionLock } from "./session-lock.js";
export interface SessionMessage {
role: "system" | "user" | "assistant" | "tool";
content?: string | null;
// Separate assistant thinking, retained for MiniMax tool-call continuity.
reasoning_content?: string;
tool_calls?: Array<{
id: string;
type: "function";
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ describe("lookupModelPrice", () => {
expect(price).toEqual(MODEL_PRICES["gpt-4o"]);
});

it("matches current MiniMax models and retains legacy estimates", () => {
expect(lookupModelPrice("MiniMax-M3.1-Flash")).toEqual({
known: true,
price: { promptPerM: 0.15, completionPerM: 0.6 },
});
expect(lookupModelPrice("MiniMax-M3")).toEqual({
known: true,
price: {
promptPerM: 0.6,
completionPerM: 2.4,
},
});
expect(lookupModelPrice("MiniMax-M2.7")).toEqual({
known: true,
price: {
promptPerM: 0.3,
completionPerM: 1.2,
},
});
});

it("matches a dated variant by longest known prefix", () => {
const dated = lookupModelPrice("gpt-4o-2024-08-06");
expect(dated.known).toBe(true);
Expand Down
Loading