From a5d396df1f74c307dc20397c361e4399549cd6cd Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:08:05 +0800 Subject: [PATCH 1/2] Add MiniMax provider preset --- README.md | 3 ++- lib/providers.mjs | 1 + tests/backend.test.mjs | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 815be3f..4127e34 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ App-owned runtimes, provider settings, chat history, attachments, caches, and lo | Feature | Details | |---|---| | **Official Claude Code** | Uses Anthropic's official CLI and Agent SDK — no replacement agent engine | -| **9 AI Providers** | NVIDIA NIM · DeepSeek · OpenRouter · Google Gemini · Anthropic · OpenAI · Ollama · LM Studio · Custom API | +| **10 AI Providers** | NVIDIA NIM · DeepSeek · MiniMax · OpenRouter · Google Gemini · Anthropic · OpenAI · Ollama · LM Studio · Custom API | | **Portable Runtime** | Node.js, Claude Code, caches, settings, and app data stay in the project folder | | **Studio Dashboard** | Streaming chat, Markdown, code highlighting, compact tool steps, approvals, attachments, and diagnostics | | **Shared Claude History** | GUI and portable terminal sessions use real Claude Code conversation IDs and can resume the same history | @@ -123,6 +123,7 @@ Pressing Enter selects the dashboard. |---|---|---| | **NVIDIA NIM** | Chat Completions adapter | [build.nvidia.com](https://build.nvidia.com) | | **DeepSeek** | Anthropic-compatible API | [platform.deepseek.com](https://platform.deepseek.com) | +| **MiniMax** | Chat Completions adapter | [platform.minimax.io](https://platform.minimax.io) | | **OpenRouter** | Anthropic-compatible API | [openrouter.ai](https://openrouter.ai) | | **Google Gemini** | Chat Completions adapter | [aistudio.google.com](https://aistudio.google.com) | | **Anthropic Claude** | Native Messages API / terminal login | [console.anthropic.com](https://console.anthropic.com) | diff --git a/lib/providers.mjs b/lib/providers.mjs index 872cd34..a25f53f 100644 --- a/lib/providers.mjs +++ b/lib/providers.mjs @@ -9,6 +9,7 @@ export const PROVIDERS = { deepseek: { name: 'DeepSeek', short: 'DS', description: 'Direct Anthropic-compatible API', transport: 'anthropic', baseUrl: 'https://api.deepseek.com/anthropic', modelsUrl: 'https://api.deepseek.com/models' }, nvidia: { name: 'NVIDIA NIM', short: 'NV', description: 'Cloud inference · local adapter', transport: 'openai', baseUrl: 'https://integrate.api.nvidia.com/v1' }, gemini: { name: 'Google Gemini', short: 'GE', description: 'Google AI · local adapter', transport: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }, + minimax: { name: 'MiniMax', short: 'MM', description: 'MiniMax · OpenAI-compatible API', transport: 'openai', baseUrl: 'https://api.minimax.io/v1', modelsUrl: 'https://api.minimax.io/v1/models', defaultModel: 'MiniMax-M3', endpoints: [{ region: 'global_en', baseUrl: 'https://api.minimax.io/v1', anthropicBaseUrl: 'https://api.minimax.io/anthropic', docsRoot: 'https://platform.minimax.io/docs' }, { region: 'cn_zh', baseUrl: 'https://api.minimaxi.com/v1', anthropicBaseUrl: 'https://api.minimaxi.com/anthropic', docsRoot: 'https://platform.minimaxi.com/docs' }] }, openai: { name: 'OpenAI', short: 'OA', description: 'Chat Completions · local adapter', transport: 'openai', baseUrl: 'https://api.openai.com/v1' }, ollama: { name: 'Ollama', short: 'OL', description: 'Local models · offline inference', transport: 'anthropic', baseUrl: 'http://127.0.0.1:11434', local: true }, lmstudio: { name: 'LM Studio', short: 'LM', description: 'Your local model server', transport: 'anthropic', baseUrl: 'http://127.0.0.1:1234', local: true }, diff --git a/tests/backend.test.mjs b/tests/backend.test.mjs index 9b1b0b3..0a8cfc4 100644 --- a/tests/backend.test.mjs +++ b/tests/backend.test.mjs @@ -16,8 +16,8 @@ const { pickWorkspace }=await import('../lib/workspace-picker.mjs'); const { nativeSessionId }=await import('../lib/native-sessions.mjs'); test.after(()=>rmSync(temp,{recursive:true,force:true})); const profile={provider:'custom',model:'test-model',auth:'api',baseUrl:'http://127.0.0.1:9099/v1',key:'test-secret-not-real'}; -test('all nine providers and safe configuration round trips',()=>{ - assert.equal(Object.keys(PROVIDERS).length,9);saveProfile({...profile,contextWindow:128000}); +test('all ten providers and safe configuration round trips',()=>{ + assert.equal(Object.keys(PROVIDERS).length,10);assert.equal(PROVIDERS.minimax.defaultModel,'MiniMax-M3');assert.equal(PROVIDERS.minimax.baseUrl,'https://api.minimax.io/v1');assert.equal(PROVIDERS.minimax.endpoints[1].baseUrl,'https://api.minimaxi.com/v1');saveProfile({...profile,contextWindow:128000}); assert.equal(readConfig().profiles.custom.contextWindow,128000);assert.throws(()=>saveProfile({...profile,contextWindow:100}),/Context window/); assert.equal(readConfig().profiles.custom.key,profile.key);assert.equal(publicConfig().profiles.custom.hasKey,true);assert.equal(publicConfig().profiles.custom.key,undefined); saveProfile({...profile,key:undefined,model:'next'});assert.equal(readConfig().profiles.custom.key,profile.key);saveProfile(profile); From 353ef0b80ea7e0abe7c4b4280c3d4893bbcffd4d Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:08:43 +0800 Subject: [PATCH 2/2] feat: default MiniMax preset to M3.1 Flash --- README.md | 8 +++ dashboard/app.js | 6 +- lib/adapter.mjs | 22 ++++++- lib/agent.mjs | 4 +- lib/providers.mjs | 3 +- tests/backend.test.mjs | 2 +- tests/minimax.test.mjs | 145 +++++++++++++++++++++++++++++++++++++++++ tools/launcher.mjs | 2 +- 8 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 tests/minimax.test.mjs diff --git a/README.md b/README.md index 4127e34..5803ace 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,14 @@ Pressing Enter selects the dashboard. > **Non-Claude models are experimental and are not supported by Anthropic.** Tool calling, images, context limits, resume behavior, and other Claude Code features depend on the selected model and provider. +### MiniMax + +The MiniMax preset defaults to **MiniMax-M3.1-Flash** and offers **MiniMax-M3** as the second model. Both have a 1,000,000-token context window. Existing saved models and manually entered IDs remain unchanged. Use `https://api.minimax.io/v1` globally or `https://api.minimaxi.com/v1` in China with the matching API key. + +The built-in Chat Completions adapter uses adaptive thinking by default for these models. Flash always thinks and defaults to `max` reasoning effort; its supported effort values are `low`, `medium`, `high`, `xhigh`, and `max`. M3 also accepts disabled thinking and ignores effort. Flash does not accept `service_tier` or `reasoning_split=false`. The dashboard preserves its existing image attachment support; video input is not implemented by this adapter. + +See MiniMax's [model documentation](https://platform.minimax.io/docs/guides/models-intro.md) and [Chat Completions API](https://platform.minimax.io/docs/api-reference/text-chat-openai.md). + ## Custom OpenAI-Compatible Provider OpenAI-compatible providers can use: diff --git a/dashboard/app.js b/dashboard/app.js index c74e783..0987345 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -446,11 +446,11 @@ function renderProviders(){ $$('[data-provider]').forEach(b=>b.onclick=()=>selectProvider(b.dataset.provider)); } function selectProvider(id){ - state.selectedProvider=id;state.models=[];const p=state.providers[id];if(!p)return;const saved=state.config.profiles[id]; + state.selectedProvider=id;const p=state.providers[id];if(!p)return;state.models=p.models||[];const saved=state.config.profiles[id]; renderProviders();$('#provider-title').textContent=p.name;$('#transport-tag').textContent=p.transport==='anthropic'?'Direct Messages API':'Local compatibility adapter'; - $('#provider-note').textContent=id==='anthropic'?'Use an API key in the studio, or choose account login for terminal sessions.':p.transport==='openai'?'The official agent connects through a local protocol adapter. Extended thinking and provider-specific server tools are not supported.':'This provider exposes an Anthropic-compatible endpoint. Non-Claude model capabilities vary.'; + $('#provider-note').textContent=id==='anthropic'?'Use an API key in the studio, or choose account login for terminal sessions.':id==='minimax'?'Flash and M3 use adaptive thinking with the built-in adapter. Saved and manually entered model IDs remain available.':p.transport==='openai'?'The official agent connects through a local protocol adapter. Extended thinking and provider-specific server tools are not supported.':'This provider exposes an Anthropic-compatible endpoint. Non-Claude model capabilities vary.'; $('#base-url').value=saved?.baseUrl||p.baseUrl;$('#api-key').value='';setApiKeyVisibility(false);$('#api-key').placeholder=saved?.hasKey?'Credential saved · leave blank to keep':'Enter your provider API key';$('#key-saved').textContent=saved?.hasKey?'· saved':''; - $('#model-name').value=saved?.model||p.defaultModel||'';$('#context-window').value=saved?.contextWindow||'';$('#auth').value=saved?.auth||'api';$('#auth-field').hidden=id!=='anthropic';$('#model-results').hidden=true;formMessage('#provider-message','');updateAuthField();updateAdapterFields(true); + $('#model-name').value=saved?.model||p.defaultModel||'';$('#context-window').value=saved?.contextWindow||'';$('#auth').value=saved?.auth||'api';$('#auth-field').hidden=id!=='anthropic';renderModels();formMessage('#provider-message','');updateAuthField();updateAdapterFields(true); } function updateAdapterFields(loadSaved=false){ const p=state.providers[state.selectedProvider];const saved=state.config.profiles[state.selectedProvider]; diff --git a/lib/adapter.mjs b/lib/adapter.mjs index 214f69e..aae8330 100644 --- a/lib/adapter.mjs +++ b/lib/adapter.mjs @@ -19,9 +19,17 @@ function toolResultContent(block) { for(const part of block.content){if(part.type==='text')text.push(part.text);else if(part.type==='image')images.push(imageContent(part));else throw new Error(`Unsupported content: ${part.type}`);} return {text:text.join('\n'),images}; } -export function translateRequest(body, model, {toolMetadata}={}) { +const MINIMAX_FLASH='MiniMax-M3.1-Flash'; +const MINIMAX_M3='MiniMax-M3'; + +export function translateRequest(body, model, {toolMetadata, provider}={}) { if (!Array.isArray(body.messages)) throw new Error('messages must be an array'); - if (body.thinking && body.thinking.type !== 'disabled') throw new Error('Extended thinking is unavailable through the Chat Completions adapter. Disable thinking for this provider.'); + const minimax = provider === 'minimax' && [MINIMAX_FLASH, MINIMAX_M3].includes(model); + if (minimax) { + if (body.thinking && !['adaptive','disabled'].includes(body.thinking.type)) throw new Error('MiniMax thinking must be adaptive or disabled'); + if (model === MINIMAX_FLASH && body.thinking?.type === 'disabled') throw new Error('MiniMax-M3.1-Flash thinking cannot be disabled'); + if (model === MINIMAX_FLASH && (body.service_tier !== undefined || body.reasoning_split === false)) throw new Error('MiniMax-M3.1-Flash does not support service_tier or reasoning_split=false'); + } else if (body.thinking && body.thinking.type !== 'disabled') throw new Error('Extended thinking is unavailable through the Chat Completions adapter. Disable thinking for this provider.'); const messages = []; if (body.system) messages.push({ role: 'system', content: textContent(body.system) }); for (const message of body.messages) { @@ -47,6 +55,14 @@ export function translateRequest(body, model, {toolMetadata}={}) { if (content.length || calls.length) messages.push({ role: message.role, content: content.length ? content.every(b=>b.type==='text') ? content.map(b=>b.text).join('\n') : content : null, ...(calls.length ? { tool_calls: calls } : {}) }); } const request = { model, messages, stream: !!body.stream }; + if (minimax) { + request.thinking = { type: body.thinking?.type || 'adaptive' }; + if (model === MINIMAX_FLASH) { + const effort = body.reasoning_effort ?? body.output_config?.effort ?? 'max'; + if (!['low','medium','high','xhigh','max'].includes(effort)) throw new Error('Unsupported MiniMax-M3.1-Flash reasoning effort'); + request.reasoning_effort = effort; + } + } if (body.max_tokens) request.max_tokens = body.max_tokens; if (body.temperature !== undefined) request.temperature = body.temperature; if (body.top_p !== undefined) request.top_p = body.top_p; @@ -139,7 +155,7 @@ export async function startAdapter(profile, { fetchImpl = fetch, onError = () => return json(res, 200, { input_tokens: count, estimated: true }); } const remember=(id,metadata)=>{if(id&&metadata)toolMetadata.set(id,metadata);}; - const request = translateRequest(body, profile.model,{toolMetadata}); + const request = translateRequest(body, profile.model,{toolMetadata,provider:profile.provider}); const upstream = await fetchImpl(`${profile.baseUrl.replace(/\/+$/,'')}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(profile.key ? { Authorization: `Bearer ${profile.key}` } : {}) }, body: JSON.stringify(request), redirect: 'error', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(180000)]) }); diff --git a/lib/agent.mjs b/lib/agent.mjs index 8f54ffc..a63b777 100644 --- a/lib/agent.mjs +++ b/lib/agent.mjs @@ -65,7 +65,9 @@ export class AgentManager { includePartialMessages:true,settingSources:[],systemPrompt:{type:'preset',preset:'claude_code'}, maxTurns:40,abortController:run.controller, ...(session.sdkSessionId?{resume:session.sdkSessionId}:{}), - ...(adapter?{thinking:{type:'disabled'},effort:'low'}:{}), + ...(adapter?(profile.provider==='minimax'&&(!profile.adapter||profile.adapter==='builtin')&&['MiniMax-M3.1-Flash','MiniMax-M3'].includes(profile.model) + ?{thinking:{type:'adaptive'},...(profile.model==='MiniMax-M3.1-Flash'?{effort:'max'}:{})} + :{thinking:{type:'disabled'},effort:'low'}):{}), canUseTool:async(name,input,context)=>{ clearResponseTimer(); if(run.cancelled)return {behavior:'deny',message:'Session cancelled'}; diff --git a/lib/providers.mjs b/lib/providers.mjs index a25f53f..bc2851b 100644 --- a/lib/providers.mjs +++ b/lib/providers.mjs @@ -9,7 +9,7 @@ export const PROVIDERS = { deepseek: { name: 'DeepSeek', short: 'DS', description: 'Direct Anthropic-compatible API', transport: 'anthropic', baseUrl: 'https://api.deepseek.com/anthropic', modelsUrl: 'https://api.deepseek.com/models' }, nvidia: { name: 'NVIDIA NIM', short: 'NV', description: 'Cloud inference · local adapter', transport: 'openai', baseUrl: 'https://integrate.api.nvidia.com/v1' }, gemini: { name: 'Google Gemini', short: 'GE', description: 'Google AI · local adapter', transport: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }, - minimax: { name: 'MiniMax', short: 'MM', description: 'MiniMax · OpenAI-compatible API', transport: 'openai', baseUrl: 'https://api.minimax.io/v1', modelsUrl: 'https://api.minimax.io/v1/models', defaultModel: 'MiniMax-M3', endpoints: [{ region: 'global_en', baseUrl: 'https://api.minimax.io/v1', anthropicBaseUrl: 'https://api.minimax.io/anthropic', docsRoot: 'https://platform.minimax.io/docs' }, { region: 'cn_zh', baseUrl: 'https://api.minimaxi.com/v1', anthropicBaseUrl: 'https://api.minimaxi.com/anthropic', docsRoot: 'https://platform.minimaxi.com/docs' }] }, + minimax: { name: 'MiniMax', short: 'MM', description: 'MiniMax · OpenAI-compatible API', transport: 'openai', baseUrl: 'https://api.minimax.io/v1', modelsUrl: 'https://api.minimax.io/v1/models', defaultModel: 'MiniMax-M3.1-Flash', models: [{ id: 'MiniMax-M3.1-Flash', name: 'MiniMax-M3.1-Flash', contextWindow: 1000000 }, { id: 'MiniMax-M3', name: 'MiniMax-M3', contextWindow: 1000000 }], endpoints: [{ region: 'global_en', baseUrl: 'https://api.minimax.io/v1', anthropicBaseUrl: 'https://api.minimax.io/anthropic', docsRoot: 'https://platform.minimax.io/docs' }, { region: 'cn_zh', baseUrl: 'https://api.minimaxi.com/v1', anthropicBaseUrl: 'https://api.minimaxi.com/anthropic', docsRoot: 'https://platform.minimaxi.com/docs' }] }, openai: { name: 'OpenAI', short: 'OA', description: 'Chat Completions · local adapter', transport: 'openai', baseUrl: 'https://api.openai.com/v1' }, ollama: { name: 'Ollama', short: 'OL', description: 'Local models · offline inference', transport: 'anthropic', baseUrl: 'http://127.0.0.1:11434', local: true }, lmstudio: { name: 'LM Studio', short: 'LM', description: 'Your local model server', transport: 'anthropic', baseUrl: 'http://127.0.0.1:1234', local: true }, @@ -64,6 +64,7 @@ export async function discoverModels(profile, signal) { const res = await fetch(providerURL(profile), { headers:providerHeaders(profile), signal:signal||AbortSignal.timeout(15000), redirect:'error' }); if (!res.ok) throw new Error(`Model discovery returned HTTP ${res.status}. Check the endpoint/credential or enter a model manually.`); const body = await res.json(); + if (profile.provider === 'minimax') return p.models.map(model => ({...model})); return (body.data || body.models || []).map(m => { const raw=m.context_length??m.context_window??m.max_context_length??m.max_input_tokens??m.inputTokenLimit; const contextWindow=Number(raw); diff --git a/tests/backend.test.mjs b/tests/backend.test.mjs index 0a8cfc4..6ad3798 100644 --- a/tests/backend.test.mjs +++ b/tests/backend.test.mjs @@ -17,7 +17,7 @@ const { nativeSessionId }=await import('../lib/native-sessions.mjs'); test.after(()=>rmSync(temp,{recursive:true,force:true})); const profile={provider:'custom',model:'test-model',auth:'api',baseUrl:'http://127.0.0.1:9099/v1',key:'test-secret-not-real'}; test('all ten providers and safe configuration round trips',()=>{ - assert.equal(Object.keys(PROVIDERS).length,10);assert.equal(PROVIDERS.minimax.defaultModel,'MiniMax-M3');assert.equal(PROVIDERS.minimax.baseUrl,'https://api.minimax.io/v1');assert.equal(PROVIDERS.minimax.endpoints[1].baseUrl,'https://api.minimaxi.com/v1');saveProfile({...profile,contextWindow:128000}); + assert.equal(Object.keys(PROVIDERS).length,10);assert.equal(PROVIDERS.minimax.defaultModel,'MiniMax-M3.1-Flash');assert.equal(PROVIDERS.minimax.baseUrl,'https://api.minimax.io/v1');assert.equal(PROVIDERS.minimax.endpoints[1].baseUrl,'https://api.minimaxi.com/v1');saveProfile({...profile,contextWindow:128000}); assert.equal(readConfig().profiles.custom.contextWindow,128000);assert.throws(()=>saveProfile({...profile,contextWindow:100}),/Context window/); assert.equal(readConfig().profiles.custom.key,profile.key);assert.equal(publicConfig().profiles.custom.hasKey,true);assert.equal(publicConfig().profiles.custom.key,undefined); saveProfile({...profile,key:undefined,model:'next'});assert.equal(readConfig().profiles.custom.key,profile.key);saveProfile(profile); diff --git a/tests/minimax.test.mjs b/tests/minimax.test.mjs new file mode 100644 index 0000000..cb0a4d6 --- /dev/null +++ b/tests/minimax.test.mjs @@ -0,0 +1,145 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +const temp=mkdtempSync(join(tmpdir(),'portable-minimax-')); +process.env.PORTABLE_AI_DATA_DIR=join(temp,'data'); +const { PROVIDERS, discoverModels, providerEnvironment }=await import('../lib/providers.mjs'); +const { saveProfile, readConfig }=await import('../lib/config.mjs'); +const { startAdapter }=await import('../lib/adapter.mjs'); +const { startResponsesAdapter }=await import('../lib/responses-adapter.mjs'); +const { AgentManager }=await import('../lib/agent.mjs'); +const { SessionStore }=await import('../lib/sessions.mjs'); +test.after(()=>rmSync(temp,{recursive:true,force:true})); +const flash='MiniMax-M3.1-Flash', m3='MiniMax-M3'; +const completion=()=>new Response(JSON.stringify({choices:[{message:{content:'Fixture reply'},finish_reason:'stop'}]}),{headers:{'Content-Type':'application/json'}}); +const image={type:'image',source:{type:'base64',media_type:'image/png',data:'AAAA'}}; +const input={messages:[{role:'user',content:[{type:'text',text:'Describe'},image]}],max_tokens:100}; + +test('MiniMax catalog projects current choices in both regions without changing saved or custom IDs',async t=>{ + assert.equal(PROVIDERS.minimax.defaultModel,flash); + assert.deepEqual(PROVIDERS.minimax.models.map(m=>[m.id,m.contextWindow]),[[flash,1000000],[m3,1000000]]); + const upstream=[{id:'MiniMax-M2.5'},{id:m3},{id:flash},{id:'speech-2.8-hd'},{id:'custom-model'}]; + const original=structuredClone(upstream),requests=[]; + t.mock.method(globalThis,'fetch',async(url,options)=>{requests.push({url,options});return new Response(JSON.stringify({data:upstream}));}); + for(const endpoint of PROVIDERS.minimax.endpoints){ + const profile={provider:'minimax',baseUrl:endpoint.baseUrl,key:'fixture-key',model:'MiniMax-M2.5',auth:'api'}; + saveProfile(profile); + assert.deepEqual(await discoverModels(profile),PROVIDERS.minimax.models); + assert.equal(requests.at(-1).url,`${endpoint.baseUrl}/models`); + assert.equal(requests.at(-1).options.headers.Authorization,'Bearer fixture-key'); + assert.equal(readConfig().profiles.minimax.model,'MiniMax-M2.5'); + } + assert.deepEqual(upstream,original); + assert.deepEqual((await discoverModels({provider:'custom',baseUrl:'https://custom.example/v1'})).map(m=>m.id),upstream.map(m=>m.id)); + saveProfile({provider:'minimax',baseUrl:PROVIDERS.minimax.baseUrl,model:'my-private-alias',auth:'api',key:'fixture-key'}); + assert.equal(readConfig().profiles.minimax.model,'my-private-alias'); +}); + +test('real local HTTP adapter serializes MiniMax defaults, efforts, images and tool history for both regions',async t=>{ + for(const endpoint of PROVIDERS.minimax.endpoints)for(const model of [flash,m3,'MiniMax-M2.5','my-private-alias']){ + const profile={provider:'minimax',baseUrl:endpoint.baseUrl,model,key:'fixture-key'}; + let captured; + const app=await startAdapter(profile,{fetchImpl:async(url,options)=>{captured={url,options,body:JSON.parse(options.body)};return completion();}}); + try{ + const send=body=>fetch(`${app.url}/v1/messages`,{method:'POST',headers:{Authorization:`Bearer ${app.token}`},body:JSON.stringify(body)}); + const history={...input,messages:[...input.messages,{role:'assistant',content:[{type:'tool_use',id:'call-1',name:'Read',input:{file_path:'saved.txt'}}]},{role:'user',content:[{type:'tool_result',tool_use_id:'call-1',content:'saved history'}]}]}; + const response=await send(history);assert.equal(response.status,200);assert.equal((await response.json()).model,model); + assert.equal(captured.url,`${endpoint.baseUrl}/chat/completions`); + assert.equal(captured.options.headers.Authorization,'Bearer fixture-key');assert.equal(captured.body.model,model); + assert.equal(captured.body.messages[0].content[1].image_url.url,'data:image/png;base64,AAAA'); + assert.equal(captured.body.messages.at(-1).content,'saved history');assert.equal(captured.body.messages.at(-1).tool_call_id,'call-1'); + assert.equal(captured.body.messages[1].tool_calls[0].function.arguments,'{"file_path":"saved.txt"}'); + assert.equal(captured.body.service_tier,undefined);assert.equal(captured.body.reasoning_split,undefined); + if(model===flash){ + assert.deepEqual(captured.body.thinking,{type:'adaptive'});assert.equal(captured.body.reasoning_effort,'max'); + for(const effort of ['low','medium','high','xhigh','max']){ + assert.equal((await send({...input,thinking:{type:'adaptive'},output_config:{effort}})).status,200); + assert.equal(captured.body.reasoning_effort,effort);assert.equal(captured.body.output_config,undefined); + } + for(const invalid of [{thinking:{type:'disabled'}},{reasoning_effort:'none'},{service_tier:'priority'},{reasoning_split:false}]){ + captured=null;assert.equal((await send({...input,...invalid})).status,400);assert.equal(captured,null); + } + }else if(model===m3){ + assert.deepEqual(captured.body.thinking,{type:'adaptive'});assert.equal(captured.body.reasoning_effort,undefined); + assert.equal((await send({...input,thinking:{type:'disabled'},output_config:{effort:'none'}})).status,200); + assert.deepEqual(captured.body.thinking,{type:'disabled'});assert.equal(captured.body.reasoning_effort,undefined); + }else{ + assert.equal(captured.body.thinking,undefined);assert.equal(captured.body.reasoning_effort,undefined); + } + }finally{await app.close();} + } +}); + +test('MiniMax streaming requests retain the Chat Completions boundary and response events',async()=>{ + for(const endpoint of PROVIDERS.minimax.endpoints)for(const model of [flash,m3]){ + let wire; + const app=await startAdapter({provider:'minimax',baseUrl:endpoint.baseUrl,model},{fetchImpl:async(_url,options)=>{ + wire=JSON.parse(options.body); + const frames=[{choices:[{delta:{content:'Fixture stream'},finish_reason:null}]},{choices:[{delta:{},finish_reason:'stop'}]},'[DONE]']; + return new Response(frames.map(frame=>`data: ${typeof frame==='string'?frame:JSON.stringify(frame)}\n\n`).join(''),{headers:{'Content-Type':'text/event-stream'}}); + }}); + try{ + const response=await fetch(`${app.url}/v1/messages`,{method:'POST',headers:{Authorization:`Bearer ${app.token}`},body:JSON.stringify({...input,stream:true})}); + assert.equal(response.status,200);const events=await response.text();assert.match(events,/Fixture stream/);assert.match(events,/message_stop/); + assert.equal(wire.stream,true);assert.deepEqual(wire.stream_options,{include_usage:true});assert.equal(wire.model,model);assert.deepEqual(wire.thinking,{type:'adaptive'}); + assert.equal(wire.reasoning_effort,model===flash?'max':undefined); + }finally{await app.close();} + } +}); + +test('explicit Responses selection preserves full image and tool history without server-side state',async()=>{ + // This checks the existing adapter contract, not regional service availability. + for(const model of [flash,m3,'MiniMax-M2.5']){ + let wire; + const app=await startResponsesAdapter({provider:'minimax',baseUrl:PROVIDERS.minimax.baseUrl,model},{fetchImpl:async(url,options)=>{ + assert.equal(url,'https://api.minimax.io/v1/responses');wire=JSON.parse(options.body); + return new Response(JSON.stringify({status:'completed',output:[{type:'message',content:[{type:'output_text',text:'Fixture response'}]}]})); + }}); + try{ + const messages=[...input.messages,{role:'assistant',content:[{type:'tool_use',id:'saved-call',name:'Read',input:{}}]},{role:'user',content:[{type:'tool_result',tool_use_id:'saved-call',content:'saved history'}]}]; + const response=await fetch(`${app.url}/v1/messages`,{method:'POST',headers:{Authorization:`Bearer ${app.token}`},body:JSON.stringify({...input,messages,thinking:{type:'disabled'}})}); + assert.equal(response.status,200);assert.equal(wire.model,model);assert.equal(wire.input[0].content[1].type,'input_image'); + assert.equal(wire.input.at(-1).output,'saved history');assert.equal(wire.input.at(-1).call_id,'saved-call'); + for(const field of ['previous_response_id','store','conversation','service_tier','reasoning'])assert.equal(wire[field],undefined); + }finally{await app.close();} + } +}); + +test('non-MiniMax provider with the same model name retains generic request semantics',async()=>{ + let request; + const app=await startAdapter({provider:'custom',baseUrl:'https://custom.example/v1',model:flash},{fetchImpl:async(_url,options)=>{request=JSON.parse(options.body);return completion();}}); + try{ + const response=await fetch(`${app.url}/v1/messages`,{method:'POST',headers:{Authorization:`Bearer ${app.token}`},body:JSON.stringify({...input,thinking:{type:'disabled'},output_config:{effort:'low'}})}); + assert.equal(response.status,200);assert.equal(request.model,flash);assert.equal(request.thinking,undefined);assert.equal(request.reasoning_effort,undefined); + }finally{await app.close();} +}); + +test('SDK bridge passes current defaults through its real adapter and preserves legacy/custom configuration',async t=>{ + const realFetch=globalThis.fetch,requests=[]; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + if(String(url).startsWith('http://127.0.0.1:'))return realFetch(url,options); + assert.ok(PROVIDERS.minimax.endpoints.some(e=>url===`${e.baseUrl}/chat/completions`)); + requests.push(JSON.parse(options.body));return completion(); + }); + for(const baseUrl of PROVIDERS.minimax.endpoints.map(e=>e.baseUrl))for(const model of [flash,m3,'MiniMax-M2.5','my-private-alias']){ + saveProfile({provider:'minimax',baseUrl,model,auth:'api',key:'fixture-key',adapter:'builtin'}); + const profile=readConfig().profiles.minimax,store=new SessionStore(join(temp,`sessions-${requests.length}`)); + const session=store.create({workspace:temp,profile,trusted:true});let observed; + const manager=new AgentManager(store,{executable:()=>'/fixture/claude',sdkLoader:async()=>({query:({options})=>(async function*(){ + observed=options; + const response=await fetch(`${options.env.ANTHROPIC_BASE_URL}/v1/messages`,{method:'POST',headers:{Authorization:`Bearer ${options.env.ANTHROPIC_AUTH_TOKEN}`},body:JSON.stringify({...input,thinking:options.thinking,...(options.effort?{output_config:{effort:options.effort}}:{})})}); + assert.equal(response.status,200);yield {type:'result',is_error:false}; + })()})}); + await(await manager.start(session.id,'Fixture request')).promise; + assert.equal(store.get(session.id).status,'completed');assert.equal(observed.model,model); + assert.equal(observed.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,model);assert.equal(requests.at(-1).model,model); + assert.equal(readConfig().profiles.minimax.model,model); + if([flash,m3].includes(model)){assert.deepEqual(observed.thinking,{type:'adaptive'});assert.equal(observed.effort,model===flash?'max':undefined);} + else{assert.deepEqual(observed.thinking,{type:'disabled'});assert.equal(observed.effort,'low');} + } + const custom={provider:'custom',model:'saved-custom',auth:'api',adapter:'external',modelAliases:{opus:'legacy-opus',sonnet:'legacy-sonnet',haiku:'legacy-haiku'}}; + const env=providerEnvironment(custom,{adapter:{url:'http://127.0.0.1:1',token:'fixture'},parent:{}}); + assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL,'legacy-opus');assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL,'legacy-sonnet');assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL,'legacy-haiku'); +}); diff --git a/tools/launcher.mjs b/tools/launcher.mjs index fb73225..de0947e 100644 --- a/tools/launcher.mjs +++ b/tools/launcher.mjs @@ -73,7 +73,7 @@ async function main() { const useAdapter = PROVIDERS[profile.provider].transport === 'openai'; const adapter = !useAdapter ? null : profile.adapter === 'external' ? await startExternalAdapter(profile) : profile.adapter === 'responses' ? await startResponsesAdapter(profile) : await startAdapter(profile); const env = providerEnvironment(profile, { adapter }); - if (adapter) env.MAX_THINKING_TOKENS = '0'; + if (adapter && !(profile.provider==='minimax'&&(!profile.adapter||profile.adapter==='builtin')&&['MiniMax-M3.1-Flash','MiniMax-M3'].includes(profile.model))) env.MAX_THINKING_TOKENS = '0'; console.log(`\nOfficial Claude Code → ${PROVIDERS[profile.provider].name} / ${profile.model}${useAdapter && profile.adapter === 'external' ? ` · external adapter (${profile.toolFormat || 'native'})` : useAdapter && profile.adapter === 'responses' ? ' · Responses API adapter' : ''}\nNon-Claude models have provider-dependent feature compatibility.\n`); try { await run(executableAt(), ['--model',profile.model,...args], { stdio:'inherit', env, cwd: process.cwd() }); } finally { await adapter?.close(); }