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
50 changes: 45 additions & 5 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
Zeroshot supports two provider shapes:

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

## Supported Providers

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

## Gateway Provider

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

Required settings:

```json
{
"providerSettings": {
"gateway": {
"protocol": "openai",
"baseUrl": "http://127.0.0.1:11434",
"apiKey": "gateway-key",
"model": "openrouter/meta-llama/test-model",
Expand All @@ -53,10 +54,49 @@ Required settings:

Notes:

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

### MiniMax

The gateway model catalog includes `MiniMax-M3` and `MiniMax-M2.7`. Choose the
region and protocol with the matching base URL:

| Region | Protocol | Base URL |
| ------ | ----------- | ------------------------------------ |
| Global | `openai` | `https://api.minimax.io/v1` |
| Global | `anthropic` | `https://api.minimax.io/anthropic` |
| China | `openai` | `https://api.minimaxi.com/v1` |
| China | `anthropic` | `https://api.minimaxi.com/anthropic` |

Example Anthropic-compatible settings:

```json
{
"providerSettings": {
"gateway": {
"protocol": "anthropic",
"baseUrl": "https://api.minimax.io/anthropic",
"apiKey": "your-api-key",
"model": "MiniMax-M3",
"maxTokens": 8192,
"toolPolicy": {
"roots": ["/absolute/path/to/worktree"],
"commands": ["node"]
}
}
}
}
```

Pass the Anthropic base URL exactly as shown. The bundled client appends
`/v1/messages` for each request. For OpenAI-compatible settings, use
`"protocol": "openai"` and omit `maxTokens` unless the endpoint needs a custom
limit.

## Model Levels

Zeroshot uses provider-agnostic levels:
Expand Down
11 changes: 9 additions & 2 deletions src/agent-cli-provider/adapters/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { classifyBaseProviderError, commandSpec, createParserState, envRedaction
import { resolveGatewayConfiguration, validateGatewaySettings } from '../gateway-tools';
import { getBoolean, getString, isRecord, tryParseJson } from '../json';

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

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

export const gatewaySettingsDefaults: Readonly<Record<string, unknown>> = Object.freeze({
protocol: 'openai',
baseUrl: null,
apiKey: null,
headers: null,
model: null,
maxTokens: null,
toolPolicy: null,
});

Expand All @@ -52,8 +57,10 @@ function buildCommand(context: string, options: BuildProviderCommandOptions = {}
context,
cwd,
gateway: {
protocol: gateway.protocol,
baseUrl: gateway.baseUrl,
model: gateway.model,
...(gateway.maxTokens === undefined ? {} : { maxTokens: gateway.maxTokens }),
toolPolicy: gateway.toolPolicy,
},
...(Object.keys(headerEnv.mapping).length === 0 ? {} : { gatewayHeaderEnv: headerEnv.mapping }),
Expand Down Expand Up @@ -160,7 +167,7 @@ function classifyError(error: unknown): ErrorClassification {
/\bmust be a valid url\b/i,
/\btoolpolicy\b/i,
/\bnon-empty model identifier\b/i,
/\bgateway\.(?:baseUrl|apiKey|model|toolPolicy)\b/i,
/\bgateway\.(?:protocol|baseUrl|apiKey|model|maxTokens|toolPolicy)\b/i,
]
);
}
Expand Down
131 changes: 127 additions & 4 deletions src/agent-cli-provider/gateway-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getArray, getRecord, getString, isRecord, unknownToMessage } from './json';
import type { GatewayProtocol } from './types';

export interface GatewayChatToolDefinition {
readonly type: 'function';
Expand Down Expand Up @@ -37,14 +38,32 @@ class GatewayHttpError extends Error {
}
}

export async function createGatewayChatCompletion(input: {
interface GatewayChatCompletionInput {
readonly protocol: GatewayProtocol;
readonly baseUrl: string;
readonly apiKey: string;
readonly headers: Readonly<Record<string, string>>;
readonly model: string;
readonly maxTokens?: number;
readonly messages: readonly GatewayChatMessage[];
readonly tools: readonly GatewayChatToolDefinition[];
}): Promise<GatewayChatResponse> {
}

export function createGatewayChatCompletion(
input: GatewayChatCompletionInput
): Promise<GatewayChatResponse> {
if (input.protocol === 'anthropic') {
if (input.maxTokens === undefined) {
throw new Error('Gateway Anthropic requests require maxTokens.');
}
return createAnthropicChatCompletion({ ...input, maxTokens: input.maxTokens });
}
return createOpenAIChatCompletion(input);
}

async function createOpenAIChatCompletion(
input: GatewayChatCompletionInput
): Promise<GatewayChatResponse> {
const response = await fetch(`${input.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Expand All @@ -54,7 +73,7 @@ export async function createGatewayChatCompletion(input: {
},
body: JSON.stringify({
model: input.model,
messages: input.messages.map((message) => serializeMessage(message)),
messages: input.messages.map((message) => serializeOpenAIMessage(message)),
tools: input.tools,
tool_choice: 'auto',
temperature: 0,
Expand Down Expand Up @@ -85,7 +104,49 @@ export async function createGatewayChatCompletion(input: {
};
}

function serializeMessage(message: GatewayChatMessage): Record<string, unknown> {
async function createAnthropicChatCompletion(
input: GatewayChatCompletionInput & { readonly maxTokens: number }
): Promise<GatewayChatResponse> {
const system = input.messages
.filter((message) => message.role === 'system')
.map((message) => message.content)
.join('\n\n');
const response = await fetch(`${input.baseUrl}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': input.apiKey,
'anthropic-version': '2023-06-01',
...input.headers,
},
body: JSON.stringify({
model: input.model,
max_tokens: input.maxTokens,
...(system ? { system } : {}),
messages: serializeAnthropicMessages(input.messages),
tools: input.tools.map((tool) => ({
name: tool.function.name,
description: tool.function.description,
input_schema: tool.function.parameters,
})),
}),
});

const bodyText = await response.text();
const parsed = tryParseJson(bodyText);
if (!response.ok) {
throw httpError(response.status, parsed ?? bodyText);
}
if (!isRecord(parsed)) {
throw new Error('Gateway returned a non-JSON response.');
}
return {
text: getAnthropicMessageText(parsed),
toolCalls: getAnthropicToolCalls(parsed),
};
}

function serializeOpenAIMessage(message: GatewayChatMessage): Record<string, unknown> {
if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
return {
role: 'assistant',
Expand Down Expand Up @@ -113,6 +174,45 @@ function serializeMessage(message: GatewayChatMessage): Record<string, unknown>
};
}

function serializeAnthropicMessages(
messages: readonly GatewayChatMessage[]
): readonly Record<string, unknown>[] {
const result: Record<string, unknown>[] = [];
for (const message of messages) {
if (message.role === 'system') continue;
if (message.role === 'tool') {
result.push({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: message.toolCallId,
content: message.content,
},
],
});
continue;
}
if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
result.push({
role: 'assistant',
content: [
...(message.content ? [{ type: 'text', text: message.content }] : []),
...message.toolCalls.map((toolCall) => ({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: tryParseJson(toolCall.argumentsText) ?? {},
})),
],
});
continue;
}
result.push({ role: message.role, content: message.content });
}
return result;
}

function getGatewayMessageText(message: Record<string, unknown>): string {
const content = message.content;
if (typeof content === 'string') return content;
Expand Down Expand Up @@ -143,6 +243,29 @@ function getGatewayToolCalls(message: Record<string, unknown>): readonly Gateway
return result;
}

function getAnthropicMessageText(message: Record<string, unknown>): string {
return getArray(message, 'content')
.filter((item) => isRecord(item) && getString(item, 'type') === 'text')
.map((item) => (isRecord(item) ? getString(item, 'text') ?? '' : ''))
.join('');
}

function getAnthropicToolCalls(message: Record<string, unknown>): readonly GatewayToolCall[] {
const result: GatewayToolCall[] = [];
for (const item of getArray(message, 'content')) {
if (!isRecord(item) || getString(item, 'type') !== 'tool_use') continue;
const id = getString(item, 'id');
const name = getString(item, 'name');
if (!id || !name) continue;
result.push({
id,
name,
argumentsText: JSON.stringify(isRecord(item.input) ? item.input : {}),
});
}
return result;
}

function httpError(status: number, body: unknown): GatewayHttpError {
return new GatewayHttpError(status, buildGatewayErrorMessage(status, body));
}
Expand Down
2 changes: 2 additions & 0 deletions src/agent-cli-provider/gateway-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,12 @@ async function runGatewayLoop(

for (let turn = 0; turn < MAX_GATEWAY_TURNS; turn += 1) {
const response = await createGatewayChatCompletion({
protocol: gateway.protocol,
baseUrl: gateway.baseUrl,
apiKey: gateway.apiKey,
headers: gateway.headers,
model: gateway.model,
...(gateway.maxTokens === undefined ? {} : { maxTokens: gateway.maxTokens }),
messages,
tools: TOOL_DEFINITIONS,
});
Expand Down
Loading