feat: add Anthropic fast mode support

This commit is contained in:
Peter Steinberger
2026-03-12 23:38:48 +00:00
parent 52e2a7747a
commit 35aafd7ca8
9 changed files with 271 additions and 1 deletions

View File

@@ -7,6 +7,14 @@ export type FastModeState = {
source: "session" | "config" | "default";
};
export function resolveFastModeParam(
extraParams: Record<string, unknown> | undefined,
): boolean | undefined {
return normalizeFastMode(
(extraParams?.fastMode ?? extraParams?.fast_mode) as string | boolean | null | undefined,
);
}
function resolveConfiguredFastModeRaw(params: {
cfg: OpenClawConfig | undefined;
provider: string;

View File

@@ -6,12 +6,16 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY ?? "";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const ANTHROPIC_LIVE =
isTruthyEnvValue(process.env.ANTHROPIC_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const GEMINI_LIVE =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
const describeAnthropicLive = ANTHROPIC_LIVE && ANTHROPIC_KEY ? describe : describe.skip;
const describeGeminiLive = GEMINI_LIVE && GEMINI_KEY ? describe : describe.skip;
describeLive("pi embedded extra params (live)", () => {
@@ -65,6 +69,79 @@ describeLive("pi embedded extra params (live)", () => {
// Should respect maxTokens from config (16) — allow a small buffer for provider rounding.
expect(outputTokens ?? 0).toBeLessThanOrEqual(20);
}, 30_000);
it("verifies OpenAI fast-mode service_tier semantics against the live API", async () => {
const headers = {
"content-type": "application/json",
authorization: `Bearer ${OPENAI_KEY}`,
};
const runProbe = async (serviceTier: "default" | "priority") => {
const res = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers,
body: JSON.stringify({
model: "gpt-5.4",
input: "Reply with OK.",
max_output_tokens: 32,
service_tier: serviceTier,
}),
});
const json = (await res.json()) as {
error?: { message?: string };
service_tier?: string;
status?: string;
};
expect(res.ok, json.error?.message ?? `HTTP ${res.status}`).toBe(true);
return json;
};
const standard = await runProbe("default");
expect(standard.service_tier).toBe("default");
expect(standard.status).toBe("completed");
const fast = await runProbe("priority");
expect(fast.service_tier).toBe("priority");
expect(fast.status).toBe("completed");
}, 45_000);
});
describeAnthropicLive("pi embedded extra params (anthropic live)", () => {
it("verifies Anthropic fast-mode service_tier semantics against the live API", async () => {
const headers = {
"content-type": "application/json",
"x-api-key": ANTHROPIC_KEY,
"anthropic-version": "2023-06-01",
};
const runProbe = async (serviceTier: "auto" | "standard_only") => {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers,
body: JSON.stringify({
model: "claude-sonnet-4-5",
max_tokens: 32,
service_tier: serviceTier,
messages: [{ role: "user", content: "Reply with OK." }],
}),
});
const json = (await res.json()) as {
error?: { message?: string };
stop_reason?: string;
usage?: { service_tier?: string };
};
expect(res.ok, json.error?.message ?? `HTTP ${res.status}`).toBe(true);
return json;
};
const standard = await runProbe("standard_only");
expect(standard.usage?.service_tier).toBe("standard");
expect(standard.stop_reason).toBe("end_turn");
const fast = await runProbe("auto");
expect(["standard", "priority"]).toContain(fast.usage?.service_tier);
expect(fast.stop_reason).toBe("end_turn");
}, 45_000);
});
describeGeminiLive("pi embedded extra params (gemini live)", () => {

View File

@@ -201,7 +201,8 @@ describe("applyExtraParamsToAgent", () => {
model:
| Model<"openai-responses">
| Model<"openai-codex-responses">
| Model<"openai-completions">;
| Model<"openai-completions">
| Model<"anthropic-messages">;
options?: SimpleStreamOptions;
cfg?: Record<string, unknown>;
extraParamsOverride?: Record<string, unknown>;
@@ -1683,6 +1684,91 @@ describe("applyExtraParamsToAgent", () => {
expect(payload.service_tier).toBe("default");
});
it("injects service_tier=auto for Anthropic fast mode on direct API-key models", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "anthropic",
applyModelId: "claude-sonnet-4-5",
extraParamsOverride: { fastMode: true },
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-5",
baseUrl: "https://api.anthropic.com",
} as unknown as Model<"anthropic-messages">,
payload: {},
});
expect(payload.service_tier).toBe("auto");
});
it("injects service_tier=standard_only for Anthropic fast mode off", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "anthropic",
applyModelId: "claude-sonnet-4-5",
extraParamsOverride: { fastMode: false },
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-5",
baseUrl: "https://api.anthropic.com",
} as unknown as Model<"anthropic-messages">,
payload: {},
});
expect(payload.service_tier).toBe("standard_only");
});
it("preserves caller-provided Anthropic service_tier values", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "anthropic",
applyModelId: "claude-sonnet-4-5",
extraParamsOverride: { fastMode: true },
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-5",
baseUrl: "https://api.anthropic.com",
} as unknown as Model<"anthropic-messages">,
payload: {
service_tier: "standard_only",
},
});
expect(payload.service_tier).toBe("standard_only");
});
it("does not inject Anthropic fast mode service_tier for OAuth auth", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "anthropic",
applyModelId: "claude-sonnet-4-5",
extraParamsOverride: { fastMode: true },
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-5",
baseUrl: "https://api.anthropic.com",
} as unknown as Model<"anthropic-messages">,
options: {
apiKey: "sk-ant-oat-test-token",
},
payload: {},
});
expect(payload).not.toHaveProperty("service_tier");
});
it("does not inject Anthropic fast mode service_tier for proxied base URLs", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "anthropic",
applyModelId: "claude-sonnet-4-5",
extraParamsOverride: { fastMode: true },
model: {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-sonnet-4-5",
baseUrl: "https://proxy.example.com/anthropic",
} as unknown as Model<"anthropic-messages">,
payload: {},
});
expect(payload).not.toHaveProperty("service_tier");
});
it("applies fast-mode defaults for openai-codex responses without service_tier", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai-codex",

View File

@@ -1,5 +1,6 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import { resolveFastModeParam } from "../fast-mode.js";
import {
requiresOpenAiCompatibleAnthropicToolPayload,
usesOpenAiFunctionAnthropicToolSchema,
@@ -18,6 +19,7 @@ const PI_AI_OAUTH_ANTHROPIC_BETAS = [
"oauth-2025-04-20",
...PI_AI_DEFAULT_ANTHROPIC_BETAS,
] as const;
type AnthropicServiceTier = "auto" | "standard_only";
type CacheRetention = "none" | "short" | "long";
@@ -53,6 +55,25 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
}
function isAnthropicPublicApiBaseUrl(baseUrl: unknown): boolean {
if (baseUrl == null) {
return true;
}
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return true;
}
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.anthropic.com";
} catch {
return baseUrl.toLowerCase().includes("api.anthropic.com");
}
}
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
return enabled ? "auto" : "standard_only";
}
function requiresAnthropicToolPayloadCompatibilityForModel(model: {
api?: unknown;
provider?: unknown;
@@ -304,6 +325,44 @@ export function createAnthropicToolPayloadCompatibilityWrapper(
};
}
export function createAnthropicFastModeWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
const serviceTier = resolveAnthropicFastServiceTier(enabled);
return (model, context, options) => {
if (
model.api !== "anthropic-messages" ||
model.provider !== "anthropic" ||
!isAnthropicPublicApiBaseUrl(model.baseUrl) ||
isAnthropicOAuthApiKey(options?.apiKey)
) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
}
return originalOnPayload?.(payload, model);
},
});
};
}
export function resolveAnthropicFastMode(
extraParams: Record<string, unknown> | undefined,
): boolean | undefined {
return resolveFastModeParam(extraParams);
}
export function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>

View File

@@ -5,9 +5,11 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicToolPayloadCompatibilityWrapper,
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
resolveAnthropicFastMode,
resolveAnthropicBetas,
resolveCacheRetention,
} from "./anthropic-stream-wrappers.js";
@@ -439,6 +441,12 @@ export function applyExtraParamsToAgent(
// upstream model-ID heuristics for Gemini 3.1 variants.
agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel);
const anthropicFastMode = resolveAnthropicFastMode(merged);
if (anthropicFastMode !== undefined) {
log.debug(`applying Anthropic fast mode=${anthropicFastMode} for ${provider}/${modelId}`);
agent.streamFn = createAnthropicFastModeWrapper(agent.streamFn, anthropicFastMode);
}
const openAIFastMode = resolveOpenAIFastMode(merged);
if (openAIFastMode) {
log.debug(`applying OpenAI fast mode for ${provider}/${modelId}`);