Merge upstream cherry-picks from gsd-build/gsd-2 and badlogic/pi-mono

- ANTHROPIC_BASE_URL support for custom proxy endpoints (pi-ai anthropic provider)
- Codex unsupported model filtering (openai-codex OAuth provider)
- Bedrock thinking xhigh/max effort mapping for opus-4-7/opus-4-6
- Extended adaptive thinking to haiku-4-5, sonnet-4-6/4-7 (stream-adapter)
- hasLegacyOAuthCredential + removeLegacyOAuthCredential self-heal (auth-storage)
- Rate-limit regex extended with quota/reset patterns (error-classifier)
- setCurrentDispatchedModelId for rate-limit fallback tracking (auto)
- PARALLEL-BLOCKER sentinel in auto-artifact-paths / auto-dispatch / auto-recovery
- active_requirement_missing_owner downgraded to warning (doctor)
- claude-opus-4-7 model entries added to models.generated.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-18 13:57:21 +02:00
commit 1c4b289d89
21 changed files with 797 additions and 38 deletions

View file

@ -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",

View file

@ -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");
});
});

View file

@ -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<string, any> | undefined {

View file

@ -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<typeof resolveAnthropicBaseUrl>[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",
);
});

View file

@ -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")
);
}

View file

@ -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<typeof Anthropic> {
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(
{

View file

@ -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 = `<!doctype html>
<html lang="en">
@ -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)
));
},
};

View file

@ -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");
});
});

View file

@ -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

View file

@ -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<Parameters<ModelRegistry["registerProvider"]>[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 ────────────────────────────────────────────────────────────────

View file

@ -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);

View file

@ -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,
};

View file

@ -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,

View file

@ -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)`;

View file

@ -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",

View file

@ -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).

View file

@ -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);

View file

@ -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");

View file

@ -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,

View file

@ -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;

View file

@ -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", () => {