diff --git a/src/commands/providers.ts b/src/commands/providers.ts index 68c22c7a2..5d31d4c68 100644 --- a/src/commands/providers.ts +++ b/src/commands/providers.ts @@ -7,9 +7,9 @@ import { listRecipes, getRecipe } from '../core/ai/recipes/index.ts'; import { configureGateway, embedOne, isAvailable as gwIsAvailable, chat as gwChat } from '../core/ai/gateway.ts'; +import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts'; import { probeOllama, probeLMStudio } from '../core/ai/probes.ts'; import { loadConfig } from '../core/config.ts'; -import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts'; import { AIConfigError, AITransientError } from '../core/ai/errors.ts'; import type { Recipe } from '../core/ai/types.ts'; @@ -173,8 +173,18 @@ async function runTest(args: string[]): Promise { // the divergence at the top of the test so the recovery experience // doesn't repeat the bug-reporter's "providers test ✓ but import still // broken" trap. + // + // #2863: `cfg` is lifted out of the try block (not just used for the + // warning) so the configureGateway calls below can reuse it. Before this + // fix, the --model override only forwarded embedding_model/chat_model + + // env, dropping config.provider_base_urls entirely — a probe against a + // custom endpoint (e.g. a regional DashScope base URL) would silently + // fall back to the recipe's hardcoded default endpoint and fail with a + // misleading "Incorrect API key" error even though the key was valid for + // the configured endpoint. + let cfg: ReturnType | null = null; try { - const cfg = loadConfig(); + cfg = loadConfig(); const configuredModel = tpArg === 'embedding' ? cfg?.embedding_model : cfg?.chat_model; if (!configuredModel) { console.error( @@ -190,17 +200,27 @@ async function runTest(args: string[]): Promise { } } catch { /* loadConfig throws when no brain configured — first-time install path; the no-config branch above handles it. */ } + // Reuse the SAME resolver the production path uses (buildGatewayConfig — + // also used by cli.ts#connectEngine and init-embed-check.ts) so the probe + // sees the identical base_urls / provider_chat_options / folded API keys + // that a real `gbrain import`/`gbrain query` call would. Only the + // touchpoint's model (+ embedding dims) is overridden on top, so an + // isolated `--model` probe still targets exactly the requested model — + // it just resolves that model's endpoint the way the brain actually + // would. Falls back to bare env when no brain is configured yet (cfg is + // null on first-time install, matching the old behavior for that case). + const baseGatewayConfig = cfg ? buildGatewayConfig(cfg) : { env: { ...process.env } }; if (tpArg === 'embedding') { const dims = recipe?.touchpoints.embedding?.default_dims ?? 1536; configureGateway({ + ...baseGatewayConfig, embedding_model: modelArg, embedding_dimensions: dims, - env: { ...process.env }, }); } else { configureGateway({ + ...baseGatewayConfig, chat_model: modelArg, - env: { ...process.env }, }); } void modelId; // intentionally unused but preserved for readability diff --git a/test/providers-test-model-base-url.test.ts b/test/providers-test-model-base-url.test.ts new file mode 100644 index 000000000..9442d3ad6 --- /dev/null +++ b/test/providers-test-model-base-url.test.ts @@ -0,0 +1,107 @@ +/** + * #2863 regression — `gbrain providers test --model` must resolve + * `provider_base_urls` the same way the production embed/chat path does. + * + * Before the fix, the `--model` override branch in `runTest` + * (src/commands/providers.ts) forwarded only `embedding_model`/`chat_model` + * + `env` into `configureGateway`, dropping `config.provider_base_urls` + * entirely. A brain configured with a custom (e.g. China-region DashScope) + * endpoint would pass `gbrain providers test --touchpoint embedding` (no + * `--model`, uses configureFromEnv() which DOES forward base_urls) but fail + * `gbrain providers test --touchpoint embedding --model + * dashscope:text-embedding-v3` with a misleading "Incorrect API key" error + * — the probe silently fell back to the recipe's hardcoded default endpoint + * (dashscope-intl.aliyuncs.com) instead of the configured one. + * + * This test drives the real `runProviders('test', ...)` CLI path end to end + * (loadConfig -> configureGateway -> gateway -> AI SDK -> fetch) and asserts + * on the actual HTTP request URL, so it fails on the pre-fix code and only + * passes once the --model override reuses buildGatewayConfig (the same + * resolver src/cli.ts#connectEngine and init-embed-check.ts use for the + * production path). + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runProviders } from '../src/commands/providers.ts'; +import { resetGateway } from '../src/core/ai/gateway.ts'; +import { withEnv } from './helpers/with-env.ts'; + +const CUSTOM_BASE_URL = 'https://llm-custom.cn-beijing.maas.example.test/compatible-mode/v1'; + +type FetchHandler = (url: string, init: RequestInit) => Promise; +let fetchHandler: FetchHandler | null = null; +const origFetch = globalThis.fetch; +let tmpHome: string; + +function okEmbeddingResponse(dims: number): Response { + const vec = Array(dims).fill(0).map((_, i) => 0.001 * i); + return new Response( + JSON.stringify({ data: [{ embedding: vec }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +beforeEach(() => { + fetchHandler = null; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + if (!fetchHandler) throw new Error('fetch called but no handler installed'); + return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {}); + }) as typeof fetch; + + tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-providers-test-base-url-')); + mkdirSync(join(tmpHome, '.gbrain'), { recursive: true }); + writeFileSync( + join(tmpHome, '.gbrain', 'config.json'), + JSON.stringify({ + embedding_model: 'dashscope:text-embedding-v3', + embedding_dimensions: 1024, + provider_base_urls: { dashscope: CUSTOM_BASE_URL }, + }), + ); +}); + +afterEach(() => { + globalThis.fetch = origFetch; + resetGateway(); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('providers test --model — provider_base_urls (#2863)', () => { + test('embedding touchpoint probe hits the configured custom base URL, not the recipe default', async () => { + let capturedUrl = ''; + fetchHandler = async (url) => { + capturedUrl = url; + return okEmbeddingResponse(1024); + }; + + await withEnv( + { GBRAIN_HOME: tmpHome, DASHSCOPE_API_KEY: 'test-dashscope-key' }, + async () => { + await runProviders('test', ['--touchpoint', 'embedding', '--model', 'dashscope:text-embedding-v3']); + }, + ); + + expect(capturedUrl.startsWith(CUSTOM_BASE_URL)).toBe(true); + expect(capturedUrl).not.toContain('dashscope-intl.aliyuncs.com'); + }); + + test('bare `providers test` (no --model) already used the custom base URL (control)', async () => { + let capturedUrl = ''; + fetchHandler = async (url) => { + capturedUrl = url; + return okEmbeddingResponse(1024); + }; + + await withEnv( + { GBRAIN_HOME: tmpHome, DASHSCOPE_API_KEY: 'test-dashscope-key' }, + async () => { + await runProviders('test', ['--touchpoint', 'embedding']); + }, + ); + + expect(capturedUrl.startsWith(CUSTOM_BASE_URL)).toBe(true); + }); +});