Skip to content
Merged
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
35 changes: 35 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,41 @@ pnpm mcode provider list --json

## 3. Search and image input

For a custom BYOK model, declare image input support explicitly when adding the
provider. Use this only if the selected provider endpoint and model accept images:

```bash
pnpm mcode provider add --name my-vision-provider --base-url https://example.com/v1 \
--api-format openai-completions --model my-vision-model \
--api-key-env MCODE_PROVIDER_API_KEY --support-image --use
```

`--support-image` applies to every repeated `--model` and saves
`capabilities.support_image: true`. Without it, adding an unknown custom model
does not infer image support from its name. `--use` and `provider test` check
connectivity with a text request; they do not verify vision support.

For an existing provider, close MCode and add the capability to the matching model
in the active profile's `config.yaml` (normally `~/.minimax/config.yaml`; see
[Accounts and data](installation.md#accounts-and-data) for profiles and overrides).
Merge this fragment into the existing provider and keep its other settings:

```yaml
custom_provider:
my-vision-provider:
models:
my-vision-model:
capabilities:
support_image: true
```

The existing `modalities: { input: [text, image], output: [text] }` model setting
also enables image input. Either declaration is sufficient. To make a model
text-only again, remove `image` from `modalities.input` and remove or set
`capabilities.support_image` to `false`. Generic `attachment: true` alone does not
declare image input support. Restart MCode after editing configuration and select
the configured model with `/provider`, or use `exec --model` for a single run.

After signing in to MiniMax, try a task that explicitly requires search:

> Use web_search to find the official Node.js test runner documentation. Summarize how to run tests and include the source URL. If the tool is unavailable, say so.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ import type {
CodexOAuthStartResult,
CodexOAuthLoginOptions,
CodexOAuthStatus,
UserModelInputView,
} from "../../service/model-system/index.js";

export type ProcessLocalModelInput = Omit<UserModelInputView, "modalities"> & {
modalities?: { input?: readonly string[]; output?: readonly string[] };
};

export interface ProcessLocalMiniAppLaunch {
readonly miniApp: MiniAppSurfaceSummary;
/** Main-process-only launch coordinates. Never forward this object to a renderer. */
Expand Down Expand Up @@ -262,7 +267,7 @@ export interface LocalRuntimeApplication {
baseUrl: string;
apiKey: string;
apiFormat?: string;
models?: readonly { modelId: string; displayName?: string }[];
models?: readonly ProcessLocalModelInput[];
saveAndUse?: boolean;
}): Promise<unknown>;
discoverCandidate(input: {
Expand All @@ -278,21 +283,7 @@ export interface LocalRuntimeApplication {
baseUrl: string;
apiKey?: string;
apiFormat?: string;
models?: readonly {
modelId: string;
displayName?: string;
configurationSource?: string;
enabled?: boolean;
attachment?: boolean;
reasoning?: boolean;
toolCall?: boolean;
temperature?: boolean;
modalities?: {
input?: readonly string[];
output?: readonly string[];
};
limit?: { context?: number; output?: number };
}[];
models?: readonly ProcessLocalModelInput[];
};
modelId?: string;
saveAndUse?: boolean;
Expand All @@ -309,7 +300,7 @@ export interface LocalRuntimeApplication {
apiKey?: string;
apiFormat?: string;
enabled?: boolean;
models?: readonly { modelId: string; displayName?: string }[];
models?: readonly ProcessLocalModelInput[];
saveAndUse?: boolean;
}): Promise<unknown>;
delete(input: { providerId: string }): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
} from '../../service/model-system/index.js';
import { watchGlobalEvents, watchProcessEvents } from '../events.js';
import type { ModelProviderApplication } from './model-provider-application.js';
import type { LocalRuntimeApplication } from './process-local-application-contract.js';
import type {
LocalRuntimeApplication,
ProcessLocalModelInput,
} from './process-local-application-contract.js';
import type { SessionReportCapability } from '../../service/session-system/index.js';

export interface ProcessLocalApplicationOptions {
Expand Down Expand Up @@ -201,18 +204,7 @@ function toProviderRecord(provider: ModelProviderView): Record<string, unknown>
}

function toUserModelInputs(
models: readonly {
modelId: string;
displayName?: string;
configurationSource?: string;
enabled?: boolean;
attachment?: boolean;
reasoning?: boolean;
toolCall?: boolean;
temperature?: boolean;
modalities?: { input?: readonly string[]; output?: readonly string[] };
limit?: { context?: number; output?: number };
}[],
models: readonly ProcessLocalModelInput[],
): UserModelInputView[] {
return models.map(({ modalities, ...model }) => ({
...model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ export interface UserModelInputView {
reasoning?: boolean;
toolCall?: boolean;
temperature?: boolean;
capabilities?: LocalModelConfig['capabilities'];
modalities?: { input?: string[]; output?: string[] };
limit?: { context?: number; output?: number };
thinkingConfig?: { mode?: string; defaultValue?: string };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@ import {
} from './service-input.js';

describe('model provider input normalization', () => {
it('preserves declared capabilities on create and merges updates without losing limits', () => {
const created = modelsFromInputs([
{
modelId: 'vision-model',
capabilities: { support_image: true, max_image_bytes_inline: 1024 },
},
]);
expect(created['vision-model']?.capabilities).toEqual({
support_image: true,
max_image_bytes_inline: 1024,
});
const patch = [{ modelId: 'vision-model', capabilities: { support_image: false } }];
for (const updated of [modelsFromInputs(patch, created), mergeModelsFromInputs(created, patch)]) {
expect(updated['vision-model']?.capabilities).toEqual({
support_image: false,
max_image_bytes_inline: 1024,
});
const renamed = mergeModelsFromInputs(updated, [
{ modelId: 'vision-model', displayName: 'Renamed' },
]);
expect(renamed['vision-model']?.capabilities).toEqual(updated['vision-model']?.capabilities);
}
expect(created['vision-model']?.capabilities?.support_image).toBe(true);
});

it('normalizes API keys and formats', () => {
expect(() => assertValidRawApiKey(undefined as unknown as string)).toThrow(
LocalModelProviderError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ function modelFromInput(
const nextModel: LocalModelConfig = {
...existing,
...modelMetadataFields(model),
...(model.capabilities
? { capabilities: { ...existing?.capabilities, ...model.capabilities } }
: {}),
...modelReasoningFields(model, effortOptions, defaultThinkingConfig),
...modelThinkingConfigFields(model, defaultThinkingConfig),
...modelEffortFields(effortOptions),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,19 @@ describe('resolveThinkingLevel', () => {
});

describe('model capability helpers', () => {
it('requires explicit image input metadata instead of generic attachment support', () => {
expect(capabilitiesFromModelConfig({ attachment: true }).support_image).toBe(false);
expect(
capabilitiesFromModelConfig({ modalities: { input: ['text'], output: ['image'] } }).support_image,
).toBe(false);
expect(
capabilitiesFromModelConfig({ modalities: { input: ['text', 'image'] } }).support_image,
).toBe(true);
expect(
capabilitiesFromModelConfig({ capabilities: { support_image: false } }).support_image,
).toBe(false);
});

it('uses explicit capability flags and forced thinking modes', () => {
expect(
capabilitiesFromModelConfig({
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command {
.option('--api-key-env <name>', 'environment variable containing the API key')
.option('--context-limit <tokens>', 'context limit for every listed model', parsePositiveSafeInteger)
.option('--output-limit <tokens>', 'output limit for every listed model', parsePositiveSafeInteger)
.option('--support-image', 'declare image input support for every listed model')
.option('--use', 'test the first model, then save and select it as the default')
.action(
(commandOptions: {
Expand All @@ -203,6 +204,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command {
model: string[];
contextLimit?: number;
outputLimit?: number;
supportImage?: boolean;
apiKeyEnv?: string;
use?: boolean;
}) => {
Expand All @@ -217,6 +219,7 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command {
models: commandOptions.model,
contextLimit: commandOptions.contextLimit,
outputLimit: commandOptions.outputLimit,
supportImage: commandOptions.supportImage,
apiKeyEnv: commandOptions.apiKeyEnv,
saveAndUse: commandOptions.use,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/cli/provider-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type McodeProviderCliRequest =
readonly models: readonly string[];
readonly contextLimit?: number;
readonly outputLimit?: number;
readonly supportImage?: boolean;
readonly apiKeyEnv?: string;
readonly saveAndUse?: boolean;
}
Expand Down Expand Up @@ -67,6 +68,7 @@ export async function runMcodeProviderCommand(
apiFormat: request.apiFormat,
models: request.models.map((modelId) => ({
modelId,
...(request.supportImage ? { capabilities: { support_image: true } } : {}),
...(request.contextLimit !== undefined || request.outputLimit !== undefined
? {
limit: {
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/provider/contract.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ModelConfig } from '@mavis/config';

export const MCODE_PROVIDER_API_FORMATS = [
'anthropic-messages',
'openai-completions',
Expand Down Expand Up @@ -74,6 +76,7 @@ export interface McodeProviderModelInput {
readonly reasoning?: boolean;
readonly toolCall?: boolean;
readonly temperature?: boolean;
readonly capabilities?: Readonly<NonNullable<ModelConfig['capabilities']>>;
readonly modalities?: { readonly input?: readonly string[]; readonly output?: readonly string[] };
readonly limit?: { readonly context?: number; readonly output?: number };
}
Expand Down
45 changes: 45 additions & 0 deletions test/byok.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ test(
"provider", "add", "--name", "Limited", "--base-url", baseUrl,
"--api-format", "openai-completions", "--model", "fixture-model",
"--model", "second-model", "--context-limit", "32768", "--output-limit", "4096", "--use",
"--support-image",
];
const beforeFailure = readFileSync(configPath, "utf8");
rejectConnection = true;
Expand All @@ -312,6 +313,7 @@ test(
assert.equal(config.defaultModel, "custom_provider:limited/fixture-model");
for (const model of ["fixture-model", "second-model"]) {
assert.deepEqual(config.custom_provider.limited.models[model].limit, { context: 32768, output: 4096 });
assert.deepEqual(config.custom_provider.limited.models[model].capabilities, { support_image: true });
}
const configured = JSON.parse(await run(["provider", "list", "--json"])).providers.find(
(provider) => provider.name === "Limited",
Expand All @@ -330,8 +332,10 @@ test(
await run([
"provider", "add", "--name", name, "--base-url", baseUrl,
"--api-format", "openai-completions", "--model", "fixture-model", flag, "32768",
"--support-image",
]);
assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].limit, limit);
assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].capabilities, { support_image: true });
assert.equal(savedConfig().defaultModel, config.defaultModel);
}
assert.equal(requests.length, beforeSaveOnly, "Limits alone must not test or select a model");
Expand All @@ -342,7 +346,48 @@ test(
reasoning: true,
thinking: { effortOptions: ["low", "high", "max"] },
};
effortConfig.custom_provider.fixture.models["vision-modalities"] = {
modalities: { input: ["text", "image"], output: ["text"] },
};
effortConfig.custom_provider.fixture.models["attachment-only"] = { attachment: true };
writeFileSync(configPath, stringifyYaml(effortConfig));
// The packaged command must carry the declaration through saving, a fresh
// process/config reload, attachment preparation and SDK serialization.
const imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGUlEQVR4nGP4n8LwnxLMMGrAqAGjBgwXAwCOqWIfmQV0zAAAAABJRU5ErkJggg==";
const imagePath = path.join(workspaceDir, "fixture.png");
writeFileSync(imagePath, Buffer.from(imageBase64, "base64"));
for (const [model, supportsImage] of [
["custom_provider:limited/fixture-model", true],
["custom_provider:context-only/fixture-model", true],
["custom_provider:fixture/vision-modalities", true],
["custom_provider:fixture/fixture-model", false],
["custom_provider:fixture/attachment-only", false],
]) {
const beforeImage = requests.length;
await run(["exec", "IMAGE_INPUT_TEST", "--model", model, "--file", imagePath,
"--timeout", "20s", "--max-steps", "1"]);
const turns = requests.slice(beforeImage).filter((r) => r.body.stream === true);
assert.ok(turns.length > 0, `Expected a runtime request for ${model}`);
for (const { body } of turns) {
const images = body.messages.flatMap((message) =>
Array.isArray(message.content)
? message.content.filter((part) => part.type === "image_url")
: []);
if (supportsImage) {
assert.equal(images.length, 1, `Expected an inline image for ${model}`);
// PNG inputs are transcoded to JPEG by the real attachment pipeline.
const url = images[0].image_url.url;
assert.match(url, /^data:image\/jpeg;base64,/);
const bytes = Buffer.from(url.split(",")[1], "base64");
assert.deepEqual([...bytes.subarray(0, 3)], [0xff, 0xd8, 0xff]);
assert.deepEqual([...bytes.subarray(-2)], [0xff, 0xd9]);
assert.doesNotMatch(JSON.stringify(body.messages), /current model cannot read this image inline/);
} else {
assert.deepEqual(images, [], `${model} must remain text-only`);
assert.match(JSON.stringify(body.messages), /current model cannot read this image inline/);
}
}
}
const effortModel = `${selected.providerId}/kimi-k3`;
for (const effort of ["low", "high", "max"]) {
const beforeEffort = requests.length;
Expand Down
Loading