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:
commit
1c4b289d89
21 changed files with 797 additions and 38 deletions
|
|
@ -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",
|
||||
|
|
|
|||
172
packages/pi-ai/src/providers/amazon-bedrock.test.ts
Normal file
172
packages/pi-ai/src/providers/amazon-bedrock.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue