Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.7.5",
"version": "0.7.6",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand Down
114 changes: 60 additions & 54 deletions src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ function extractJson(text: string): string {
return text.trim();
}

function supportsNativeStructuredOutput<TModel extends AnthropicModels>(model: TModel) {
return anthropicModelStructuredOutputSupport[model];
}

// Only include thinkingLevel for models with extended thinking (adaptive-only models don't use budget_tokens).
type ThinkingLevelOption<TModel extends AnthropicModels> = SupportedThinkingLevel<TModel> extends never ? undefined
: SupportedThinkingLevel<TModel>;
Expand All @@ -58,53 +54,65 @@ export type AnthropicCacheOptions = {
ttl?: "5m" | "1h";
};

export function anthropicModel<zO, zI, TModel extends AnthropicModels>(options: {
model: TModel;
effort?: EffortOption<TModel>;
thinkingLevel?: ThinkingLevelOption<TModel>;
interleaved?: InterleavedOption<TModel>;
thinkingDisplay?: ThinkingDisplay;
/**
* Cache the instructions, tools and conversation prefix across calls.
*
* Defaults to the 5 minute cache when the agent has tools, and to off when it
* has none: cached tokens are billed at ~0.1x but writes at 1.25x (2x for
* `ttl: "1h"`), so caching pays off from the second call sharing a prefix
* onward, and tools are the signal that a second call is coming. Pass `true`
* to cache a toolless agent anyway (worth it if you rerun the same
* instructions), or `false` to opt out entirely. Set here, this outranks the
* `cache` on the agent using the model.
*
* Anthropic silently declines to cache prefixes below the model's minimum,
* which is per-model and ranges from 1024 tokens (Sonnet 4.5 and older) to
* 4096 (Opus, Haiku 4.5), so read `usage.cacheReadTokens` rather than
* assuming a hit.
*/
cache?: boolean | AnthropicCacheOptions;
baseUrl?: string;
apiKey?: string;
client?: Anthropic;
}): Adapter<zO, zI> {
const modelConfig = anthropicModelThinkingSupport[options.model];
// I know, terrifying, someone should fix this tbh it's super super scary
const thinkingLevel =
("schema" in modelConfig ? (options.thinkingLevel ?? modelConfig.schema.parse(undefined)) : undefined) as
| SupportedThinkingLevel<TModel>
| undefined;
const effort =
("effortSchema" in modelConfig ? (options.effort ?? modelConfig.effortSchema.parse(undefined)) : undefined) as
| SupportedEffortLevel<TModel>
| undefined;
export function anthropicModel<zO, zI, TModel extends AnthropicModels | (string & Record<never, never>)>(
options:
& {
model: TModel;
effort?: TModel extends AnthropicModels ? EffortOption<TModel> : EffortLevel;
thinkingLevel?: TModel extends AnthropicModels ? ThinkingLevelOption<TModel> : never;
interleaved?: TModel extends AnthropicModels ? InterleavedOption<TModel> : never;
thinkingDisplay?: ThinkingDisplay;
/**
* Cache the instructions, tools and conversation prefix across calls.
*
* Defaults to the 5 minute cache when the agent has tools, and to off when it
* has none: cached tokens are billed at ~0.1x but writes at 1.25x (2x for
* `ttl: "1h"`), so caching pays off from the second call sharing a prefix
* onward, and tools are the signal that a second call is coming. Pass `true`
* to cache a toolless agent anyway (worth it if you rerun the same
* instructions), or `false` to opt out entirely. Set here, this outranks the
* `cache` on the agent using the model.
*
* Anthropic silently declines to cache prefixes below the model's minimum,
* which is per-model and ranges from 1024 tokens (Sonnet 4.5 and older) to
* 4096 (Opus, Haiku 4.5), so read `usage.cacheReadTokens` rather than
* assuming a hit.
*/
cache?: boolean | AnthropicCacheOptions;
baseUrl?: string;
apiKey?: string;
client?: Anthropic;
}
& (TModel extends AnthropicModels ? { capabilities?: never } : {
/** Declare capabilities for a model ID that is not yet in the built-in list. */
capabilities: { adaptiveThinking?: boolean; nativeStructuredOutput?: boolean };
}),
): Adapter<zO, zI> {
const modelConfig = anthropicModelThinkingSupport[options.model as AnthropicModels];
const capabilities = "capabilities" in options ? options.capabilities : undefined;
const nativeStructuredOutput = capabilities?.nativeStructuredOutput ??
anthropicModelStructuredOutputSupport[options.model as AnthropicModels] ?? false;
const thinkingLevel = modelConfig && "schema" in modelConfig
? (options.thinkingLevel ?? modelConfig.schema.parse(undefined))
: undefined;
const effort = modelConfig && "effortSchema" in modelConfig
? (options.effort ?? modelConfig.effortSchema.parse(undefined))
: options.effort;
const thinkingDisplay = options.thinkingDisplay ?? "summarized";
const interleaved = options.interleaved;
const streamConfig = getAnthropicMessagesStreamConfig({
model: options.model,
thinkingLevel: thinkingLevel as ThinkingLevel | undefined,
effort: effort as EffortLevel | undefined,
thinkingDisplay: thinkingDisplay,
interleaved: interleaved,
});
// scaryness over
const streamConfig = modelConfig
? getAnthropicMessagesStreamConfig({
model: options.model as AnthropicModels,
thinkingLevel: thinkingLevel as ThinkingLevel | undefined,
effort: effort as EffortLevel | undefined,
thinkingDisplay: thinkingDisplay,
interleaved: interleaved,
})
: {
thinking: capabilities?.adaptiveThinking ? { type: "adaptive" as const, display: thinkingDisplay } : undefined,
output_config: effort ? { effort: effort as EffortLevel } : undefined,
betas: undefined,
};
const client = options.client ??
new Anthropic({
apiKey: options.apiKey ?? requireEnv("ANTHROPIC_API_KEY"),
Expand All @@ -116,7 +124,7 @@ export function anthropicModel<zO, zI, TModel extends AnthropicModels>(options:
return instructions;
}

if (supportsNativeStructuredOutput(options.model)) {
if (nativeStructuredOutput) {
if (structuredOutput.instructions) {
return `${structuredOutput.instructions}\n\n${instructions}`;
}
Expand Down Expand Up @@ -183,7 +191,7 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
thinking: streamConfig.thinking,
output_config: {
...streamConfig.output_config,
format: supportsNativeStructuredOutput(options.model) && structuredOutput
format: nativeStructuredOutput && structuredOutput
? { type: "json_schema", schema: structuredOutput.jsonSchema }
: undefined,
},
Expand Down Expand Up @@ -288,9 +296,7 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
content: restoredContent,
};
} else if (endingPart.type === "output_text" && structuredOutput) {
const rawJson = supportsNativeStructuredOutput(options.model)
? endingPart.content
: extractJson(endingPart.content);
const rawJson = nativeStructuredOutput ? endingPart.content : extractJson(endingPart.content);

let structuredContent: string;
try {
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/anthropic/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ function extended<const T extends readonly [ThinkingLevel, ...ThinkingLevel[]]>(

const anthropicModelThinkingSupportDefinition = {
// Adaptive thinking only (effort controls thinking intensity)
"claude-opus-5-5": adaptive({
levels: ["low", "medium", "high", "xhigh", "max"],
default: "medium",
}),
"claude-opus-5": adaptive({
levels: ["low", "medium", "high", "xhigh", "max"],
default: "high",
Expand Down Expand Up @@ -183,6 +187,7 @@ export type SupportsInterleaved<TModel extends AnthropicModels> = ModelConfig<TM
* Model support for native structured ouput.
*/
export const anthropicModelStructuredOutputSupport = {
"claude-opus-5-5": true,
"claude-opus-5": true,
"claude-opus-4-8": true,
"claude-opus-4-7": true,
Expand Down
1 change: 1 addition & 0 deletions src/adapters/anthropic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ExtendedThinkingSupport<T extends string> {
}

export interface AnthropicModelThinkingSupportMap {
"claude-opus-5-5": AdaptiveThinkingSupport<"low" | "medium" | "high" | "xhigh" | "max">;
"claude-opus-5": AdaptiveThinkingSupport<"low" | "medium" | "high" | "xhigh" | "max">;
"claude-opus-4-8": AdaptiveThinkingSupport<"low" | "medium" | "high" | "xhigh" | "max">;
"claude-opus-4-7": AdaptiveThinkingSupport<"low" | "medium" | "high" | "xhigh" | "max">;
Expand Down
13 changes: 8 additions & 5 deletions src/adapters/model_resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { azureOpenAIModel } from "./azure_openai/adapter.ts";
import type { AnthropicModels } from "./anthropic/models.ts";
import { geminiModel } from "./gemini/adapter.ts";
import type { GoogleModels } from "./google_genai/models.ts";
import { openAIModel } from "./openai/adapter.ts";
import type { OpenAIModels } from "./openai/models.ts";
import { openrouterModel } from "./openrouter/adapter.ts";
import type { OpenRouterModels } from "./openrouter/models.ts";
import { sidModel, type SidModels } from "./sid/adapter.ts";
import { tributaryModel, type TributaryModels } from "./tributary/adapter.ts";
import { vertexAIModel } from "./vertex_ai/adapter.ts";
import { openrouterModel } from "./openrouter/adapter.ts";
import { openAIModel } from "./openai/adapter.ts";

/**
* A string shorthand for creating a model instance.
Expand All @@ -36,7 +36,10 @@ export type ModelString =
| `sid:${SidModels}`;

/** A model instance or a string shorthand that can be resolved into one. */
export type AdapterLike = Adapter<unknown, unknown> | ModelString;
export type AdapterLike =
| Adapter<unknown, unknown>
| ModelString
| (string & Record<never, never>);

/**
* Resolve a {@link ModelLike} value into a concrete {@link Model} instance.
Expand All @@ -60,9 +63,9 @@ export function resolveModel(model: AdapterLike): Adapter<unknown, unknown> {

switch (provider) {
case "anthropic":
return anthropicModel({ model: modelName as AnthropicModels });
return anthropicModel({ model: modelName, capabilities: {} });
case "openai":
return openAIModel({ model: modelName as OpenAIModels });
return openAIModel({ model: modelName });
case "azure":
return azureOpenAIModel({ model: modelName as OpenAIModels });
case "gemini":
Expand Down
14 changes: 10 additions & 4 deletions src/adapters/openai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
import type { Adapter } from "../adapter.ts";
import { getOpenAISupportedMimeTypes } from "./mimes.ts";
import {
getModelModalities,
type OpenAIModelModality,
openAiModelReasoningSupport,
type OpenAIModels,
type OpenAIReasoningEffort,
resolveOpenAIReasoning,
type SupportedReasoningEffort,
} from "./models.ts";
Expand All @@ -17,21 +19,25 @@ import type { ProviderFileStore } from "../../types.ts";
/** Backup to the store's 7 day expiry, later so the store is always the one that deletes first. */
const FILE_EXPIRES_AFTER_SECONDS = 9 * 24 * 60 * 60;

export function openAIModel<zO, zI, TModel extends OpenAIModels>(options: {
export function openAIModel<zO, zI, TModel extends OpenAIModels | (string & Record<never, never>)>(options: {
model: TModel;
apiKey?: string;
baseUrl?: string;
serviceTier?: OpenResponsesServiceTier;
effort?: SupportedReasoningEffort<TModel>;
effort?: TModel extends OpenAIModels ? SupportedReasoningEffort<TModel> : OpenAIReasoningEffort;
/** Input modalities for a model ID that is not yet in the built-in list. Defaults to text. */
modalities?: TModel extends OpenAIModels ? never : readonly OpenAIModelModality[];
parallelToolCalls?: boolean;
client?: OpenResponsesClient;
/** When set, images and PDFs are uploaded to the Files API and sent as `file_id`. */
fileStore?: ProviderFileStore;
}): Adapter<zO, zI> {
const modelConfig = openAiModelReasoningSupport[options.model as OpenAIModels];

return openResponsesModel({
provider: "OpenAI",
model: options.model,
supportedMimeTypes: getOpenAISupportedMimeTypes(getModelModalities(options.model)),
supportedMimeTypes: getOpenAISupportedMimeTypes(modelConfig?.modalities ?? options.modalities ?? ["text"]),
client: options.client,
openAIOptions: options.client ? undefined : {
apiKey: options.apiKey ?? requireEnv("OPENAI_API_KEY"),
Expand Down
31 changes: 23 additions & 8 deletions src/adapters/openai/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ function reasoning<const T extends readonly [OpenAIReasoningEffort, ...OpenAIRea

const openAiModelsDefinition = {
// Frontier
"gpt-6-astra": reasoning({
levels: ["low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
"gpt-6-sol": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
"gpt-6-luna": reasoning({
levels: ["none", "low", "medium", "high", "xhigh", "max"],
default: "medium",
modalities: ["text", "image"],
}),
// `gpt-5.6` is an alias that routes to Sol. Terra trades capability for cost, Luna is the
// fast, high-volume tier. `max` arrived with this generation and is reserved for the
// hardest quality-first work.
Expand Down Expand Up @@ -206,20 +221,20 @@ export type OpenAIModels = keyof typeof openAiModels;
type ModelConfig<TModel extends OpenAIModels> = (typeof openAiModels)[TModel];

export type SupportedReasoningEffort<TModel extends OpenAIModels> = ModelConfig<TModel> extends
{ schema: z.ZodType<infer V> } ? V : never;
{ schema: z.ZodType<infer V extends OpenAIReasoningEffort> } ? V : never;

export function getModelModalities<TModel extends OpenAIModels>(model: TModel): readonly OpenAIModelModality[] {
return openAiModels[model].modalities as readonly OpenAIModelModality[];
}

export function resolveOpenAIReasoning<TModel extends OpenAIModels>(
model: TModel,
effort?: SupportedReasoningEffort<TModel>,
export function resolveOpenAIReasoning(
model: OpenAIModels | (string & Record<never, never>),
effort?: OpenAIReasoningEffort,
): { effort: OpenAIReasoningEffort; summary?: "auto" } | undefined {
const config = openAiModelReasoningSupport[model];
if (!("schema" in config)) return undefined;
const resolved = (effort ?? getDefaultReasoningEffort(model)) as OpenAIReasoningEffort;
return { effort: resolved, summary: resolved === "none" ? undefined : "auto" };
const config = openAiModelReasoningSupport[model as OpenAIModels];
if (config && !("schema" in config)) return undefined;
const resolved = effort ?? config?.schema.parse(undefined);
return resolved ? { effort: resolved, summary: resolved === "none" ? undefined : "auto" } : undefined;
}

export function getDefaultReasoningEffort<TModel extends OpenAIModels>(
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/openai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export interface ReasoningModelSupport<T extends readonly [string, ...string[]]>
}

export interface OpenAiModelsMap {
"gpt-6-astra": ReasoningModelSupport<["low", "medium", "high", "xhigh", "max"]>;
"gpt-6-sol": ReasoningModelSupport<["none", "low", "medium", "high", "xhigh", "max"]>;
"gpt-6-luna": ReasoningModelSupport<["none", "low", "medium", "high", "xhigh", "max"]>;
"gpt-5.6": ReasoningModelSupport<["none", "low", "medium", "high", "xhigh", "max"]>;
"gpt-5.6-sol": ReasoningModelSupport<["none", "low", "medium", "high", "xhigh", "max"]>;
"gpt-5.6-terra": ReasoningModelSupport<["none", "low", "medium", "high", "xhigh", "max"]>;
Expand Down
23 changes: 23 additions & 0 deletions tests/adapters/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,29 @@ Deno.test("Anthropic caching breakpoints cover the system prefix and the convers
assertEquals(tailCacheControl(request), { type: "ephemeral", ttl: "1h" });
});

Deno.test("Anthropic Opus 5.5 uses medium adaptive thinking and native structured output", async () => {
const { client, requests } = createCapturingAnthropicClient();
await streamOnce(anthropicModel({ model: "claude-opus-5-5", client }));

assertEquals(requests[0].model, "claude-opus-5-5");
assertEquals(requests[0].thinking, { type: "adaptive", display: "summarized" });
assertEquals(requests[0].output_config?.effort, "medium");
});

Deno.test("Anthropic accepts a future model with declared capabilities", async () => {
const { client, requests } = createCapturingAnthropicClient();
await streamOnce(anthropicModel({
model: "claude-opus-6",
capabilities: { adaptiveThinking: true, nativeStructuredOutput: true },
effort: "high",
client,
}));

assertEquals(requests[0].model, "claude-opus-6");
assertEquals(requests[0].thinking, { type: "adaptive", display: "summarized" });
assertEquals(requests[0].output_config?.effort, "high");
});

Deno.test("Anthropic caching defaults to the 5 minute cache", async () => {
const { client, requests } = createCapturingAnthropicClient();
await streamOnce(anthropicModel({ model: "claude-opus-4-8", cache: true, client }));
Expand Down
Loading
Loading