From 863f1a68e50d9c37f841d55c869ca6c6d0f9b942 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Thu, 15 Oct 2026 13:41:03 +0800 Subject: [PATCH] feat: support Google BYOK providers through Pi --- docs/examples.md | 11 ++ .../connectivity/test-connection.test.ts | 103 ++++++++++++++++++ .../connectivity/test-connection.ts | 68 +++++++++++- .../src/service/model-system/identity.ts | 1 + .../resolution/local-model-resolver.test.ts | 60 +++++++++- .../model-system/resolution/model-ref.ts | 2 + packages/shared/src/opencode-go-headers.ts | 4 +- packages/tui/src/provider/contract.ts | 1 + .../src/tui/features/provider/onboarding.ts | 1 + 9 files changed, 242 insertions(+), 9 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index ce7525613..22a3c83e4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 `, 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 diff --git a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.test.ts b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.test.ts index af46f6ddf..75873133a 100644 --- a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.test.ts @@ -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(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( + 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( + (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(); + } + }); +}); diff --git a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts index 0e4fffe97..db7a5cb69 100644 --- a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts +++ b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts @@ -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_` / // `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'; @@ -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>(); 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 @@ -56,10 +60,13 @@ export class ModelConnectionTester { } private async runTest(target: ModelConnectionTestTarget): Promise { - 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) { @@ -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' }; @@ -90,6 +97,55 @@ export class ModelConnectionTester { } } +async function testGoogle( + target: ModelConnectionTestTarget, + signal: AbortSignal, +): Promise { + 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, diff --git a/packages/local-runtime-v2/src/service/model-system/identity.ts b/packages/local-runtime-v2/src/service/model-system/identity.ts index 798772009..4d7d55dd0 100644 --- a/packages/local-runtime-v2/src/service/model-system/identity.ts +++ b/packages/local-runtime-v2/src/service/model-system/identity.ts @@ -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]; diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 50e6c42db..22370bec8 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -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({ @@ -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' } } }, @@ -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'); + }); +}); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts index f8ad44191..53089675e 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts @@ -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; diff --git a/packages/shared/src/opencode-go-headers.ts b/packages/shared/src/opencode-go-headers.ts index 616a3be69..67909a4a4 100644 --- a/packages/shared/src/opencode-go-headers.ts +++ b/packages/shared/src/opencode-go-headers.ts @@ -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; diff --git a/packages/tui/src/provider/contract.ts b/packages/tui/src/provider/contract.ts index 5c0c92e26..6e3c4e8ab 100644 --- a/packages/tui/src/provider/contract.ts +++ b/packages/tui/src/provider/contract.ts @@ -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]; diff --git a/packages/tui/src/tui/features/provider/onboarding.ts b/packages/tui/src/tui/features/provider/onboarding.ts index a3bed0bb3..f73b15d2b 100644 --- a/packages/tui/src/tui/features/provider/onboarding.ts +++ b/packages/tui/src/tui/features/provider/onboarding.ts @@ -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 =