Merge remote-tracking branch 'origin/main' into model-registry

This commit is contained in:
Mario Zechner
2026-06-10 18:49:18 +02:00
99 changed files with 3278 additions and 796 deletions
+24
View File
@@ -2,6 +2,30 @@
## [Unreleased]
- When Amazon Bedrock rejects an unsupported data retention mode, the error now links the AWS data retention documentation ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
### Fixed
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
## [0.79.1] - 2026-06-09
### Added
- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support.
### Fixed
- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)).
- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)).
- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)).
- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)).
- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)).
- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
## [0.79.0] - 2026-06-08
### Fixed
- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.78.1",
"version": "0.79.1",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
+25 -2
View File
@@ -92,7 +92,6 @@ const TOGETHER_TOGGLE_REASONING_EFFORT_COMPAT: OpenAICompletionsCompat = {
};
const TOGETHER_REASONING_ONLY_MODELS = new Set([
"deepseek-ai/DeepSeek-R1",
"MiniMaxAI/MiniMax-M2.5",
"MiniMaxAI/MiniMax-M2.7",
]);
const TOGETHER_REASONING_EFFORT_MODELS = new Set(["openai/gpt-oss-20b", "openai/gpt-oss-120b"]);
@@ -233,7 +232,8 @@ function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
modelId.includes("opus-4-8") ||
modelId.includes("opus-4.8") ||
modelId.includes("sonnet-4-6") ||
modelId.includes("sonnet-4.6")
modelId.includes("sonnet-4.6") ||
modelId.includes("fable-5")
);
}
@@ -295,6 +295,12 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
) {
mergeThinkingLevelMap(model, { xhigh: "xhigh" });
}
if (
(model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
model.id.includes("fable-5")
) {
mergeThinkingLevelMap(model, { off: null, xhigh: "xhigh" });
}
if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) {
mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true });
}
@@ -1058,6 +1064,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
if (api === "openai-completions") {
compat = { ...(compat ?? {}), maxTokensField: "max_tokens" };
}
models.push({
id: modelId,
name: m.name || modelId,
@@ -1216,6 +1226,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
thinkingFormat: "deepseek",
};
for (const { key, provider, baseUrl } of moonshotVariants) {
@@ -1355,6 +1366,11 @@ async function generateModels() {
candidate.contextWindow = 272000;
candidate.maxTokens = 128000;
}
// models.dev reports gpt-5-pro output as 272000 (a duplicate of the input sub-limit);
// the actual max output is 128000. Also propagates to the derived Azure clone.
if (candidate.provider === "openai" && candidate.id === "gpt-5-pro") {
candidate.maxTokens = 128000;
}
// Keep selected OpenRouter model metadata stable until upstream settles.
if (candidate.provider === "openrouter" && candidate.id === "moonshotai/kimi-k2.5") {
candidate.cost.input = 0.41;
@@ -2053,6 +2069,12 @@ async function generateModels() {
];
allModels.push(...vertexModels);
// Azure Foundry deploys these with larger context windows than OpenAI's own API,
// which caps gpt-5.4/gpt-5.5 at 272k. See models-sold-directly-by-azure docs.
const AZURE_CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
"gpt-5.4": 1050000,
"gpt-5.5": 1050000,
};
const azureOpenAiModels: Model<Api>[] = allModels
.filter((model) => model.provider === "openai" && model.api === "openai-responses")
.map((model) => ({
@@ -2060,6 +2082,7 @@ async function generateModels() {
api: "azure-openai-responses",
provider: "azure-openai-responses",
baseUrl: "",
contextWindow: AZURE_CONTEXT_WINDOW_OVERRIDES[model.id] ?? model.contextWindow,
}));
allModels.push(...azureOpenAiModels);
+30
View File
@@ -440,6 +440,36 @@ export const IMAGE_MODELS = {
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-pro": {
id: "sourceful/riverflow-v2.5-pro",
name: "Sourceful: Riverflow V2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
name: "xAI: Grok Imagine Image Quality",
File diff suppressed because it is too large Load Diff
+26 -8
View File
@@ -143,10 +143,13 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
// in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
// Region resolution: explicit option > env vars > SDK default chain.
// When AWS_PROFILE is set, we leave region undefined so the SDK can
// resovle it from aws profile configs. Otherwise fall back to us-east-1.
if (configuredRegion) {
// Region resolution: ARN-embedded > explicit option > env vars > SDK default chain.
// When the model ID is an inference profile ARN, extract the region from it.
// This avoids conflicts with AWS_REGION set for other services.
const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/);
if (arnRegionMatch) {
config.region = arnRegionMatch[1];
} else if (configuredRegion) {
config.region = configuredRegion;
} else if (endpointRegion && useExplicitEndpoint) {
config.region = endpointRegion;
@@ -287,6 +290,13 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
ServiceUnavailableException: "Service unavailable",
};
/**
* Some models reject the account/profile's configured Bedrock data retention mode
* (e.g. "data retention mode 'default' is not available for this model"). Point
* users at the AWS docs explaining how to configure a supported mode.
*/
const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html";
/**
* Format a Bedrock error with a human-readable prefix.
* AWS SDK exceptions (both from `client.send()` and from stream event items)
@@ -296,11 +306,14 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
*/
function formatBedrockError(error: unknown): string {
const message = error instanceof Error ? error.message : JSON.stringify(error);
const dataRetentionHint = /data retention mode/i.test(message)
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
: "";
if (error instanceof BedrockRuntimeServiceException) {
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
return `${prefix}: ${message}`;
return `${prefix}: ${message}${dataRetentionHint}`;
}
return message;
return `${message}${dataRetentionHint}`;
}
/**
@@ -525,13 +538,18 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[]
function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean {
const candidates = getModelMatchCandidates(modelId, modelName);
return candidates.some(
(s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("sonnet-4-6"),
(s) =>
s.includes("opus-4-6") ||
s.includes("opus-4-7") ||
s.includes("opus-4-8") ||
s.includes("sonnet-4-6") ||
s.includes("fable-5"),
);
}
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);
return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8"));
return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5"));
}
function mapThinkingLevelToEffort(
+3 -3
View File
@@ -200,7 +200,7 @@ export interface AnthropicOptions extends StreamOptions {
* Effort level for adaptive thinking models.
* Controls how much thinking Claude allocates:
* - "max": Always thinks with no constraints (Opus 4.6 only)
* - "xhigh": Highest reasoning level (Opus 4.7)
* - "xhigh": Highest reasoning level (Opus 4.7+, Fable 5)
* - "high": Always thinks, deep reasoning
* - "medium": Moderate thinking, may skip for simple queries
* - "low": Minimal thinking, skips for simple tasks
@@ -711,7 +711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
/**
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking.
* Note: effort "max" is only valid on Opus 4.6, while Opus 4.7 supports "xhigh".
* Note: effort "max" is only valid on Opus 4.6, while Opus 4.7+ and Fable 5 support "xhigh".
*/
function mapThinkingLevelToEffort(
model: Model<"anthropic-messages">,
@@ -972,7 +972,7 @@ function buildParams(
display,
};
}
} else if (options?.thinkingEnabled === false) {
} else if (options?.thinkingEnabled === false && model.thinkingLevelMap?.off !== null) {
params.thinking = { type: "disabled" };
}
}
@@ -256,6 +256,7 @@ function buildParams(
input: messages,
stream: true,
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
store: false,
};
if (options?.maxTokens) {
@@ -554,7 +554,8 @@ function buildParams(
}
if (compat.thinkingFormat === "zai" && model.reasoning) {
(params as any).enable_thinking = !!options?.reasoningEffort;
const zaiParams = params as typeof params & { thinking?: { type: "enabled" | "disabled" } };
zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
} else if (compat.thinkingFormat === "qwen" && model.reasoning) {
(params as any).enable_thinking = !!options?.reasoningEffort;
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
+1 -1
View File
@@ -426,7 +426,7 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
@@ -3,8 +3,11 @@ import { getModels, getProviders } from "../src/models.ts";
import type { Api, Model } from "../src/types.ts";
const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [
"anthropic/claude-fable-5",
"anthropic/claude-opus-4-8",
"opencode/claude-fable-5",
"opencode/claude-opus-4-8",
"vercel-ai-gateway/anthropic/claude-fable-5",
"vercel-ai-gateway/anthropic/claude-opus-4.8",
];
@@ -22,7 +25,7 @@ describe("Anthropic adaptive thinking model metadata", () => {
expect(flaggedModels).toEqual(expect.arrayContaining([...EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS].sort()));
expect(flaggedModels).toEqual(
flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6)/.test(modelId)),
flaggedModels.filter((modelId) => /(opus[-.]4[-.][678]|sonnet[-.]4[-.]6|fable[-.]5)/.test(modelId)),
);
});
});
@@ -83,6 +83,13 @@ describe("Anthropic forceAdaptiveThinking compat override", () => {
expect(payload.output_config).toEqual({ effort: "medium" });
});
it("uses adaptive thinking with native xhigh effort for Claude Fable 5", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), { reasoning: "xhigh" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => {
const model: Model<"anthropic-messages"> = {
...getModel("anthropic", "claude-opus-4-8"),
@@ -132,6 +132,13 @@ describe("Anthropic thinking disable payload", () => {
expect(payload.output_config).toBeUndefined();
});
it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"));
expect(payload.thinking).toBeUndefined();
expect(payload.output_config).toBeUndefined();
});
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" });
@@ -13,6 +13,7 @@ interface CapturedAzureClientOptions {
interface CapturedAzureResponsesPayload {
prompt_cache_key?: string;
store?: boolean;
}
const azureMock = vi.hoisted(() => ({
@@ -144,6 +145,16 @@ describe("azure-openai-responses base URL normalization", () => {
expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64));
});
it("disables server-side response storage", async () => {
const model = getModel("azure-openai-responses", "gpt-4o-mini");
await streamAzureOpenAIResponses(model, context, {
apiKey: "test-api-key",
azureBaseUrl: "https://my-resource.openai.azure.com",
}).result();
expect(azureMock.lastParams?.store).toBe(false);
});
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
const model = getModel("azure-openai-responses", "gpt-4o-mini");
@@ -128,4 +128,30 @@ describe("bedrock endpoint resolution", () => {
expect(config.endpoint).toBe("https://bedrock-vpc.example.com");
expect(config.region).toBe("us-west-2");
});
it("extracts region from inference profile ARN regardless of AWS_REGION", async () => {
process.env.AWS_REGION = "us-east-1";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123",
};
const config = await captureClientConfig(model);
expect(config.region).toBe("us-west-2");
});
it("extracts region from GovCloud inference profile ARN", async () => {
process.env.AWS_REGION = "us-east-1";
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
const model: Model<"bedrock-converse-stream"> = {
...baseModel,
id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123",
};
const config = await captureClientConfig(model);
expect(config.region).toBe("us-gov-west-1");
});
});
@@ -83,6 +83,25 @@ describe("Bedrock thinking payload", () => {
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
const payload = await capturePayload(model);
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
});
it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
const payload = await capturePayload(model, { reasoning: "xhigh" });
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
});
it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => {
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
const model: Model<"bedrock-converse-stream"> = {
@@ -1120,6 +1120,33 @@ describe("openai-completions tool_choice", () => {
expect(params.reasoning_effort).toBeUndefined();
});
it("sends max_tokens for OpenCode completions models", async () => {
const cases = [getModel("opencode-go", "kimi-k2.6")!, getModel("opencode", "grok-build-0.1")!] as const;
for (const model of cases) {
let payload: unknown;
expect(model.compat?.maxTokensField).toBe("max_tokens");
await streamSimple(
model,
{
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
maxTokens: 123,
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as { max_tokens?: number; max_completion_tokens?: number };
expect(params.max_tokens).toBe(123);
expect(params.max_completion_tokens).toBeUndefined();
}
});
it("omits reasoning effort for OpenCode Grok Build", async () => {
const model = getModel("opencode", "grok-build-0.1")!;
let payload: unknown;
+15 -1
View File
@@ -20,7 +20,14 @@ describe("getSupportedThinkingLevels", () => {
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
});
it("does not include xhigh for non-Opus Anthropic models", () => {
it("includes xhigh but not off for Anthropic Claude Fable 5 on anthropic-messages API", () => {
const model = getModel("anthropic", "claude-fable-5");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
expect(getSupportedThinkingLevels(model!)).not.toContain("off");
});
it("does not include xhigh for Claude Sonnet 4.5", () => {
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).not.toContain("xhigh");
@@ -79,4 +86,11 @@ describe("getSupportedThinkingLevels", () => {
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
});
it("includes xhigh but not off for Bedrock Claude Fable 5", () => {
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
expect(model).toBeDefined();
expect(getSupportedThinkingLevels(model!)).toContain("xhigh");
expect(getSupportedThinkingLevels(model!)).not.toContain("off");
});
});