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
11 changes: 11 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ pnpm mcode provider list --json

Providers created by `mcode provider add` are stored under `custom_provider` in the active profile's `config.yaml`. That tree is the supported home for any endpoint other than the official MiniMax API; `minimax_api` is reserved for the official endpoint and keeps its fixed authentication scheme.

Google-compatible endpoints use Pi's native Google adapter. Add the model manually with a base URL ending at the API version (not `/models`):

```bash
mcode provider add --name "OpenCode Go Gemini" \
--base-url https://opencode.ai/inference/go/v1 \
--api-format google-generative-ai --model gemini-4-argon-contributor \
--api-key-env OPENCODE_API_KEY --use
```

Google requests use `x-goog-api-key`. Model requests, tool calls and thinking parameters are handled by Pi.

Authentication follows the API format: `openai-completions` and `openai-responses` send `Authorization: Bearer <key>`, and `anthropic-messages` sends the key as `x-api-key`. A relay that expects Bearer authentication on an OpenAI-compatible endpoint therefore needs no extra configuration. If a relay only exposes an Anthropic-compatible endpoint (`/v1/messages`) and requires Bearer authentication, add explicit headers to the provider entry in `config.yaml`:

```yaml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,106 @@ describe('OpenCode Go connection identity', () => {
expect(headers[0]?.get('x-opencode-session')).not.toBe(headers[1]?.get('x-opencode-session'));
});
});

describe('Google connection tests through Pi', () => {
const target = {
api: 'google-generative-ai' as const,
apiKey: API_KEY,
baseUrl: 'https://opencode.ai/inference/go/v1',
modelId: 'gemini-4-argon-contributor',
outputLimit: 1024,
};

it.each([undefined, 'off', 'high'])('uses Pi with effort %s and the configured output limit', async (effort) => {
let request: Request | undefined;
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(async (input, init) => {
request = new Request(input, init);
return new Response(
`data: ${JSON.stringify({
candidates: [
{
content: { role: 'model', parts: [{ text: 'pong' }] },
finishReason: 'STOP',
},
],
})}\n\n`,
{ headers: { 'content-type': 'text/event-stream' } },
);
}),
);
try {
expect(await new ModelConnectionTester().test('google', { ...target, effort })).toEqual({
ok: true,
});
expect(request?.url).toBe(
`${target.baseUrl}/models/${target.modelId}:streamGenerateContent?alt=sse`,
);
expect(request?.headers.get('x-goog-api-key')).toBe(API_KEY);
expect(request?.headers.get('x-opencode-session')).toMatch(/^[0-9a-f-]{36}$/u);
expect(request?.headers.get('user-agent')).toContain('MiniMaxCode');
const body = await request?.json();
expect(body).toMatchObject({
contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
generationConfig: { maxOutputTokens: 1024 },
});
if (effort === 'high') {
expect(body.generationConfig.thinkingConfig).toMatchObject({ includeThoughts: true });
} else {
expect(body.generationConfig).not.toHaveProperty('thinkingConfig');
}
} finally {
vi.unstubAllGlobals();
}
});

it('redacts provider errors returned by Pi', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
error: {
code: 401,
message: `Invalid key ${API_KEY}`,
status: 'UNAUTHENTICATED',
},
}),
{ status: 401, headers: { 'content-type': 'application/json' } },
),
),
);
try {
const result = await new ModelConnectionTester().test('google-error', target);
expect(result).toMatchObject({ ok: false, errorCode: 'provider_error' });
expect(result.errorMessage).toContain('Invalid key');
expect(result.errorMessage).not.toContain(API_KEY);
} finally {
vi.unstubAllGlobals();
}
});

it('honors the caller timeout while Pi is waiting for Google', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(
(input, init) =>
new Promise((_resolve, reject) => {
const signal = new Request(input, init).signal;
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
),
);
try {
expect(
await new ModelConnectionTester({ timeoutMs: 50 }).test('google-timeout', target),
).toMatchObject({ ok: false, errorCode: 'timeout' });
} finally {
vi.unstubAllGlobals();
}
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// Minimal-completion connection tester for BYOK providers. Sends a single
// non-streaming ping with the model's configured output limit and normalizes
// ping with the model's configured output limit (Google uses Pi streaming) and normalizes
// the outcome to `unauthorized` / `timeout` / `network` / `http_<status>` /
// `provider_error` / `invalid_response`. Structured upstream failures retain
// their reason after removing configured secrets and common credential shapes.

import { completeSimple, type ThinkingLevel } from '@earendil-works/pi-ai';
import { withOpenCodeGoHeaders } from '@mavis/shared';

import { buildProviderHeaders, providerCompletionUrl } from './provider-request.js';
import {
isThinkingEffortDisabled,
resolveMiniMaxM3ThinkingProtocol,
resolveModelThinkingProtocol,
} from '../resolution/model-ref.js';
Expand All @@ -26,12 +30,12 @@ const MAX_UPSTREAM_ERROR_CHARS = 1_200;

export class ModelConnectionTester {
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
private readonly timeoutMs: number | undefined;
private readonly inFlight = new Map<string, Promise<ModelConnectionTestResult>>();

constructor(options: ModelConnectionTesterOptions = {}) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.timeoutMs = options.timeoutMs;
}

// One in-flight test per dedup key (provider or provider/model): concurrent
Expand All @@ -56,10 +60,13 @@ export class ModelConnectionTester {
}

private async runTest(target: ModelConnectionTestTarget): Promise<ModelConnectionTestResult> {
const { url, init } = buildTestRequest(target);
const timeoutMs =
this.timeoutMs ?? (target.api === 'google-generative-ai' ? 30_000 : DEFAULT_TIMEOUT_MS);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
if (target.api === 'google-generative-ai') return await testGoogle(target, controller.signal);
const { url, init } = buildTestRequest(target);
const response = await this.fetchImpl(url, { ...init, signal: controller.signal });
if (response.ok) return await validateSuccessResponse(response, target.api, target);
if (response.status === 401 || response.status === 403) {
Expand All @@ -80,7 +87,7 @@ export class ModelConnectionTester {
return {
ok: false,
errorCode: 'timeout',
errorMessage: `Request timed out after ${this.timeoutMs}ms`,
errorMessage: `Request timed out after ${timeoutMs}ms`,
};
}
return { ok: false, errorCode: 'network', errorMessage: 'Network error' };
Expand All @@ -90,6 +97,55 @@ export class ModelConnectionTester {
}
}

async function testGoogle(
target: ModelConnectionTestTarget,
signal: AbortSignal,
): Promise<ModelConnectionTestResult> {
const reasoning = isThinkingEffortDisabled(target.effort)
? undefined
: (target.effort as ThinkingLevel | undefined);
const result = await completeSimple(
{
id: target.modelId,
name: target.modelId,
provider: 'google',
api: 'google-generative-ai',
baseUrl: target.baseUrl,
reasoning: Boolean(reasoning),
input: ['text'],
contextWindow: 200_000,
maxTokens: resolveTestOutputLimit(target),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{ messages: [{ role: 'user', content: 'ping', timestamp: Date.now() }] },
{
apiKey: target.apiKey,
maxTokens: resolveTestOutputLimit(target),
signal,
reasoning,
headers: withOpenCodeGoHeaders(target.baseUrl, target.headers),
},
);
signal.throwIfAborted();
if (result.stopReason === 'error' || result.stopReason === 'aborted') {
return {
ok: false,
errorCode: 'provider_error',
errorMessage: redactConnectionTestError(
result.errorMessage ?? 'Model provider error',
target,
),
};
}
return result.content.length > 0
? { ok: true }
: {
ok: false,
errorCode: 'invalid_response',
errorMessage: 'Invalid response from model provider',
};
}

async function readUpstreamErrorReason(
response: Response,
target: ModelConnectionTestTarget,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const MODEL_PROVIDER_APIS = [
'anthropic-messages',
'openai-completions',
'openai-responses',
'google-generative-ai',
] as const;
export type ModelProviderApi = (typeof MODEL_PROVIDER_APIS)[number];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1243,7 +1243,7 @@ describe('Codex OAuth discovered models', () => {
});

describe('OpenCode Go conversation identity', () => {
it.each(['openai-completions', 'openai-responses', 'anthropic-messages'] as const)(
it.each(['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai'] as const)(
'keeps session identity across turns for renamed %s providers',
async (api) => {
const resolver = new LocalModelResolver({
Expand All @@ -1253,7 +1253,7 @@ describe('OpenCode Go conversation identity', () => {
api,
options: {
apiKey: 'offline-test-key',
baseURL: 'https://opencode.ai/zen/go/v1',
baseURL: 'https://opencode.ai/inference/go/v1',
headers: { 'USER-AGENT': 'generic-sdk' },
},
models: { 'test-model': { headers: { 'X-OpenCode-Session': 'static-id' } } },
Expand Down Expand Up @@ -1655,3 +1655,59 @@ describe('LocalModelResolver request image ceiling (#425)', () => {
).toBeUndefined();
});
});

describe('Google custom providers', () => {
it('preserves the Google API and delegates thinking parameters to Pi', async () => {
const config: LocalModelConfig = {
reasoning: true,
thinking: { effortOptions: ['low', 'high'] },
};
const resolver = new LocalModelResolver({
byokConfigGetter: () => ({
custom_provider: {
google: {
api: 'google-generative-ai',
options: {
apiKey: 'offline-key',
baseURL: 'https://example.com/v1beta/',
},
models: { 'gemini-3-pro-preview': config },
},
},
}),
});
const resolved = await resolver.resolveModel({
sessionId: 'google-session',
turnId: 'google-turn',
agentConfig: {
...AGENT_CONFIG,
model: modelRefForModel('custom_provider:google', 'gemini-3-pro-preview', config, {
thinking: { effort: 'high' },
}),
},
});
expect(resolved.model.api).toBe('google-generative-ai');
expect(resolved.model.baseUrl).toBe('https://example.com/v1beta');
expect(resolved.model.thinkingLevelMap).toBeUndefined();
expect(resolved.thinkingLevel).toBe('high');
let payload: unknown;
await streamSimple(
resolved.model,
{
messages: [{ role: 'user', content: 'ping', timestamp: 0 }],
},
{
apiKey: 'offline-key',
reasoning: resolved.thinkingLevel,
onPayload: (value) => {
payload = value;
throw new Error('stop before network');
},
},
).result();
expect(payload).toMatchObject({
config: { thinkingConfig: { thinkingLevel: 'HIGH' } },
});
expect(payload).not.toHaveProperty('reasoning');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ export function resolveModelThinkingProtocol(
value: unknown,
modelId?: unknown,
): ModelThinkingProtocolConfig | undefined {
// Pi owns Google's model-specific thinking parameters.
if (api === 'google-generative-ai') return undefined;
const effort = normalizeModelThinkingEffort(value);
if (!effort) return undefined;

Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/opencode-go-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ function isOpenCodeGoUrl(value: string | undefined): boolean {
return (
url.protocol === 'https:' &&
url.hostname === 'opencode.ai' &&
(url.pathname === '/zen/go' || url.pathname.startsWith('/zen/go/'))
['/zen/go', '/inference/go'].some(
(path) => url.pathname === path || url.pathname.startsWith(`${path}/`),
)
);
} catch {
return false;
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/provider/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const MCODE_PROVIDER_API_FORMATS = [
'anthropic-messages',
'openai-completions',
'openai-responses',
'google-generative-ai',
] as const;
export type McodeProviderApiFormat = (typeof MCODE_PROVIDER_API_FORMATS)[number];

Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/tui/features/provider/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const CUSTOM_FORMATS: readonly {
},
{ value: 'openai-responses', label: 'OpenAI Responses', description: '/responses' },
{ value: 'anthropic-messages', label: 'Anthropic Messages', description: '/v1/messages' },
{ value: 'google-generative-ai', label: 'Google Generative AI', description: '/models' },
];

type OnboardingMode =
Expand Down
Loading