From 1914f31342132cef3e0bfd2eac071c2d2b2e07bd Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Tue, 14 Apr 2026 01:58:19 +0200 Subject: [PATCH 1/7] feat(pi-ai): support ANTHROPIC_BASE_URL env var for custom proxy endpoints (#4140) Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit e94cf668e2fdf28537aead642b4062cd3a22a8d3) --- .../src/providers/anthropic-auth.test.ts | 48 ++++++++++++++++++- packages/pi-ai/src/providers/anthropic.ts | 20 +++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/pi-ai/src/providers/anthropic-auth.test.ts b/packages/pi-ai/src/providers/anthropic-auth.test.ts index 19e201b0d..f95ebafab 100644 --- a/packages/pi-ai/src/providers/anthropic-auth.test.ts +++ b/packages/pi-ai/src/providers/anthropic-auth.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { usesAnthropicBearerAuth } from "./anthropic.js"; +import { usesAnthropicBearerAuth, resolveAnthropicBaseUrl } from "./anthropic.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -31,3 +31,49 @@ test("createClient routes Bearer-auth providers through authToken (#3783)", () = "Bearer-auth providers should send authToken instead", ); }); + +// Minimal model stub — only the field resolveAnthropicBaseUrl cares about. +const stubModel = { baseUrl: "https://api.anthropic.com" } as Parameters[0]; + +test("resolveAnthropicBaseUrl returns model.baseUrl when ANTHROPIC_BASE_URL is unset (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + delete process.env.ANTHROPIC_BASE_URL; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://api.anthropic.com"); +}); + +test("resolveAnthropicBaseUrl prefers ANTHROPIC_BASE_URL over model.baseUrl (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com"; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://proxy.example.com"); +}); + +test("resolveAnthropicBaseUrl ignores whitespace-only ANTHROPIC_BASE_URL (#4140)", (t) => { + const saved = process.env.ANTHROPIC_BASE_URL; + t.after(() => { + if (saved === undefined) delete process.env.ANTHROPIC_BASE_URL; + else process.env.ANTHROPIC_BASE_URL = saved; + }); + + process.env.ANTHROPIC_BASE_URL = " "; + assert.equal(resolveAnthropicBaseUrl(stubModel), "https://api.anthropic.com"); +}); + +test("createClient uses resolveAnthropicBaseUrl for all auth paths (#4140)", () => { + const source = readFileSync(join(__dirname, "..", "..", "src", "providers", "anthropic.ts"), "utf-8"); + const directUsages = (source.match(/baseURL:\s*model\.baseUrl/g) ?? []).length; + assert.equal(directUsages, 0, "createClient must not use model.baseUrl directly — use resolveAnthropicBaseUrl(model)"); + assert.ok( + source.includes("baseURL: resolveAnthropicBaseUrl(model)"), + "all createClient branches should pass baseURL through resolveAnthropicBaseUrl", + ); +}); diff --git a/packages/pi-ai/src/providers/anthropic.ts b/packages/pi-ai/src/providers/anthropic.ts index 375008ab4..93fea4555 100644 --- a/packages/pi-ai/src/providers/anthropic.ts +++ b/packages/pi-ai/src/providers/anthropic.ts @@ -25,6 +25,24 @@ import { export type { AnthropicEffort, AnthropicOptions }; export { extractRetryAfterMs }; +/** + * Resolve the base URL for Anthropic API requests. + * + * Resolution order: + * 1. ANTHROPIC_BASE_URL environment variable (if set and non-empty after trim) + * 2. model.baseUrl (from the model definition) + * + * This allows routing traffic through custom proxy endpoints (e.g. OpusMax, + * local mirrors, corporate gateways) without modifying model definitions. + */ +export function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">): string { + const envBaseUrl = process.env.ANTHROPIC_BASE_URL?.trim(); + if (envBaseUrl) { + return envBaseUrl; + } + return model.baseUrl; +} + let _AnthropicClass: typeof Anthropic | undefined; async function getAnthropicClass(): Promise { if (!_AnthropicClass) { @@ -75,7 +93,7 @@ async function createClient( const client = new AnthropicClass({ apiKey: null, authToken: apiKey, - baseURL: model.baseUrl, + baseURL: resolveAnthropicBaseUrl(model), dangerouslyAllowBrowser: true, defaultHeaders: mergeHeaders( { From b73763d9443be71bbec89bc332c4720ee920902c Mon Sep 17 00:00:00 2001 From: Yeon Gil Kang Date: Wed, 15 Apr 2026 11:23:02 +0900 Subject: [PATCH 2/7] fix(pi-ai): hide unsupported ChatGPT codex oauth models ChatGPT-backed Codex sign-in no longer exposes the removed 5.1/5.2 Codex variants. Filter those models from openai-codex OAuth so GSD stops surfacing selections that immediately fail while leaving API-key-backed OpenAI models available. (cherry picked from commit 1aedba583916826fc5c6129037f61e9802010e46) --- .../pi-ai/src/utils/oauth/openai-codex.ts | 15 ++++++++ .../src/core/model-registry-auth-mode.test.ts | 36 ++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/pi-ai/src/utils/oauth/openai-codex.ts b/packages/pi-ai/src/utils/oauth/openai-codex.ts index 8c5bd56bd..f506a2e5b 100644 --- a/packages/pi-ai/src/utils/oauth/openai-codex.ts +++ b/packages/pi-ai/src/utils/oauth/openai-codex.ts @@ -26,6 +26,14 @@ const TOKEN_URL = "https://auth.openai.com/oauth/token"; const REDIRECT_URI = "http://localhost:1455/auth/callback"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; +const CHATGPT_UNSUPPORTED_MODEL_IDS = new Set([ + "gpt-5.2-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1", + "gpt-5", +]); const SUCCESS_HTML = ` @@ -454,4 +462,11 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = { getApiKey(credentials: OAuthCredentials): string { return credentials.access; }, + + modifyModels(models) { + return models.filter((model) => ( + model.provider !== "openai-codex" + || !CHATGPT_UNSUPPORTED_MODEL_IDS.has(model.id) + )); + }, }; diff --git a/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts index c8c8774c5..7a4b6e3a6 100644 --- a/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts +++ b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { Api, Model, SimpleStreamOptions, Context, AssistantMessageEventStream } from "@singularity-forge/pi-ai"; import { getApiProvider } from "@singularity-forge/pi-ai"; -import type { AuthStorage } from "./auth-storage.js"; +import { AuthStorage, type AuthStorageData } from "./auth-storage.js"; import { ModelRegistry } from "./model-registry.js"; function createRegistry(hasAuthFn?: (provider: string) => boolean): ModelRegistry { @@ -18,6 +18,10 @@ function createRegistry(hasAuthFn?: (provider: string) => boolean): ModelRegistr return new ModelRegistry(authStorage, undefined); } +function createInMemoryRegistry(data: AuthStorageData = {}): ModelRegistry { + return new ModelRegistry(AuthStorage.inMemory(data), undefined); +} + function createProviderModel(id: string, api?: string): NonNullable[1]["models"]>[number] { return { id, @@ -389,6 +393,36 @@ describe("ModelRegistry authMode — getAvailable", () => { const available = registry.getAvailable(); assert.equal(available.length, 0); }); + + it("prunes Codex models removed from ChatGPT-backed openai-codex OAuth", () => { + const registry = createInMemoryRegistry({ + "openai-codex": { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + accountId: "acct_123", + }, + }); + + assert.equal(registry.find("openai-codex", "gpt-5.1-codex-max"), undefined); + assert.equal(registry.find("openai-codex", "gpt-5.1"), undefined); + assert.equal(findModel(registry, "openai-codex", "gpt-5.2-codex"), undefined); + assert.ok(registry.find("openai-codex", "gpt-5.4")); + assert.ok(findModel(registry, "openai-codex", "gpt-5.4")); + }); + + it("keeps API-backed OpenAI Codex-capable models available", () => { + const registry = createInMemoryRegistry({ + openai: { + type: "api_key", + key: "sk-test", + }, + }); + + assert.ok(registry.find("openai", "gpt-5.2-codex")); + assert.ok(findModel(registry, "openai", "gpt-5.2-codex")); + }); }); // ─── getApiKey ──────────────────────────────────────────────────────────────── From 3b23ef3d4b37b5ee4c94a4a2eba1f6b59b49a478 Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Sat, 18 Apr 2026 00:47:50 +0200 Subject: [PATCH 3/7] fix(pi-ai): wire thinking:{type} field and extend adaptive-thinking model coverage (#4392) Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 503e79070d198254661febad35a267ead487b7e1) --- .../src/providers/amazon-bedrock.test.ts | 172 ++++++++++++++++++ .../pi-ai/src/providers/amazon-bedrock.ts | 25 ++- .../pi-ai/src/providers/anthropic-shared.ts | 6 +- .../claude-code-cli/stream-adapter.ts | 56 +++++- .../tests/stream-adapter.test.ts | 54 ++++++ 5 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 packages/pi-ai/src/providers/amazon-bedrock.test.ts diff --git a/packages/pi-ai/src/providers/amazon-bedrock.test.ts b/packages/pi-ai/src/providers/amazon-bedrock.test.ts new file mode 100644 index 000000000..d00a49cdd --- /dev/null +++ b/packages/pi-ai/src/providers/amazon-bedrock.test.ts @@ -0,0 +1,172 @@ +/** + * TDD Red Phase — Bug #4392 / Pre-existing Bug #4352 + * + * `supportsAdaptiveThinking()` in amazon-bedrock.ts is missing opus-4-7, + * sonnet-4-7, and haiku-4-5. These tests FAIL until the bug is fixed. + * + * Related: #4392 (opus-4-7 adaptive thinking not recognised on Bedrock) + * #4352 (pre-existing: only opus-4-6 / sonnet-4-6 whitelisted) + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + supportsAdaptiveThinking, + mapThinkingLevelToEffort, + buildAdditionalModelRequestFields, + type BedrockOptions, +} from "./amazon-bedrock.js"; + +import type { Model } from "../types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeModel(id: string): Model<"bedrock-converse-stream"> { + return { + id, + name: id, + api: "bedrock-converse-stream", + provider: "amazon-bedrock" as any, + baseUrl: "", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + }; +} + +// --------------------------------------------------------------------------- +// supportsAdaptiveThinking — RED tests (#4392 / #4352) +// --------------------------------------------------------------------------- + +describe("supportsAdaptiveThinking — Bug #4392 / #4352: missing models", () => { + // These two already pass (regression guard): + it("returns true for opus-4-6 (hyphen, Bedrock ARN style)", () => { + assert.ok(supportsAdaptiveThinking("anthropic.claude-opus-4-6-20250514-v1:0")); + }); + + it("returns true for sonnet-4-6 (hyphen)", () => { + assert.ok(supportsAdaptiveThinking("anthropic.claude-sonnet-4-6-20250514-v1:0")); + }); + + // --- RED: the following FAIL because opus-4-7 / sonnet-4-7 / haiku-4-5 are missing --- + + it("[#4392] returns true for opus-4-7 (hyphen, Bedrock ARN style)", () => { + // FAILS: supportsAdaptiveThinking does not include 'opus-4-7' + assert.ok( + supportsAdaptiveThinking("anthropic.claude-opus-4-7-20250514-v1:0"), + "opus-4-7 should support adaptive thinking (bug #4392)", + ); + }); + + it("[#4392] returns true for opus-4-7 (dot separator)", () => { + // FAILS: supportsAdaptiveThinking does not include 'opus-4.7' + assert.ok( + supportsAdaptiveThinking("anthropic.claude-opus-4.7-20250514-v1:0"), + "opus-4.7 (dot) should support adaptive thinking (bug #4392)", + ); + }); + + it("[#4352] returns true for sonnet-4-7 (hyphen)", () => { + // FAILS: supportsAdaptiveThinking does not include 'sonnet-4-7' + assert.ok( + supportsAdaptiveThinking("anthropic.claude-sonnet-4-7-20250514-v1:0"), + "sonnet-4-7 should support adaptive thinking (bug #4352)", + ); + }); + + it("[#4352] returns true for haiku-4-5 (hyphen)", () => { + // FAILS: supportsAdaptiveThinking does not include 'haiku-4-5' + assert.ok( + supportsAdaptiveThinking("anthropic.claude-haiku-4-5-20250514-v1:0"), + "haiku-4-5 should support adaptive thinking (bug #4352)", + ); + }); +}); + +// --------------------------------------------------------------------------- +// buildAdditionalModelRequestFields — adaptive thinking output for opus-4-7 +// Tests go through the public API surface to validate end-to-end behaviour. +// --------------------------------------------------------------------------- + +describe("buildAdditionalModelRequestFields — Bug #4392: opus-4-7 must use adaptive thinking", () => { + const options: BedrockOptions = { reasoning: "high" }; + + it("[#4392] opus-4-7 Bedrock ARN → thinking.type === 'adaptive' (not budget_tokens)", () => { + const model = makeModel("anthropic.claude-opus-4-7-20250514-v1:0"); + const fields = buildAdditionalModelRequestFields(model, options); + // FAILS: because supportsAdaptiveThinking returns false for opus-4-7, + // the function returns { thinking: { type: "enabled", budget_tokens: ... } } + assert.equal( + fields?.thinking?.type, + "adaptive", + "opus-4-7 should produce thinking.type='adaptive', not budget_tokens", + ); + }); + + it("[#4392] opus-4-7 dot separator → thinking.type === 'adaptive'", () => { + const model = makeModel("anthropic.claude-opus-4.7-20250514-v1:0"); + const fields = buildAdditionalModelRequestFields(model, options); + assert.equal( + fields?.thinking?.type, + "adaptive", + "opus-4.7 (dot) should produce thinking.type='adaptive'", + ); + }); + + it("[#4352] sonnet-4-7 → thinking.type === 'adaptive'", () => { + const model = makeModel("anthropic.claude-sonnet-4-7-20250514-v1:0"); + const fields = buildAdditionalModelRequestFields(model, options); + assert.equal( + fields?.thinking?.type, + "adaptive", + "sonnet-4-7 should produce thinking.type='adaptive'", + ); + }); + + it("[#4352] haiku-4-5 → thinking.type === 'adaptive'", () => { + const model = makeModel("anthropic.claude-haiku-4-5-20250514-v1:0"); + const fields = buildAdditionalModelRequestFields(model, options); + assert.equal( + fields?.thinking?.type, + "adaptive", + "haiku-4-5 should produce thinking.type='adaptive'", + ); + }); +}); + +// --------------------------------------------------------------------------- +// mapThinkingLevelToEffort — RED test for xhigh on opus-4-7 +// The Bedrock version returns "max" (dead code path at line 411), whereas +// the correct value is "xhigh" (as implemented in anthropic-shared.ts). +// --------------------------------------------------------------------------- + +describe("mapThinkingLevelToEffort — Bug #4392: opus-4-7 xhigh should return 'xhigh' not 'max'", () => { + it("[#4392] maps xhigh → 'xhigh' for opus-4-7 (native xhigh support)", () => { + // FAILS: current code returns "max" for opus-4-7 at line 411, + // and in any case this code path is unreachable because + // supportsAdaptiveThinking returns false for opus-4-7. + // After the fix, supportsAdaptiveThinking will return true AND + // mapThinkingLevelToEffort must return "xhigh" (not "max"). + const result = mapThinkingLevelToEffort("xhigh", "anthropic.claude-opus-4-7-20250514-v1:0"); + assert.equal( + result, + "xhigh", + "opus-4-7 supports native xhigh effort — must not be clamped to 'max'", + ); + }); + + it("[#4392] maps xhigh → 'max' for opus-4-6 (no native xhigh, clamped)", () => { + // This already passes — regression guard. + const result = mapThinkingLevelToEffort("xhigh", "anthropic.claude-opus-4-6-20250514-v1:0"); + assert.equal(result, "max"); + }); + + it("maps high → 'high' for opus-4-7 (not affected by bug)", () => { + const result = mapThinkingLevelToEffort("high", "anthropic.claude-opus-4-7-20250514-v1:0"); + assert.equal(result, "high"); + }); +}); diff --git a/packages/pi-ai/src/providers/amazon-bedrock.ts b/packages/pi-ai/src/providers/amazon-bedrock.ts index dee0c363e..78f076e68 100644 --- a/packages/pi-ai/src/providers/amazon-bedrock.ts +++ b/packages/pi-ai/src/providers/amazon-bedrock.ts @@ -383,21 +383,29 @@ function handleContentBlockStop( } /** - * Check if the model supports adaptive thinking (Opus 4.6 and Sonnet 4.6). + * Check if the model supports adaptive thinking (Opus 4.6/4.7, Sonnet 4.6/4.7, Haiku 4.5). + * @internal exported for testing only */ -function supportsAdaptiveThinking(modelId: string): boolean { +export function supportsAdaptiveThinking(modelId: string): boolean { return ( modelId.includes("opus-4-6") || modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || modelId.includes("sonnet-4-6") || - modelId.includes("sonnet-4.6") + modelId.includes("sonnet-4.6") || + modelId.includes("sonnet-4-7") || + modelId.includes("sonnet-4.7") || + modelId.includes("haiku-4-5") || + modelId.includes("haiku-4.5") ); } -function mapThinkingLevelToEffort( +/** @internal exported for testing only */ +export function mapThinkingLevelToEffort( level: SimpleStreamOptions["reasoning"], modelId: string, -): "low" | "medium" | "high" | "max" { +): "low" | "medium" | "high" | "xhigh" | "max" { switch (level) { case "minimal": case "low": @@ -407,7 +415,9 @@ function mapThinkingLevelToEffort( case "high": return "high"; case "xhigh": - return modelId.includes("opus-4-6") || modelId.includes("opus-4.6") ? "max" : "high"; + if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) return "xhigh"; + if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) return "max"; + return "high"; default: return "high"; } @@ -688,7 +698,8 @@ function mapStopReason(reason: string | undefined): StopReason { } } -function buildAdditionalModelRequestFields( +/** @internal exported for testing only */ +export function buildAdditionalModelRequestFields( model: Model<"bedrock-converse-stream">, options: BedrockOptions, ): Record | undefined { diff --git a/packages/pi-ai/src/providers/anthropic-shared.ts b/packages/pi-ai/src/providers/anthropic-shared.ts index 567609147..187ebedf6 100644 --- a/packages/pi-ai/src/providers/anthropic-shared.ts +++ b/packages/pi-ai/src/providers/anthropic-shared.ts @@ -153,7 +153,11 @@ export function supportsAdaptiveThinking(modelId: string): boolean { modelId.includes("opus-4-6") || modelId.includes("opus-4.6") || modelId.includes("sonnet-4-6") || - modelId.includes("sonnet-4.6") + modelId.includes("sonnet-4.6") || + modelId.includes("sonnet-4-7") || + modelId.includes("sonnet-4.7") || + modelId.includes("haiku-4-5") || + modelId.includes("haiku-4.5") ); } diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index a22b33aff..211902f8b 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -18,7 +18,7 @@ import type { ToolCall, } from "@singularity-forge/pi-ai"; import type { ExtensionUIContext } from "@singularity-forge/pi-coding-agent"; -import { EventStream, mapThinkingLevelToEffort, supportsAdaptiveThinking } from "@singularity-forge/pi-ai"; +import { EventStream } from "@singularity-forge/pi-ai"; import { execSync } from "node:child_process"; import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js"; import { buildWorkflowMcpServers } from "../sf/workflow-mcp.js"; @@ -679,6 +679,42 @@ export async function resolveClaudePermissionMode( return "bypassPermissions"; } +// NOTE: These helpers intentionally mirror @singularity-forge/pi-ai anthropic-shared +// behavior so this extension remains typecheck-stable even when the published +// @singularity-forge/pi-ai barrel lags behind monorepo source exports. +function modelSupportsAdaptiveThinking(modelId: string): boolean { + return ( + modelId.includes("opus-4-6") + || modelId.includes("opus-4.6") + || modelId.includes("opus-4-7") + || modelId.includes("opus-4.7") + || modelId.includes("sonnet-4-6") + || modelId.includes("sonnet-4.6") + || modelId.includes("sonnet-4-7") + || modelId.includes("sonnet-4.7") + || modelId.includes("haiku-4-5") + || modelId.includes("haiku-4.5") + ); +} + +function mapThinkingLevelToAnthropicEffort(level: ThinkingLevel | undefined, modelId: string): "low" | "medium" | "high" | "xhigh" | "max" { + switch (level) { + case "minimal": + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) return "xhigh"; + if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6")) return "max"; + return "high"; + default: + return "high"; + } +} + /** * Build the options object passed to the Claude Agent SDK's `query()` call. * @@ -715,10 +751,21 @@ export function buildSdkOptions( "Bash(pwd)", ...(mcpServers ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) : []), ]; + const supportsAdaptive = modelSupportsAdaptiveThinking(modelId); const effort = - reasoning && supportsAdaptiveThinking(modelId) - ? mapThinkingLevelToEffort(reasoning, modelId) + reasoning && supportsAdaptive + ? mapThinkingLevelToAnthropicEffort(reasoning, modelId) : undefined; + + // Bug B: SDK requires thinking:{type:"adaptive"} alongside effort for adaptive thinking to activate. + // Bug C: SDK requires thinking:{type:"disabled"} to actually stop adaptive thinking when reasoning is off; + // omitting the field leaves the SDK in its adaptive default (or persisted session state). + const thinkingConfig = supportsAdaptive + ? effort + ? { thinking: { type: "adaptive" } } + : { thinking: { type: "disabled" } } + : undefined; + return { pathToClaudeCodeExecutable: getClaudePath(), model: modelId, @@ -732,7 +779,8 @@ export function buildSdkOptions( disallowedTools, ...(allowedTools.length > 0 ? { allowedTools } : {}), ...(mcpServers ? { mcpServers } : {}), - betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [], + betas: (modelId.includes("sonnet") || modelId.includes("opus-4-7") || modelId.includes("opus-4.7")) ? ["context-1m-2025-08-07"] : [], + ...(thinkingConfig ?? {}), ...(effort ? { effort } : {}), ...sdkExtraOptions, }; diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index b9943d78b..a702b559d 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -451,6 +451,60 @@ describe("stream-adapter — session persistence (#2859)", () => { assert.equal("effort" in options, false); }); + // --- Bug fixes #4392: thinking field & model coverage --- + + test("buildSdkOptions sets thinking disabled when reasoning is undefined on adaptive model (#4392)", () => { + // Bug C: thinkingLevel="off" means reasoning===undefined; SDK needs thinking:{type:"disabled"} + const options = buildSdkOptions("claude-sonnet-4-6", "test", undefined, {}); + assert.deepEqual( + (options as any).thinking, + { type: "disabled" }, + "thinking must be {type:'disabled'} when reasoning is undefined so SDK stops adaptive thinking", + ); + }); + + test("buildSdkOptions omits effort when reasoning is undefined (thinking disabled) (#4392)", () => { + // Bug C corollary: no effort when thinking is off + const options = buildSdkOptions("claude-sonnet-4-6", "test", undefined, {}); + assert.equal("effort" in options, false, "effort must not be set when reasoning is undefined"); + }); + + test("buildSdkOptions sets thinking adaptive when reasoning is provided (#4392)", () => { + // Bug B: when effort is set, thinking:{type:"adaptive"} must also be present + const options = buildSdkOptions("claude-opus-4-6", "test", undefined, { reasoning: "high" }); + assert.deepEqual( + (options as any).thinking, + { type: "adaptive" }, + "thinking must be {type:'adaptive'} alongside effort when reasoning is set", + ); + }); + + test("buildSdkOptions includes both effort and thinking.type=adaptive when reasoning is set (#4392)", () => { + // Bug B: both fields must be present together + const options = buildSdkOptions("claude-opus-4-6", "test", undefined, { reasoning: "high" }); + assert.equal(options.effort, "high", "effort must be set"); + assert.deepEqual((options as any).thinking, { type: "adaptive" }, "thinking must be adaptive"); + }); + + test("buildSdkOptions maps reasoning to effort for sonnet-4-7 (modelSupportsAdaptiveThinking #4392)", () => { + // Bug D: sonnet-4-7 was missing from modelSupportsAdaptiveThinking + const options = buildSdkOptions("claude-sonnet-4-7", "test", undefined, { reasoning: "high" }); + assert.equal(options.effort, "high", "sonnet-4-7 must support adaptive thinking and map effort"); + }); + + test("buildSdkOptions maps reasoning to effort for haiku-4-5 (modelSupportsAdaptiveThinking #4392)", () => { + // Bug D: haiku-4-5 was missing from modelSupportsAdaptiveThinking + const options = buildSdkOptions("claude-haiku-4-5", "test", undefined, { reasoning: "high" }); + assert.equal(options.effort, "high", "haiku-4-5 must support adaptive thinking and map effort"); + }); + + test("buildSdkOptions does not set thinking field for non-adaptive model when reasoning is undefined (#4392)", () => { + // Non-adaptive models (e.g. claude-sonnet-4-20250514) don't use the thinking API at all; + // no thinking field should be set when reasoning is undefined + const options = buildSdkOptions("claude-sonnet-4-20250514", "test", undefined, {}); + assert.equal("thinking" in options, false, "non-adaptive models must not receive a thinking field"); + }); + test("buildSdkOptions includes workflow MCP server config when env is set", () => { const prev = { SF_WORKFLOW_MCP_COMMAND: process.env.SF_WORKFLOW_MCP_COMMAND, From b5e1beff8e8a7a600d71590fbc398feb2280fca6 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 17 Apr 2026 10:59:49 -0500 Subject: [PATCH 4/7] fix(auth): self-heal stale Anthropic OAuth credential (#4399) Anthropic OAuth was removed in v2.74.0 for TOS compliance (#3952). Users who upgraded through that version still have type:"oauth" entries under `anthropic` in auth.json which cannot resolve to a valid API key. stale entry, so hasAuth("anthropic") kept reporting true and masked the claude-code fallback path. Users had to hand-edit auth.json to recover. Self-heal instead: - AuthStorage.removeLegacyOAuthCredential(provider) strips only type:"oauth" entries and preserves any api_key credentials. - sdk.ts getApiKey() calls it when the legacy-OAuth branch triggers, logs a one-line warning, and throws a message pointing the user at the "claude-code" provider when the `claude` binary is in PATH, or at ANTHROPIC_API_KEY otherwise. Closes #4399 (cherry picked from commit b8ef6604617fda239a037cf5d5e6020b168d2e62) --- .../src/core/auth-storage.test.ts | 83 +++++++++++++++++++ .../pi-coding-agent/src/core/auth-storage.ts | 35 ++++++++ packages/pi-coding-agent/src/core/sdk.ts | 41 +++++++++ 3 files changed, 159 insertions(+) diff --git a/packages/pi-coding-agent/src/core/auth-storage.test.ts b/packages/pi-coding-agent/src/core/auth-storage.test.ts index 646162f2b..fac250e5d 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.test.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.test.ts @@ -569,3 +569,86 @@ describe("AuthStorage — localhost baseUrl shortcut", () => { assert.equal(key, "sk-myproxy-key"); }); }); + +// ─── hasLegacyOAuthCredential (Anthropic OAuth removed in v2.74.0, #3952) ──── + +describe("AuthStorage — hasLegacyOAuthCredential (#4280)", () => { + it("returns true when anthropic has a type:oauth credential", () => { + const storage = inMemory({ + anthropic: { + type: "oauth", + access: "ya29.fake-access-token", + refresh: "1//fake-refresh-token", + expires: Date.now() + 3_600_000, + }, + }); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), true); + }); + + it("returns false when anthropic has an api_key credential", () => { + const storage = inMemory({ anthropic: makeKey("sk-ant-fake") }); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + }); + + it("returns false when anthropic has no credential at all", () => { + const storage = inMemory({}); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + }); + + it("returns false for a provider with a legitimate OAuth credential (e.g. github-copilot)", () => { + const storage = inMemory({ + "github-copilot": { + type: "oauth", + access: "gho_fake-token", + refresh: "ghr_fake-refresh", + expires: Date.now() + 28_800_000, + }, + }); + // hasLegacyOAuthCredential is intentionally provider-scoped — calling it + // for a provider that still supports OAuth (like github-copilot) is not + // expected in production, but the method must not explode. + assert.equal(storage.hasLegacyOAuthCredential("github-copilot"), true); + }); +}); + +// ─── removeLegacyOAuthCredential (self-heal for #3952 / #4368) ─────────────── + +describe("AuthStorage — removeLegacyOAuthCredential (#4368)", () => { + it("removes oauth entry and returns true when present", () => { + const storage = inMemory({ + anthropic: { + type: "oauth", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), true); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + assert.equal(storage.has("anthropic"), false); + }); + + it("returns false when no oauth entry exists", () => { + const storage = inMemory({ anthropic: makeKey("sk-ant-fake") }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), false); + assert.equal(storage.get("anthropic")?.type, "api_key"); + }); + + it("preserves api_key credentials alongside oauth entry", () => { + const storage = inMemory({ + anthropic: [ + makeKey("sk-ant-keep"), + { + type: "oauth", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + ], + }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), true); + const remaining = storage.getCredentialsForProvider("anthropic"); + assert.equal(remaining.length, 1); + assert.equal(remaining[0].type, "api_key"); + }); +}); diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index fb394b3a1..21f78fa42 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -465,6 +465,41 @@ export class AuthStorage { return false; } + /** + * Returns true if the stored credential for a provider is of type "oauth". + * Used to detect stale OAuth credentials for providers where OAuth has been + * removed (e.g. Anthropic, #3952) so callers can surface a targeted + * migration message instead of a generic cooldown error. + */ + hasLegacyOAuthCredential(provider: string): boolean { + return this.getCredentialsForProvider(provider).some((c) => c.type === "oauth"); + } + + /** + * Remove only oauth-type credentials for a provider, preserving any api_key + * entries. Used to self-heal stale OAuth credentials for providers where + * OAuth support has been removed (e.g. Anthropic, #3952) without destroying + * a user's valid API keys. Returns true if any oauth entries were removed. + */ + removeLegacyOAuthCredential(provider: string): boolean { + const existing = this.getCredentialsForProvider(provider); + const remaining = existing.filter((c) => c.type !== "oauth"); + if (remaining.length === existing.length) return false; + + if (remaining.length === 0) { + delete this.data[provider]; + this.persistProviderChange(provider, undefined); + } else { + const next = remaining.length === 1 ? remaining[0] : remaining; + this.data[provider] = next; + this.persistProviderChange(provider, next); + } + this.providerRoundRobinIndex.delete(provider); + this.credentialBackoff.delete(provider); + this.providerBackoff.delete(provider); + return true; + } + /** * Get all credentials (for passing to getOAuthApiKey). * Returns normalized format where each provider has a single credential diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 90258bcd8..35d2202be 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -1,5 +1,17 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; +/** + * Lightweight PATH scan for the `claude` binary — no subprocess, no network. + * Mirrors the check in src/resources/extensions/gsd/doctor-providers.ts so the + * legacy Anthropic OAuth self-heal path can only trigger when the user has a + * working Claude Code CLI to fall back to. + */ +function isClaudeCodeBinaryInPath(): boolean { + const pathDirs = (process.env.PATH ?? "").split(":"); + return pathDirs.some((dir) => dir && existsSync(join(dir, "claude"))); +} + /** * Structured error thrown when all credentials for a provider are in a * backoff window. Carries typed metadata so callers (e.g. the auto-loop) @@ -442,6 +454,35 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // the retry handler and creating cascading error entries (#3429). const hasAuth = modelRegistry.authStorage.hasAuth(resolvedProvider); if (hasAuth) { + // Anthropic OAuth was removed in v2.74.0 for TOS compliance (#3952). + // Users who upgraded from an older version may still have OAuth + // credentials in auth.json that will never resolve to a valid API key. + if ( + resolvedProvider === "anthropic" && + modelRegistry.authStorage.hasLegacyOAuthCredential(resolvedProvider) + ) { + // Self-heal: strip the stale oauth entry so hasAuth() stops lying + // about anthropic being configured. This preserves any api_key + // credentials alongside it. + const removed = modelRegistry.authStorage.removeLegacyOAuthCredential(resolvedProvider); + if (removed) { + console.warn( + `[auth] Removed unsupported Anthropic OAuth credential from auth.json (#3952).`, + ); + } + if (isClaudeCodeBinaryInPath()) { + throw new Error( + `Removed stale Anthropic OAuth credential (OAuth support removed in v2.74.0). ` + + `Your current model's provider is set to "anthropic" but the local Claude Code CLI ` + + `is available — switch the model's provider to "claude-code" in your preferences ` + + `to use it, or set ANTHROPIC_API_KEY to continue with the Anthropic API directly.`, + ); + } + throw new Error( + `Removed stale Anthropic OAuth credential (OAuth support removed in v2.74.0). ` + + `Set ANTHROPIC_API_KEY, run '/login' and paste an API key, or switch to a different provider.`, + ); + } const expiry = modelRegistry.authStorage.getEarliestBackoffExpiry(resolvedProvider); const retryAfterMs = expiry !== undefined ? Math.max(0, expiry - Date.now()) : undefined; throw new CredentialCooldownError(resolvedProvider, retryAfterMs); From 9af9c0712dd8b025cc11e1147f4798bdfe6abe0e Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 18 Apr 2026 13:42:46 +0200 Subject: [PATCH 5/7] fix(sf): handle auto-mode limit errors with model fallback (#4373) Cherry-pick of gsd-build/gsd-2@0b7a05491 adapted for sf/ paths: - Expand RATE_LIMIT_RE to cover quota-window phrasing (hit your limit, usage limit, quota reached) - Rate-limit errors bypass transient-deferral early return so model fallback executes - Add setCurrentDispatchedModelId() to keep AUTO dashboard label in sync after fallback switch - 4 regression tests for classifier coverage and structural guards Co-Authored-By: Claude Sonnet 4.6 --- src/resources/extensions/sf/auto.ts | 9 ++++++ .../sf/bootstrap/agent-end-recovery.ts | 29 +++++-------------- .../extensions/sf/error-classifier.ts | 3 +- .../sf/tests/provider-errors.test.ts | 29 +++++++++++++++++++ 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/resources/extensions/sf/auto.ts b/src/resources/extensions/sf/auto.ts index 7f6aecc8c..a314e96f8 100644 --- a/src/resources/extensions/sf/auto.ts +++ b/src/resources/extensions/sf/auto.ts @@ -466,6 +466,15 @@ export function getAutoModeStartModel(): { return s.autoModeStartModel; } +/** + * Update the dashboard-facing dispatched model label. + * Used when runtime recovery switches models mid-unit (e.g. provider fallback) + * so the AUTO box reflects the active model immediately. + */ +export function setCurrentDispatchedModelId(model: { provider: string; id: string } | null): void { + s.currentDispatchedModelId = model ? `${model.provider}/${model.id}` : null; +} + // Tool tracking — delegates to auto-tool-tracking.ts export function markToolStart(toolCallId: string, toolName?: string): void { _markToolStart(toolCallId, s.active, toolName); diff --git a/src/resources/extensions/sf/bootstrap/agent-end-recovery.ts b/src/resources/extensions/sf/bootstrap/agent-end-recovery.ts index b23799694..642fc45ee 100644 --- a/src/resources/extensions/sf/bootstrap/agent-end-recovery.ts +++ b/src/resources/extensions/sf/bootstrap/agent-end-recovery.ts @@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@singularity-forge/pi-codin import { logWarning } from "../workflow-logger.js"; import { checkAutoStartAfterDiscuss } from "../guided-flow.js"; -import { getAutoDashboardData, getAutoModeStartModel, isAutoActive, pauseAuto } from "../auto.js"; +import { getAutoDashboardData, getAutoModeStartModel, isAutoActive, pauseAuto, setCurrentDispatchedModelId } from "../auto.js"; import { getNextFallbackModel, resolveModelWithFallbacksForUnit, resolvePersistModelChanges } from "../preferences.js"; import { pauseAutoForProviderError } from "../provider-error-pause.js"; import { isSessionSwitchInFlight, resolveAgentEnd } from "../auto-loop.js"; @@ -125,26 +125,11 @@ export async function handleAgentEnd( // ── 1. Classify using rawErrorMsg to avoid prose false-positives ──── const cls = classifyError(rawErrorMsg, explicitRetryAfterMs); - // ── 1b. Defer to Core RetryHandler for transient errors ───────────── - // The Core RetryHandler (agent-session.ts) processes retryable errors - // AFTER this extension handler, in the same _processAgentEvent() call. - // For transient errors (overloaded, rate limit, server), the Core will - // retry in-context — same session, same conversation — which is strictly - // better than our Layer 2 pause+resume (which creates a new session). - // - // If we react here AND the Core also retries, we race: pauseAuto tears - // down the session while agent.continue() starts a new turn. - // - // Solution: Do nothing for transient errors. The Core RetryHandler - // runs next in _processAgentEvent and will either: - // a) Retry successfully → new agent_end (success) → we see it next time - // b) Exhaust retries → the agent stays idle, autoLoop's unit timeout - // or stuck detection handles it - // - // We do NOT call resolveAgentEnd here — that would unblock autoLoop - // prematurely while the Core is still retrying in the same session. - // We do NOT call pauseAuto — that would tear down the session. - if (isTransient(cls)) { + // ── 1b. Defer to Core RetryHandler for most transient errors ──────── + // Core retries transient failures in-session after this handler. + // Keep that behavior for non-rate-limit classes to avoid pause/retry races, + // but let rate-limit continue into model fallback logic below (#4373). + if (isTransient(cls) && cls.kind !== "rate-limit") { return; } @@ -203,6 +188,7 @@ export async function handleAgentEnd( if (modelToSet) { const ok = await pi.setModel(modelToSet, { persist: persistModelChanges }); if (ok) { + setCurrentDispatchedModelId({ provider: modelToSet.provider, id: modelToSet.id }); ctx.ui.notify(`Model error${errorDetail}. Switched to fallback: ${nextModelId} and resuming.`, "warning"); pi.sendMessage({ customType: "sf-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true }); return; @@ -220,6 +206,7 @@ export async function handleAgentEnd( if (startModel) { const ok = await pi.setModel(startModel, { persist: persistModelChanges }); if (ok) { + setCurrentDispatchedModelId({ provider: startModel.provider, id: startModel.id }); retryState.networkRetryCount = 0; retryState.currentRetryModelId = undefined; ctx.ui.notify(`Model error${errorDetail}. Restored session model: ${sessionModel.provider}/${sessionModel.id} and resuming.`, "warning"); diff --git a/src/resources/extensions/sf/error-classifier.ts b/src/resources/extensions/sf/error-classifier.ts index 19b788407..ebe38a2b3 100644 --- a/src/resources/extensions/sf/error-classifier.ts +++ b/src/resources/extensions/sf/error-classifier.ts @@ -43,7 +43,8 @@ export function resetRetryState(state: RetryState): void { // ── Classification ────────────────────────────────────────────────────────── const PERMANENT_RE = /auth|unauthorized|forbidden|invalid.*key|invalid.*api|billing|quota exceeded|account/i; -const RATE_LIMIT_RE = /rate.?limit|too many requests|429/i; +// Include provider-specific quota-window phrasing like "hit your limit", "usage limit", "quota reached" +const RATE_LIMIT_RE = /rate.?limit|too many requests|429|hit your limit|usage limit|quota (?:reached|hit)|limit.*resets?/i; // OpenRouter affordability-style quota errors should be treated as transient // so core retry logic can lower maxTokens and continue in-session. const AFFORDABILITY_RE = /requires more credits|can only afford|insufficient credits|not enough credits|fewer max_tokens/i; diff --git a/src/resources/extensions/sf/tests/provider-errors.test.ts b/src/resources/extensions/sf/tests/provider-errors.test.ts index 2ae75a380..17c65dbb6 100644 --- a/src/resources/extensions/sf/tests/provider-errors.test.ts +++ b/src/resources/extensions/sf/tests/provider-errors.test.ts @@ -32,6 +32,19 @@ test("classifyError detects rate limit from message", () => { assert.equal(result.kind, "rate-limit"); }); +test("classifyError treats Anthropic quota-window phrasing as transient rate-limit (#4373)", () => { + const result = classifyError("You've hit your limit · resets soon"); + assert.ok(isTransient(result)); + assert.equal(result.kind, "rate-limit"); + assert.ok("retryAfterMs" in result && result.retryAfterMs === 60_000); +}); + +test("classifyError treats usage-limit phrasing as transient rate-limit (#4373)", () => { + const result = classifyError("usage limit reached for this workspace"); + assert.ok(isTransient(result)); + assert.equal(result.kind, "rate-limit"); +}); + test("classifyError treats OpenRouter affordability errors as transient rate-limit class", () => { const result = classifyError( "402 This request requires more credits, or fewer max_tokens. You requested up to 32000 tokens, but can only afford 329.", @@ -455,6 +468,22 @@ test("agent-end-recovery.ts resumes transient provider pauses through startAuto ); }); +test("agent-end-recovery.ts does not defer rate-limit errors to core retry handler before fallback (#4373)", () => { + const src = readFileSync(join(__dirname, "..", "bootstrap", "agent-end-recovery.ts"), "utf-8"); + assert.ok( + src.includes('if (isTransient(cls) && cls.kind !== "rate-limit")'), + "rate-limit errors must bypass transient core-retry deferral so fallback can execute (#4373)", + ); +}); + +test("agent-end-recovery.ts updates dashboard dispatched model after fallback switch", () => { + const src = readFileSync(join(__dirname, "..", "bootstrap", "agent-end-recovery.ts"), "utf-8"); + assert.ok( + src.includes("setCurrentDispatchedModelId"), + "agent-end-recovery.ts should update currentDispatchedModelId when recovery switches model", + ); +}); + // ── Codex error extraction (#1166) ────────────────────────────────────────── test("openai-codex-responses.ts extracts nested error fields", () => { From 50a70b35bd473be66604d73075403be2707e00f2 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 18 Apr 2026 13:44:44 +0200 Subject: [PATCH 6/7] fix(sf): auto-mode stuck loop on research dispatch (#4414) Cherry-pick of gsd-build/gsd-2@80ae39ccd adapted for sf/ paths: - auto-artifact-paths: Add PARALLEL-BLOCKER sentinel path for parallel-research unitId - auto-dispatch: Skip re-dispatching parallel-research if PARALLEL-BLOCKER exists - auto-recovery: Add parallel-research verification; clear path/parse caches after writeBlockerPlaceholder - doctor: Downgrade active_requirement_missing_owner to warning (noisy during normal planning) Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/sf/auto-artifact-paths.ts | 13 ++++++ src/resources/extensions/sf/auto-dispatch.ts | 6 +++ src/resources/extensions/sf/auto-recovery.ts | 46 +++++++++++++++++++ src/resources/extensions/sf/doctor.ts | 7 ++- 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/sf/auto-artifact-paths.ts b/src/resources/extensions/sf/auto-artifact-paths.ts index 0d96bc968..dbfc3dc72 100644 --- a/src/resources/extensions/sf/auto-artifact-paths.ts +++ b/src/resources/extensions/sf/auto-artifact-paths.ts @@ -43,6 +43,16 @@ export function resolveExpectedArtifactPath( return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null; } case "research-slice": { + // #4414: Sentinel unitId "{mid}/parallel-research" fans out across + // multiple slices. Resolve to a milestone-level placeholder path so + // blocker escalation has somewhere to write. Verification for this + // sentinel is handled directly in verifyExpectedArtifact. + if (sid === "parallel-research") { + const mdir = resolveMilestonePath(base, mid); + return mdir + ? join(mdir, buildMilestoneFileName(mid, "PARALLEL-BLOCKER")) + : null; + } const dir = resolveSlicePath(base, mid, sid!); return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null; } @@ -109,6 +119,9 @@ export function diagnoseExpectedArtifact( case "plan-milestone": return `${relMilestoneFile(base, mid, "ROADMAP")} (milestone roadmap)`; case "research-slice": + if (sid === "parallel-research") { + return `${relMilestoneFile(base, mid, "PARALLEL-BLOCKER")} (parallel slice research sentinel)`; + } return `${relSliceFile(base, mid, sid!, "RESEARCH")} (slice research)`; case "plan-slice": return `${relSliceFile(base, mid, sid!, "PLAN")} (slice plan)`; diff --git a/src/resources/extensions/sf/auto-dispatch.ts b/src/resources/extensions/sf/auto-dispatch.ts index 075aaa25b..2d12a8bc1 100644 --- a/src/resources/extensions/sf/auto-dispatch.ts +++ b/src/resources/extensions/sf/auto-dispatch.ts @@ -420,6 +420,12 @@ export const DISPATCH_RULES: DispatchRule[] = [ // Only dispatch parallel if 2+ slices are ready if (researchReadySlices.length < 2) return null; + // #4414: If a previous parallel-research attempt escalated to a blocker + // placeholder, skip this rule and fall through to per-slice research + // (or other rules) rather than re-dispatching the same failing unit. + const parallelBlocker = resolveMilestoneFile(basePath, mid, "PARALLEL-BLOCKER"); + if (parallelBlocker) return null; + return { action: "dispatch", unitType: "research-slice", diff --git a/src/resources/extensions/sf/auto-recovery.ts b/src/resources/extensions/sf/auto-recovery.ts index d2d5b7ecb..4f83659b3 100644 --- a/src/resources/extensions/sf/auto-recovery.ts +++ b/src/resources/extensions/sf/auto-recovery.ts @@ -261,6 +261,45 @@ export function verifyExpectedArtifact( return true; } + // #4414: research-slice parallel-research sentinel. The unitId + // `{mid}/parallel-research` is not a real slice — it triggers a single agent + // that fans out research across multiple slices. Verify success by checking + // that every slice which was "research-ready" in the roadmap now has a + // RESEARCH file. Without this, resolveExpectedArtifactPath returns null and + // the retry/escalation machinery silently re-dispatches forever. + // + // NOTE: this predicate mirrors the dispatch rule at + // auto-dispatch.ts parallel-research-slices — keep the two in sync. + if (unitType === "research-slice" && unitId.endsWith("/parallel-research")) { + const { milestone: mid } = parseUnitId(unitId); + if (!mid) return false; + const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); + if (!roadmapFile || !existsSync(roadmapFile)) { + logWarning("recovery", `verify-fail ${unitType} ${unitId}: roadmap missing`); + return false; + } + try { + const roadmap = parseLegacyRoadmap(readFileSync(roadmapFile, "utf-8")); + const milestoneResearchFile = resolveMilestoneFile(base, mid, "RESEARCH"); + for (const slice of roadmap.slices) { + if (slice.done) continue; + if (milestoneResearchFile && slice.id === "S01") continue; + const depsComplete = (slice.depends ?? []).every((depId) => + !!resolveSliceFile(base, mid, depId, "SUMMARY"), + ); + if (!depsComplete) continue; + if (!resolveSliceFile(base, mid, slice.id, "RESEARCH")) { + logWarning("recovery", `verify-fail ${unitType} ${unitId}: slice ${slice.id} missing RESEARCH`); + return false; + } + } + return true; + } catch (err) { + logWarning("recovery", `parallel-research verification failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + } + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); // For unit types with no verifiable artifact (null path), the parent directory // is missing on disk — treat as stale completion state so the key gets evicted (#313). @@ -435,6 +474,13 @@ export function writeBlockerPlaceholder( ].join("\n"); writeFileSync(absPath, content, "utf-8"); + // #4414: Clear caches so subsequent dispatch guards (e.g. + // resolveMilestoneFile) see the placeholder file. Without this, the + // cached directory listing is stale and the dispatch rule re-fires, + // producing an infinite loop despite the placeholder being on disk. + clearPathCache(); + clearParseCache(); + // Mark the task/slice as complete in the DB so verifyExpectedArtifact passes. // Without this, the DB status stays "pending" and the dispatch loop // re-derives the same unit indefinitely (#2531, #2653). diff --git a/src/resources/extensions/sf/doctor.ts b/src/resources/extensions/sf/doctor.ts index 32c610cd0..a35d8e72d 100644 --- a/src/resources/extensions/sf/doctor.ts +++ b/src/resources/extensions/sf/doctor.ts @@ -172,8 +172,13 @@ function auditRequirements(content: string | null): DoctorIssue[] { const notes = block.match(/^-\s+Notes:\s+(.+)$/m)?.[1]?.trim().toLowerCase() ?? ""; if (status === "active" && (!owner || owner === "none" || owner === "none yet")) { + // #4414: Downgrade to warning. A newly-created requirement has + // primary_owner='' by default until the planning agent wires it to + // a slice via sf_requirement_update. Flagging as error during normal + // planning is noisy — the real failure is when it persists past + // milestone completion, which is covered by other audits. issues.push({ - severity: "error", + severity: "warning", code: "active_requirement_missing_owner", scope: "project", unitId: requirementId, From 830328da950418f037ebebfbf0fe16499f29b579 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 18 Apr 2026 13:46:30 +0200 Subject: [PATCH 7/7] feat(pi-ai): add claude-opus-4-7 model support (#4348) Cherry-pick of gsd-build/gsd-2@8f8187e23 adapted for our single-file models.generated.ts: - Amazon Bedrock: add anthropic.claude-opus-4-7, eu/global/us prefix variants - Google Antigravity: add claude-opus-4-7-thinking - OpenRouter: add anthropic/claude-opus-4.7 Co-Authored-By: Claude Sonnet 4.6 --- packages/pi-ai/src/models.generated.ts | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/pi-ai/src/models.generated.ts b/packages/pi-ai/src/models.generated.ts index cb775bf68..6a1bb8a76 100644 --- a/packages/pi-ai/src/models.generated.ts +++ b/packages/pi-ai/src/models.generated.ts @@ -243,6 +243,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "anthropic.claude-opus-4-7": { + id: "anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "anthropic.claude-opus-4-6-v1": { id: "anthropic.claude-opus-4-6-v1", name: "Claude Opus 4.6", @@ -396,6 +413,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "eu.anthropic.claude-opus-4-7": { + id: "eu.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (EU)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "eu.anthropic.claude-opus-4-6-v1": { id: "eu.anthropic.claude-opus-4-6-v1", name: "Claude Opus 4.6 (EU)", @@ -498,6 +532,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "global.anthropic.claude-opus-4-7": { + id: "global.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "global.anthropic.claude-opus-4-6-v1": { id: "global.anthropic.claude-opus-4-6-v1", name: "Claude Opus 4.6 (Global)", @@ -1331,6 +1382,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "us.anthropic.claude-opus-4-7": { + id: "us.anthropic.claude-opus-4-7", + name: "Claude Opus 4.7 (US)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "us.anthropic.claude-opus-4-6-v1": { id: "us.anthropic.claude-opus-4-6-v1", name: "Claude Opus 4.6 (US)", @@ -3550,6 +3618,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"google-gemini-cli">, + "claude-opus-4-7-thinking": { + id: "claude-opus-4-7-thinking", + name: "Claude Opus 4.7 Thinking (Antigravity)", + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"google-gemini-cli">, "claude-opus-4-6-thinking": { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking (Antigravity)", @@ -7046,6 +7131,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, + "anthropic/claude-opus-4.7": { + id: "anthropic/claude-opus-4.7", + name: "Anthropic: Claude Opus 4.7", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "anthropic/claude-opus-4": { id: "anthropic/claude-opus-4", name: "Anthropic: Claude Opus 4",