Skip to content

Commit 760fe17

Browse files
committed
fix(core+desktop): cap Anthropic-proxy reasoning at medium; add test-provider
Two fixes: 1. reasoningForModel now accepts baseUrl. When provider is 'anthropic' but the baseUrl is not api.anthropic.com, we're talking to a proxy (Claude Code-style) that commonly gates reasoning by plan — only 'medium' is accepted on the consumer tier. Default for that case is now 'medium' instead of 'high'. Direct Anthropic API still gets 'high' for Claude 4.x. Also re-adds the 'claude-code-imported' case (kept getting dropped by retries) so the imported provider id also lands on 'medium'. Callers (generate + agent) now pass input.baseUrl through. 2. Adds the missing 'connection:v1:test-provider' IPC handler. The preload exposed testProvider(id) but main only registered test-active. Refactored resolveActiveCredentials into a shared resolveCredentialsForProvider(id) helper and factored the probe into runProviderTest() so both the active-only and by-id paths reuse it. Caveat: if a user manually picked 'high' via the Reasoning Depth dropdown, the per-provider override still wins (by design). Clearing the override via the "Default (auto)" option now lands on 'medium' when the baseUrl is a proxy.
1 parent 1008055 commit 760fe17

3 files changed

Lines changed: 94 additions & 44 deletions

File tree

apps/desktop/src/main/connection-ipc.ts

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -332,10 +332,11 @@ interface ActiveProviderCredentials {
332332
httpHeaders?: Record<string, string>;
333333
}
334334

335-
function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestError {
335+
function resolveCredentialsForProvider(
336+
providerId: string,
337+
): ActiveProviderCredentials | ConnectionTestError {
336338
const cfg = getCachedConfig();
337-
const active = cfg?.activeProvider;
338-
if (cfg === null || active === undefined || active.length === 0) {
339+
if (cfg === null || providerId.length === 0) {
339340
return {
340341
ok: false,
341342
code: 'IPC_BAD_INPUT',
@@ -344,32 +345,71 @@ function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestE
344345
};
345346
}
346347
const entry =
347-
cfg.providers[active] ??
348-
(isSupportedOnboardingProvider(active) ? BUILTIN_PROVIDERS[active] : undefined);
348+
cfg.providers[providerId] ??
349+
(isSupportedOnboardingProvider(providerId) ? BUILTIN_PROVIDERS[providerId] : undefined);
349350
if (entry === undefined) {
350351
return {
351352
ok: false,
352353
code: 'IPC_BAD_INPUT',
353-
message: `Provider "${active}" not found in config`,
354+
message: `Provider "${providerId}" not found in config`,
354355
hint: 'Re-add the provider from Settings',
355356
};
356357
}
357358
let apiKey: string;
358359
try {
359-
apiKey = getApiKeyForProvider(active);
360+
apiKey = getApiKeyForProvider(providerId);
360361
} catch {
361362
// No stored key — provider may be keyless (IP-whitelisted proxy).
362363
apiKey = '';
363364
}
364365
return {
365-
provider: active,
366+
provider: providerId,
366367
wire: entry.wire,
367368
apiKey,
368369
baseUrl: entry.baseUrl,
369370
...(entry.httpHeaders !== undefined ? { httpHeaders: entry.httpHeaders } : {}),
370371
};
371372
}
372373

374+
function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestError {
375+
const cfg = getCachedConfig();
376+
const active = cfg?.activeProvider;
377+
if (active === undefined || active.length === 0) {
378+
return {
379+
ok: false,
380+
code: 'IPC_BAD_INPUT',
381+
message: 'No active provider configured',
382+
hint: 'Complete onboarding first',
383+
};
384+
}
385+
return resolveCredentialsForProvider(active);
386+
}
387+
388+
async function runProviderTest(
389+
creds: ActiveProviderCredentials,
390+
): Promise<ConnectionTestResponse> {
391+
const { url } = buildEndpointForWire(creds.wire, creds.baseUrl);
392+
const headers = buildAuthHeadersForWire(creds.wire, creds.apiKey, creds.httpHeaders);
393+
394+
let res: Response;
395+
try {
396+
res = await fetchWithTimeout(url, { method: 'GET', headers });
397+
} catch (err) {
398+
const { code, hint } = classifyNetworkError(err);
399+
return {
400+
ok: false,
401+
code,
402+
message: err instanceof Error ? err.message : 'Network request failed',
403+
hint,
404+
};
405+
}
406+
if (!res.ok) {
407+
const { code, hint } = classifyHttpError(res.status);
408+
return { ok: false, code, message: `HTTP ${res.status}`, hint };
409+
}
410+
return { ok: true };
411+
}
412+
373413
export function registerConnectionIpc(): void {
374414
ipcMain.handle(
375415
'connection:v1:test',
@@ -494,30 +534,28 @@ export function registerConnectionIpc(): void {
494534
ipcMain.handle('connection:v1:test-active', async (): Promise<ConnectionTestResponse> => {
495535
const creds = resolveActiveCredentials();
496536
if (!('provider' in creds)) return creds;
497-
498-
const { url } = buildEndpointForWire(creds.wire, creds.baseUrl);
499-
const headers = buildAuthHeadersForWire(creds.wire, creds.apiKey, creds.httpHeaders);
500-
501-
let res: Response;
502-
try {
503-
res = await fetchWithTimeout(url, { method: 'GET', headers });
504-
} catch (err) {
505-
const { code, hint } = classifyNetworkError(err);
506-
return {
507-
ok: false,
508-
code,
509-
message: err instanceof Error ? err.message : 'Network request failed',
510-
hint,
511-
};
512-
}
513-
514-
if (!res.ok) {
515-
const { code, hint } = classifyHttpError(res.status);
516-
return { ok: false, code, message: `HTTP ${res.status}`, hint };
517-
}
518-
return { ok: true };
537+
return runProviderTest(creds);
519538
});
520539

540+
// Tests a specific provider by id — used by the per-row "Test connection"
541+
// button in Settings. Same probe as test-active but routed by id.
542+
ipcMain.handle(
543+
'connection:v1:test-provider',
544+
async (_e, raw: unknown): Promise<ConnectionTestResponse> => {
545+
if (typeof raw !== 'string' || raw.length === 0) {
546+
return {
547+
ok: false,
548+
code: 'IPC_BAD_INPUT',
549+
message: 'test-provider expects a provider id string',
550+
hint: 'Internal error — missing provider id',
551+
};
552+
}
553+
const creds = resolveCredentialsForProvider(raw);
554+
if (!('provider' in creds)) return creds;
555+
return runProviderTest(creds);
556+
},
557+
);
558+
521559
// Fetch available models for a stored provider by ID — credentials resolved
522560
// from the encrypted config so the renderer never touches plaintext keys.
523561
ipcMain.handle(

packages/core/src/agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,8 @@ export async function generateViaAgent(
716716
// precedence, then the model-family default from reasoningForModel. If
717717
// neither yields a value the agent runs with 'off', matching
718718
// pi-agent-core's default.
719-
const thinkingLevel = input.reasoningLevel ?? reasoningForModel(input.model) ?? 'off';
719+
const thinkingLevel =
720+
input.reasoningLevel ?? reasoningForModel(input.model, input.baseUrl) ?? 'off';
720721

721722
// Build the Agent. convertToLlm narrows AgentMessage (may include custom
722723
// types) to the LLM-visible Message subset.

packages/core/src/index.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
312312
log.info(`[${scope}] step=send_request`, ctx);
313313
const sendStart = Date.now();
314314
let result: GenerateResult;
315-
let reasoning = reasoningForModel(input.model);
315+
let reasoning = input.reasoningLevel ?? reasoningForModel(input.model, input.baseUrl);
316316
// Self-healing: if the upstream rejects on reasoning mismatch, flip the
317317
// knob once and retry. Handles new reasoning-mandatory models (and
318318
// not-supported models) without code changes.
@@ -531,16 +531,26 @@ const OPENROUTER_REASONING_MODEL_RE = new RegExp(
531531
'i',
532532
);
533533

534-
export function reasoningForModel(model: ModelRef): ReasoningLevel | undefined {
535-
// Whitelist by (provider, modelId) pair. Substring matches across providers
536-
// are unsafe: an OpenRouter or Groq pass-through id like
537-
// `anthropic/claude-4` or any third-party id containing "o1"/"r1" would
538-
// otherwise silently enable reasoning on a model that does not support it.
539-
// The whole point of this gate is to avoid silent fallbacks, so require
540-
// both axes to match a first-party provider we trust.
534+
export function reasoningForModel(
535+
model: ModelRef,
536+
baseUrl?: string | undefined,
537+
): ReasoningLevel | undefined {
538+
// Proxy detection: when the provider id is 'anthropic' but baseUrl points
539+
// somewhere other than api.anthropic.com, we're talking to a Claude Code-
540+
// style proxy. Those commonly gate reasoning by plan and consumer-tier
541+
// accepts only 'medium'. Cap defaults at 'medium' so requests don't 400
542+
// out of the gate; users on higher-tier proxies override via Settings →
543+
// Reasoning depth.
544+
const looksLikeAnthropicProxy =
545+
model.provider === 'anthropic' &&
546+
baseUrl !== undefined &&
547+
baseUrl.length > 0 &&
548+
!/(^|\/\/)api\.anthropic\.com($|[/:])/i.test(baseUrl);
549+
541550
switch (model.provider) {
542551
case 'anthropic':
543-
return CLAUDE_4_MODEL_RE.test(model.modelId) ? 'high' : undefined;
552+
if (!CLAUDE_4_MODEL_RE.test(model.modelId)) return undefined;
553+
return looksLikeAnthropicProxy ? 'medium' : 'high';
544554
case 'openai':
545555
return OPENAI_REASONING_MODEL_RE.test(model.modelId) ? 'high' : undefined;
546556
case 'openrouter':
@@ -549,11 +559,12 @@ export function reasoningForModel(model: ModelRef): ReasoningLevel | undefined {
549559
// — pi-ai may translate the knob differently across upstreams, and
550560
// 'medium' is a safer landing zone for unknown reasoning back-ends.
551561
return OPENROUTER_REASONING_MODEL_RE.test(model.modelId) ? 'medium' : undefined;
562+
case 'claude-code-imported':
563+
// Claude Code proxy endpoints gate reasoning tiers by plan — the
564+
// consumer-tier endpoint only accepts "medium". Sending "high" (or
565+
// letting pi-agent-core default up) yields a 400.
566+
return CLAUDE_4_MODEL_RE.test(model.modelId) ? 'medium' : undefined;
552567
default:
553-
// groq, cerebras, xai, mistral, bedrock, azure, vercel-ai-gateway:
554-
// all pass-through or multi-tenant. Even if they serve a reasoning model,
555-
// we cannot trust the model id alone, and pi-ai will silently drop or
556-
// mistranslate the reasoning knob. Stay conservative.
557568
return undefined;
558569
}
559570
}

0 commit comments

Comments
 (0)