fix(models): hint the /v1 base-URL suffix when a doctor chat probe 401s on an openai-compatible proxy (#3553)

Co-Authored-By: Brett <brettdavies@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-08-01 07:40:48 +08:00
committed by Sina Matian
co-authored by Brett
parent 9ada48e600
commit 64ad743d98
2 changed files with 102 additions and 1 deletions
+62 -1
View File
@@ -34,6 +34,7 @@ import {
resolveModel,
type ModelTier,
} from '../core/model-config.ts';
import { resolveRecipe } from '../core/ai/model-resolver.ts';
const TIERS: ModelTier[] = ['utility', 'reasoning', 'deep', 'subagent'];
@@ -186,6 +187,44 @@ function classifyError(err: unknown): { status: ProbeStatus; message: string } {
return { status: 'unknown', message: msg };
}
const OPENAI_COMPAT_V1_HINT =
'If the API key is correct, the base URL may be missing the /v1 suffix. ' +
'OpenAI-shaped proxies (codex-proxy, Azure-OpenAI mirrors, LiteLLM fronting an OpenAI route) ' +
'serve /v1/chat/completions and 401 on the bare path. ' +
'Confirm with: `curl <base>/models` returns 200 with the same bearer, then append /v1 to the base URL.';
/**
* Fix-hint for the openai-compatible-proxy `/v1`-suffix trap.
*
* An OpenAI-shaped proxy whose base URL omits `/v1` (codex-proxy, some
* Azure-OpenAI mirrors, a LiteLLM proxy fronting an OpenAI-route backend)
* serves `/v1/chat/completions` and returns 401 on the bare `/chat/completions`
* the AI SDK appends to the base. `classifyError` reads that 401 as `auth` and
* points the operator at the bearer token, when the real fix is the URL shape.
*
* Returns the corrective hint only when the model routes through an
* openai-compatible recipe (proxy tier, not native anthropic/openai/google),
* `baseURL` is set, and `baseURL` does not already end in `/v1` (optionally with
* a trailing slash). Pure: recipe resolution is synchronous and does no
* network/engine work; any resolution failure returns undefined.
*
* @internal exported for tests.
*/
export function openAiCompatV1Hint(
modelStr: string,
baseURL: string | undefined | null,
): string | undefined {
if (!baseURL || !baseURL.trim()) return undefined;
if (/\/v1\/?$/.test(baseURL.trim())) return undefined;
try {
const { recipe } = resolveRecipe(modelStr);
if (recipe.tier !== 'openai-compat') return undefined;
return OPENAI_COMPAT_V1_HINT;
} catch {
return undefined;
}
}
/**
* Validate the configured embedding model + dims combo without spending tokens.
* Catches the bug class where a brain configured for Voyage with a missing or
@@ -523,7 +562,29 @@ async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion'): P
}
} catch (err) {
const { status, message } = classifyError(err);
return { model: modelStr, touchpoint, status, message, elapsed_ms: Date.now() - start };
const result: ProbeResult = { model: modelStr, touchpoint, status, message, elapsed_ms: Date.now() - start };
// An openai-compatible proxy whose base URL omits `/v1` returns 401 (not
// 404) on the bare `/chat/completions` path, which classifyError reads as
// `auth`. Attach the URL-shape hint so the operator doesn't chase the
// bearer token. Fail open: any error resolving the base URL yields no hint
// and never breaks the probe.
if (status === 'auth') {
try {
const { loadConfig } = await import('../core/config.ts');
const { buildGatewayConfig } = await import('../core/ai/build-gateway-config.ts');
const fileCfg = loadConfig();
if (fileCfg) {
const cfg = buildGatewayConfig(fileCfg);
const { recipe } = resolveRecipe(modelStr);
const baseURL = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default;
const hint = openAiCompatV1Hint(modelStr, baseURL);
if (hint) result.fix = hint;
}
} catch {
// fail open — no hint
}
}
return result;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, test, expect } from 'bun:test';
import { openAiCompatV1Hint } from '../src/commands/models.ts';
/**
* `gbrain models doctor` — the openai-compatible-proxy `/v1`-suffix hint.
*
* `openAiCompatV1Hint` is pure: it resolves the model's recipe synchronously
* (no network, no engine) to decide whether the provider is an openai-compatible
* proxy, then inspects the passed base URL. These cases pin the four branches
* without any transport stub.
*/
describe('openAiCompatV1Hint', () => {
test('openai-compat proxy without /v1 suffix returns a /v1 hint', () => {
const hint = openAiCompatV1Hint('litellm:gpt-4o', 'http://localhost:4000');
expect(hint).toBeDefined();
expect(hint).toContain('/v1');
});
test('base URL already ending in /v1 returns undefined', () => {
expect(openAiCompatV1Hint('litellm:gpt-4o', 'http://localhost:4000/v1')).toBeUndefined();
});
test('base URL ending in /v1/ (trailing slash) returns undefined', () => {
expect(openAiCompatV1Hint('litellm:gpt-4o', 'http://localhost:4000/v1/')).toBeUndefined();
});
test('native anthropic provider returns undefined', () => {
expect(openAiCompatV1Hint('anthropic:claude-sonnet-4-6', 'https://api.anthropic.com')).toBeUndefined();
});
test('native openai provider returns undefined', () => {
expect(openAiCompatV1Hint('openai:gpt-4o', 'https://api.openai.com')).toBeUndefined();
});
test('missing base URL returns undefined', () => {
expect(openAiCompatV1Hint('litellm:gpt-4o', undefined)).toBeUndefined();
expect(openAiCompatV1Hint('litellm:gpt-4o', null)).toBeUndefined();
expect(openAiCompatV1Hint('litellm:gpt-4o', '')).toBeUndefined();
});
});