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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Models and Adapters for these providers are shipped out of the box:

- Anthropic
- OpenAI
- Azure OpenAI
- Gemini
- Vertex AI
- Tributary
Expand Down Expand Up @@ -51,7 +52,7 @@ const calculator = new Tool({
});

const agent = new Agent({
model: openAIModel({ model: "gpt-4.1-mini" }), // Or via shorthand "openai:gpt-4.1-mini"
model: openAIModel({ model: "gpt-4.1-mini" }), // Or via shorthand "openai:gpt-4.1-mini", "azure:gpt-4.1-mini"
instructions: "You are a helpful assistant. Use the calculator when math is needed.",
tools: [calculator],
});
Expand Down Expand Up @@ -361,6 +362,40 @@ console.log(result.outputText);

This works for file inputs like PDFs, images, and CSVs, depending on the model provider.

### Files API

`openAIModel` and `azureOpenAIModel` accept a `fileStore`. With one set, images and PDFs are uploaded to the provider's
Files API once and referenced by `file_id` on later calls instead of being sent as base64 every time.

```ts
import { Agent } from "jsr:@alphaxiv/agents";
import { openAIModel } from "jsr:@alphaxiv/agents/openai";

const agent = new Agent({
model: openAIModel({
model: "gpt-5.4-mini",
fileStore: {
get: (url) => db.getProviderFile(url),
set: (url, fileId, expiresAt) => db.upsertProviderFile(url, fileId, expiresAt),
delete: (url, fileId) => db.deleteProviderFile(url, fileId),
},
}),
instructions: "You are a helpful assistant.",
});
```

The store maps a source URL to a provider file id and lives wherever you keep it:

- `get` returns the row for a URL, or nothing
- `set` stores a new id and returns the one that won, keeping a live row's id and replacing an expired one
- `delete` removes a row only while it still holds the id you pass

Rows expire 7 days after upload and are never extended. The adapter only replaces an expired row when that URL comes up
again, so sweep expired rows on your own schedule and delete their files on the provider with your own client.

If the provider rejects a file id, the adapter uploads again and resends the request inside the same call, so a run sees
one uninterrupted stream.

## Tracing

Tracing is optional, but built in. You can:
Expand Down
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.6.16",
"version": "0.7.0",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand All @@ -20,6 +20,7 @@
"./openrouter": "./src/adapters/openrouter/adapter.ts",
"./tributary": "./src/adapters/tributary/adapter.ts",
"./openai": "./src/adapters/openai/adapter.ts",
"./azure-openai": "./src/adapters/azure_openai/adapter.ts",
"./sid": "./src/adapters/sid/adapter.ts",

"./sid/tools": "./src/adapters/sid/tools.ts"
Expand Down
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type {
ChatItemToolUse,
ChatLike,
ContextSummaryStartEvent,
ProviderFileStore,
ProviderStreamMetadata,
StreamItem,
TokenUsage,
Expand Down
1 change: 1 addition & 0 deletions src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
// caches the tools with it, and one on the conversation tail caches the turn so far.
if (cacheControl) applyAnthropicCacheBreakpoint(anthropicHistory, cacheControl);

yield { type: "request_start", index: 0 };
const response = client.beta.messages.stream({
model: options.model,
// An empty text block is rejected, so a blank prompt stays a bare string (and has nothing to cache).
Expand Down
50 changes: 50 additions & 0 deletions src/adapters/azure_openai/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { requireEnv } from "../../util.ts";
import {
type OpenResponsesClient,
openResponsesModel,
type OpenResponsesServiceTier,
} from "../open_responses/adapter.ts";
import type { Adapter } from "../adapter.ts";
import { getOpenAISupportedMimeTypes } from "../openai/mimes.ts";
import {
getModelModalities,
type OpenAIModels,
resolveOpenAIReasoning,
type SupportedReasoningEffort,
} from "../openai/models.ts";
import type { ProviderFileStore } from "../../types.ts";

/**
* OpenAI models on Azure Foundry, addressed by model name rather than by deployment name.
* A deployment named after a model id takes that name over, so never create one.
*
* With a `fileStore`, files are capped at 50 MB each, and Azure never expires an upload on its own.
* The store's sweep is the only thing that deletes them.
*/
export function azureOpenAIModel<zO, zI, TModel extends OpenAIModels>(options: {
model: TModel;
apiKey?: string;
endpoint?: string;
serviceTier?: OpenResponsesServiceTier;
effort?: SupportedReasoningEffort<TModel>;
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> {
return openResponsesModel({
provider: "Azure",
model: options.model,
supportedMimeTypes: getOpenAISupportedMimeTypes(getModelModalities(options.model)),
client: options.client,
openAIOptions: options.client ? undefined : {
apiKey: options.apiKey ?? requireEnv("AZURE_OPENAI_API_KEY"),
baseURL: `${(options.endpoint ?? requireEnv("AZURE_OPENAI_ENDPOINT")).replace(/\/$/, "")}/openai/v1`,
},
reasoning: resolveOpenAIReasoning(options.model, options.effort),
parallelToolCalls: options.parallelToolCalls,
serviceTier: options.serviceTier,
// Responses on Azure only reads ids uploaded with this purpose, and Azure rejects `expires_after`.
files: options.fileStore ? { purpose: "assistants", store: options.fileStore } : undefined,
});
}
1 change: 1 addition & 0 deletions src/adapters/google_genai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function googleGenerateContentAPIModel<zO, zI>(
inlineFiles: options.googleGenAIOptions?.vertexai === true,
});

yield { type: "request_start", index: 0 };
const stream = await client.models.generateContentStream({
model: options.model,
contents: googleHistory,
Expand Down
7 changes: 6 additions & 1 deletion src/adapters/model_resolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Adapter } from "./adapter.ts";
import { anthropicModel } from "./anthropic/adapter.ts";
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";
Expand All @@ -16,7 +17,8 @@ import { openAIModel } from "./openai/adapter.ts";
*
* Type-safe prefixes ensure autocomplete works for known model names:
* - `"anthropic:claude-sonnet-4-5"`
* - `"openai:gpt-4o"`
* - `"openai:gpt-5.6-luna"`
* - `"azure:gpt-5.6-luna"`
* - `"gemini:gemini-2.5-pro"`
* - `"vertex:gemini-2.5-pro"`
* - `"openrouter:<any-model-path>"`
Expand All @@ -26,6 +28,7 @@ import { openAIModel } from "./openai/adapter.ts";
export type ModelString =
| `anthropic:${AnthropicModels}`
| `openai:${OpenAIModels}`
| `azure:${OpenAIModels}`
| `gemini:${GoogleModels}`
| `vertex:${GoogleModels}`
| `openrouter:${OpenRouterModels}`
Expand Down Expand Up @@ -60,6 +63,8 @@ export function resolveModel(model: AdapterLike): Adapter<unknown, unknown> {
return anthropicModel({ model: modelName as AnthropicModels });
case "openai":
return openAIModel({ model: modelName as OpenAIModels });
case "azure":
return azureOpenAIModel({ model: modelName as OpenAIModels });
case "gemini":
return geminiModel({ model: modelName as GoogleModels });
case "vertex":
Expand Down
Loading
Loading