Skip to content

Commit 926fd04

Browse files
tomdpsocto-patch
andauthored
feat(gateway): add MiniMax compatibility (#815)
## Main-trunk migration Replaces #658 after the trunk cutover. The contributor commit now starts from the new `main`; original authorship is preserved. The only migration resolution retained both the current reasoning-effort tests and the contributor’s new gateway tests. ## Original PR body Reason: add target provider/model to existing provider registry ## Summary Adds MiniMax model compatibility to the bundled gateway configuration. ## Related Issues None. ## Changes Made - Register MiniMax-M3 and MiniMax-M2.7 in the gateway model catalog. - Support selectable compatibility protocols and token limits in gateway requests. - Document global and China endpoint configurations and cover request and configuration behavior with tests. ## Testing - `npm ci` - `npm run check:agent-cli-provider:ci` - `npm run check` - `npm test` ## Checklist - [x] Tests pass (`npm test`) - [x] Documentation updated (if needed) - [x] Follows commit guidelines --------- Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
1 parent 4d8ccf3 commit 926fd04

13 files changed

Lines changed: 441 additions & 16 deletions

docs/providers.md

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
Zeroshot supports two provider shapes:
44

55
- CLI-backed providers that shell out to a full agent CLI
6-
- One bundled `gateway` provider that wraps OpenAI-compatible model APIs with a
7-
Zeroshot-owned tool runner
6+
- One bundled `gateway` provider that wraps OpenAI-compatible or Anthropic-compatible
7+
model APIs with a Zeroshot-owned tool runner
88

99
## Supported Providers
1010

@@ -29,16 +29,17 @@ Zeroshot supports two provider shapes:
2929

3030
## Gateway Provider
3131

32-
Use `gateway` for OpenAI-compatible model endpoints such as OpenRouter,
33-
Ollama, vLLM, or self-hosted gateways. These stay model configs behind one
34-
provider engine; do not add them as standalone provider ids.
32+
Use `gateway` for OpenAI-compatible or Anthropic-compatible model endpoints.
33+
These stay model configs behind one provider engine; do not add them as
34+
standalone provider ids.
3535

3636
Required settings:
3737

3838
```json
3939
{
4040
"providerSettings": {
4141
"gateway": {
42+
"protocol": "openai",
4243
"baseUrl": "http://127.0.0.1:11434",
4344
"apiKey": "gateway-key",
4445
"model": "openrouter/meta-llama/test-model",
@@ -53,10 +54,49 @@ Required settings:
5354

5455
Notes:
5556

57+
- `protocol` defaults to `openai`; set it to `anthropic` for Messages API endpoints.
58+
- Anthropic-compatible configurations require a positive `maxTokens` value.
5659
- `toolPolicy` is required. There is no default file or shell access.
5760
- `headers` is optional for extra gateway-specific request headers.
5861
- `model` may be any non-empty provider-specific model id.
5962

63+
### MiniMax
64+
65+
The gateway model catalog includes `MiniMax-M3` and `MiniMax-M2.7`. Choose the
66+
region and protocol with the matching base URL:
67+
68+
| Region | Protocol | Base URL |
69+
| ------ | ----------- | ------------------------------------ |
70+
| Global | `openai` | `https://api.minimax.io/v1` |
71+
| Global | `anthropic` | `https://api.minimax.io/anthropic` |
72+
| China | `openai` | `https://api.minimaxi.com/v1` |
73+
| China | `anthropic` | `https://api.minimaxi.com/anthropic` |
74+
75+
Example Anthropic-compatible settings:
76+
77+
```json
78+
{
79+
"providerSettings": {
80+
"gateway": {
81+
"protocol": "anthropic",
82+
"baseUrl": "https://api.minimax.io/anthropic",
83+
"apiKey": "your-api-key",
84+
"model": "MiniMax-M3",
85+
"maxTokens": 8192,
86+
"toolPolicy": {
87+
"roots": ["/absolute/path/to/worktree"],
88+
"commands": ["node"]
89+
}
90+
}
91+
}
92+
}
93+
```
94+
95+
Pass the Anthropic base URL exactly as shown. The bundled client appends
96+
`/v1/messages` for each request. For OpenAI-compatible settings, use
97+
`"protocol": "openai"` and omit `maxTokens` unless the endpoint needs a custom
98+
limit.
99+
60100
## Model Levels
61101

62102
Zeroshot uses provider-agnostic levels:

src/agent-cli-provider/adapters/gateway.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { classifyBaseProviderError, commandSpec, createParserState, envRedaction
1818
import { resolveGatewayConfiguration, validateGatewaySettings } from '../gateway-tools';
1919
import { getBoolean, getString, isRecord, tryParseJson } from '../json';
2020

21-
const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {};
21+
const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
22+
'MiniMax-M3': { rank: 3 },
23+
'MiniMax-M2.7': { rank: 2 },
24+
};
2225

2326
const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
2427
level1: { rank: 1, model: null },
@@ -27,10 +30,12 @@ const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
2730
};
2831

2932
export const gatewaySettingsDefaults: Readonly<Record<string, unknown>> = Object.freeze({
33+
protocol: 'openai',
3034
baseUrl: null,
3135
apiKey: null,
3236
headers: null,
3337
model: null,
38+
maxTokens: null,
3439
toolPolicy: null,
3540
});
3641

@@ -52,8 +57,10 @@ function buildCommand(context: string, options: BuildProviderCommandOptions = {}
5257
context,
5358
cwd,
5459
gateway: {
60+
protocol: gateway.protocol,
5561
baseUrl: gateway.baseUrl,
5662
model: gateway.model,
63+
...(gateway.maxTokens === undefined ? {} : { maxTokens: gateway.maxTokens }),
5764
toolPolicy: gateway.toolPolicy,
5865
},
5966
...(Object.keys(headerEnv.mapping).length === 0 ? {} : { gatewayHeaderEnv: headerEnv.mapping }),
@@ -160,7 +167,7 @@ function classifyError(error: unknown): ErrorClassification {
160167
/\bmust be a valid url\b/i,
161168
/\btoolpolicy\b/i,
162169
/\bnon-empty model identifier\b/i,
163-
/\bgateway\.(?:baseUrl|apiKey|model|toolPolicy)\b/i,
170+
/\bgateway\.(?:protocol|baseUrl|apiKey|model|maxTokens|toolPolicy)\b/i,
164171
]
165172
);
166173
}

src/agent-cli-provider/gateway-client.ts

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getArray, getRecord, getString, isRecord, unknownToMessage } from './json';
2+
import type { GatewayProtocol } from './types';
23

34
export interface GatewayChatToolDefinition {
45
readonly type: 'function';
@@ -18,13 +19,15 @@ export interface GatewayToolCall {
1819
export interface GatewayChatMessage {
1920
readonly role: 'system' | 'user' | 'assistant' | 'tool';
2021
readonly content: string;
22+
readonly anthropicContent?: readonly unknown[];
2123
readonly toolCalls?: readonly GatewayToolCall[];
2224
readonly toolCallId?: string;
2325
}
2426

2527
export interface GatewayChatResponse {
2628
readonly text: string;
2729
readonly toolCalls: readonly GatewayToolCall[];
30+
readonly anthropicContent?: readonly unknown[];
2831
}
2932

3033
class GatewayHttpError extends Error {
@@ -37,14 +40,32 @@ class GatewayHttpError extends Error {
3740
}
3841
}
3942

40-
export async function createGatewayChatCompletion(input: {
43+
interface GatewayChatCompletionInput {
44+
readonly protocol: GatewayProtocol;
4145
readonly baseUrl: string;
4246
readonly apiKey: string;
4347
readonly headers: Readonly<Record<string, string>>;
4448
readonly model: string;
49+
readonly maxTokens?: number;
4550
readonly messages: readonly GatewayChatMessage[];
4651
readonly tools: readonly GatewayChatToolDefinition[];
47-
}): Promise<GatewayChatResponse> {
52+
}
53+
54+
export function createGatewayChatCompletion(
55+
input: GatewayChatCompletionInput
56+
): Promise<GatewayChatResponse> {
57+
if (input.protocol === 'anthropic') {
58+
if (input.maxTokens === undefined) {
59+
throw new Error('Gateway Anthropic requests require maxTokens.');
60+
}
61+
return createAnthropicChatCompletion({ ...input, maxTokens: input.maxTokens });
62+
}
63+
return createOpenAIChatCompletion(input);
64+
}
65+
66+
async function createOpenAIChatCompletion(
67+
input: GatewayChatCompletionInput
68+
): Promise<GatewayChatResponse> {
4869
const response = await fetch(`${input.baseUrl}/chat/completions`, {
4970
method: 'POST',
5071
headers: {
@@ -54,7 +75,8 @@ export async function createGatewayChatCompletion(input: {
5475
},
5576
body: JSON.stringify({
5677
model: input.model,
57-
messages: input.messages.map((message) => serializeMessage(message)),
78+
...(input.maxTokens === undefined ? {} : { max_tokens: input.maxTokens }),
79+
messages: input.messages.map((message) => serializeOpenAIMessage(message)),
5880
tools: input.tools,
5981
tool_choice: 'auto',
6082
temperature: 0,
@@ -85,7 +107,51 @@ export async function createGatewayChatCompletion(input: {
85107
};
86108
}
87109

88-
function serializeMessage(message: GatewayChatMessage): Record<string, unknown> {
110+
async function createAnthropicChatCompletion(
111+
input: GatewayChatCompletionInput & { readonly maxTokens: number }
112+
): Promise<GatewayChatResponse> {
113+
const system = input.messages
114+
.filter((message) => message.role === 'system')
115+
.map((message) => message.content)
116+
.join('\n\n');
117+
const response = await fetch(`${input.baseUrl}/v1/messages`, {
118+
method: 'POST',
119+
headers: {
120+
'Content-Type': 'application/json',
121+
'x-api-key': input.apiKey,
122+
'anthropic-version': '2023-06-01',
123+
...input.headers,
124+
},
125+
body: JSON.stringify({
126+
model: input.model,
127+
max_tokens: input.maxTokens,
128+
...(system ? { system } : {}),
129+
messages: serializeAnthropicMessages(input.messages),
130+
tools: input.tools.map((tool) => ({
131+
name: tool.function.name,
132+
description: tool.function.description,
133+
input_schema: tool.function.parameters,
134+
})),
135+
}),
136+
});
137+
138+
const bodyText = await response.text();
139+
const parsed = tryParseJson(bodyText);
140+
if (!response.ok) {
141+
throw httpError(response.status, parsed ?? bodyText);
142+
}
143+
if (!isRecord(parsed)) {
144+
throw new Error('Gateway returned a non-JSON response.');
145+
}
146+
const anthropicContent = getArray(parsed, 'content');
147+
return {
148+
text: getAnthropicMessageText(parsed),
149+
toolCalls: getAnthropicToolCalls(parsed),
150+
anthropicContent,
151+
};
152+
}
153+
154+
function serializeOpenAIMessage(message: GatewayChatMessage): Record<string, unknown> {
89155
if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
90156
return {
91157
role: 'assistant',
@@ -113,6 +179,48 @@ function serializeMessage(message: GatewayChatMessage): Record<string, unknown>
113179
};
114180
}
115181

182+
function serializeAnthropicMessages(
183+
messages: readonly GatewayChatMessage[]
184+
): readonly Record<string, unknown>[] {
185+
const result: Record<string, unknown>[] = [];
186+
let pendingToolResults: Record<string, unknown>[] | undefined;
187+
for (const message of messages) {
188+
if (message.role === 'system') continue;
189+
if (message.role === 'tool') {
190+
const toolResult = {
191+
type: 'tool_result',
192+
tool_use_id: message.toolCallId,
193+
content: message.content,
194+
};
195+
if (pendingToolResults) {
196+
pendingToolResults.push(toolResult);
197+
} else {
198+
pendingToolResults = [toolResult];
199+
result.push({ role: 'user', content: pendingToolResults });
200+
}
201+
continue;
202+
}
203+
pendingToolResults = undefined;
204+
if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
205+
result.push({
206+
role: 'assistant',
207+
content: message.anthropicContent ?? [
208+
...(message.content ? [{ type: 'text', text: message.content }] : []),
209+
...message.toolCalls.map((toolCall) => ({
210+
type: 'tool_use',
211+
id: toolCall.id,
212+
name: toolCall.name,
213+
input: tryParseJson(toolCall.argumentsText) ?? {},
214+
})),
215+
],
216+
});
217+
continue;
218+
}
219+
result.push({ role: message.role, content: message.content });
220+
}
221+
return result;
222+
}
223+
116224
function getGatewayMessageText(message: Record<string, unknown>): string {
117225
const content = message.content;
118226
if (typeof content === 'string') return content;
@@ -143,6 +251,29 @@ function getGatewayToolCalls(message: Record<string, unknown>): readonly Gateway
143251
return result;
144252
}
145253

254+
function getAnthropicMessageText(message: Record<string, unknown>): string {
255+
return getArray(message, 'content')
256+
.filter((item) => isRecord(item) && getString(item, 'type') === 'text')
257+
.map((item) => (isRecord(item) ? getString(item, 'text') ?? '' : ''))
258+
.join('');
259+
}
260+
261+
function getAnthropicToolCalls(message: Record<string, unknown>): readonly GatewayToolCall[] {
262+
const result: GatewayToolCall[] = [];
263+
for (const item of getArray(message, 'content')) {
264+
if (!isRecord(item) || getString(item, 'type') !== 'tool_use') continue;
265+
const id = getString(item, 'id');
266+
const name = getString(item, 'name');
267+
if (!id || !name) continue;
268+
result.push({
269+
id,
270+
name,
271+
argumentsText: JSON.stringify(isRecord(item.input) ? item.input : {}),
272+
});
273+
}
274+
return result;
275+
}
276+
146277
function httpError(status: number, body: unknown): GatewayHttpError {
147278
return new GatewayHttpError(status, buildGatewayErrorMessage(status, body));
148279
}

src/agent-cli-provider/gateway-runner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,12 @@ async function runGatewayLoop(
164164

165165
for (let turn = 0; turn < MAX_GATEWAY_TURNS; turn += 1) {
166166
const response = await createGatewayChatCompletion({
167+
protocol: gateway.protocol,
167168
baseUrl: gateway.baseUrl,
168169
apiKey: gateway.apiKey,
169170
headers: gateway.headers,
170171
model: gateway.model,
172+
...(gateway.maxTokens === undefined ? {} : { maxTokens: gateway.maxTokens }),
171173
messages,
172174
tools: TOOL_DEFINITIONS,
173175
});
@@ -184,6 +186,9 @@ async function runGatewayLoop(
184186
messages.push({
185187
role: 'assistant',
186188
content: response.text,
189+
...(response.anthropicContent === undefined
190+
? {}
191+
: { anthropicContent: response.anthropicContent }),
187192
toolCalls: response.toolCalls,
188193
});
189194

0 commit comments

Comments
 (0)