Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion runtime/typescript/packages/anthropic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
*/

export { AnthropicExecutor } from "./executor.js";
export { AnthropicProcessor, processResponse } from "./processor.js";
export { AnthropicProcessor, processResponse, processStream } from "./processor.js";
export { buildChatArgs, messageToWire, toolsToWire, outputsToWire } from "./wire.js";
export { listModels, modelInfoFromWire } from "./models.js";

// Auto-register on import
import { registerExecutor, registerProcessor } from "@prompty/core";
Expand Down
75 changes: 75 additions & 0 deletions runtime/typescript/packages/anthropic/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Anthropic model discovery.
*
* @module
*/

import Anthropic from "@anthropic-ai/sdk";
import {
ApiKeyConnection,
ModelInfo,
ReferenceConnection,
createModelInfo,
enrichModelInfo,
getConnection,
} from "@prompty/core";
import type { Connection } from "@prompty/core";

interface AnthropicModelsClient {
models: {
list(params?: { limit?: number; after_id?: string }): Promise<AsyncIterable<unknown>>;
};
}

/** Map one raw Anthropic model response into the canonical generated model. */
export function modelInfoFromWire(raw: Record<string, unknown>): ModelInfo {
return createModelInfo(enrichModelInfo("anthropic", {
id: typeof raw.id === "string" ? raw.id : "",
displayName: typeof raw.display_name === "string" ? raw.display_name : undefined,
ownedBy: "anthropic",
contextWindow: typeof raw.context_length === "number" ? raw.context_length : undefined,
inputModalities: stringArray(raw.input_modalities),
outputModalities: stringArray(raw.output_modalities),
additionalProperties: { ...raw },
}));
}

/** List every model available from the Anthropic Models API. */
export async function listModels(connection: Connection): Promise<ModelInfo[]> {
const client = buildClient(connection);
const page = await client.models.list({ limit: 100 });
const models: ModelInfo[] = [];

for await (const raw of page) {
models.push(modelInfoFromWire(asRecord(raw)));
}

return models;
}

function buildClient(connection: Connection): AnthropicModelsClient {
if (connection instanceof ReferenceConnection) {
return getConnection(connection.name) as AnthropicModelsClient;
}
if (!(connection instanceof ApiKeyConnection)) {
throw new Error(
`Connection kind '${connection.kind}' is not supported by Anthropic listModels. ` +
"Use 'key' for API key auth or 'reference' with registerConnection() for pre-configured clients.",
);
}
return new Anthropic({
apiKey: connection.apiKey || process.env.ANTHROPIC_API_KEY,
...(connection.endpoint ? { baseURL: connection.endpoint } : {}),
});
}

function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Anthropic model listing returned a non-object model entry.");
}
return value as Record<string, unknown>;
}

function stringArray(value: unknown): string[] | undefined {
return Array.isArray(value) ? value.map(String) : undefined;
}
130 changes: 99 additions & 31 deletions runtime/typescript/packages/anthropic/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@
import type { Prompty } from "@prompty/core";
import type { Processor } from "@prompty/core";
import type { ToolCall } from "@prompty/core";
import { traceSpan } from "@prompty/core";
import {
ErrorChunk,
InvocationUsage,
StreamChunk,
TextChunk,
ThinkingChunk,
ToolChunk,
UsageChunk,
traceSpan,
} from "@prompty/core";
import { createStructuredResult } from "@prompty/core";

export class AnthropicProcessor implements Processor {
Expand All @@ -29,6 +38,10 @@ export class AnthropicProcessor implements Processor {
return result;
});
}

processStream(response: AsyncIterable<unknown>): AsyncIterable<StreamChunk> {
return processStream(response);
}
}

/**
Expand All @@ -39,7 +52,7 @@ export function processResponse(agent: Prompty, response: unknown): unknown {

// Streaming response — return content-extracting async generator
if (isAsyncIterable(response)) {
return streamGenerator(response);
return legacyStreamGenerator(processStream(response));
}

const r = response as Record<string, unknown>;
Expand Down Expand Up @@ -76,53 +89,108 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
*
* Tool calls are accumulated and yielded at the end of the stream.
*/
async function* streamGenerator(
export async function* processStream(
response: AsyncIterable<unknown>,
): AsyncGenerator<string | ToolCall> {
): AsyncGenerator<StreamChunk> {
const toolCallAcc: Map<
number,
{ id: string; name: string; arguments: string }
> = new Map();
let inputTokens: number | undefined;
let outputTokens: number | undefined;

try {
for await (const event of response) {
const e = event as Record<string, unknown>;
const eventType = e.type as string | undefined;

if (eventType === "message_start") {
const message = e.message as Record<string, unknown> | undefined;
const usage = message?.usage as Record<string, unknown> | undefined;
inputTokens = numberValue(usage?.input_tokens) ?? inputTokens;
} else if (eventType === "message_delta") {
const usage = e.usage as Record<string, unknown> | undefined;
outputTokens = numberValue(usage?.output_tokens) ?? outputTokens;
} else if (eventType === "content_block_delta") {
const delta = e.delta as Record<string, unknown> | undefined;
if (!delta) continue;

for await (const event of response) {
const e = event as Record<string, unknown>;
const eventType = e.type as string | undefined;

if (eventType === "content_block_delta") {
const delta = e.delta as Record<string, unknown> | undefined;
if (!delta) continue;

if (delta.type === "text_delta") {
yield delta.text as string;
} else if (delta.type === "input_json_delta") {
// Accumulate partial JSON for tool arguments
const idx = e.index as number;
const acc = toolCallAcc.get(idx);
if (acc) {
acc.arguments += (delta.partial_json ?? "") as string;
if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text) {
yield new TextChunk({ value: delta.text });
} else if (delta.type === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking) {
yield new ThinkingChunk({ value: delta.thinking });
} else if (delta.type === "input_json_delta") {
const idx = typeof e.index === "number" ? e.index : 0;
const acc = toolCallAcc.get(idx);
if (acc && typeof delta.partial_json === "string") {
acc.arguments += delta.partial_json;
}
}
}
} else if (eventType === "content_block_start") {
const block = e.content_block as Record<string, unknown> | undefined;
if (block?.type === "tool_use") {
const idx = e.index as number;
toolCallAcc.set(idx, {
id: (block.id ?? "") as string,
name: (block.name ?? "") as string,
arguments: "",
} else if (eventType === "content_block_start") {
const block = e.content_block as Record<string, unknown> | undefined;
if (block?.type === "tool_use") {
const idx = typeof e.index === "number" ? e.index : 0;
toolCallAcc.set(idx, {
id: stringValue(block.id),
name: stringValue(block.name),
arguments: "",
});
}
} else if (eventType === "error") {
const error = e.error as Record<string, unknown> | undefined;
yield new ErrorChunk({
message: stringValue(error?.message) || "Anthropic stream failed",
});
return;
}
}
} catch (error) {
yield new ErrorChunk({
message: error instanceof Error ? error.message : String(error),
});
return;
}

// Yield accumulated tool calls at the end of the stream
const sortedIndices = [...toolCallAcc.keys()].sort((a, b) => a - b);
for (const idx of sortedIndices) {
const tc = toolCallAcc.get(idx)!;
yield { id: tc.id, name: tc.name, arguments: tc.arguments } as ToolCall;
yield ToolChunk.load({ kind: "tool", toolCall: tc });
}
if (inputTokens !== undefined || outputTokens !== undefined) {
const input = inputTokens ?? 0;
const output = outputTokens ?? 0;
yield new UsageChunk({
usage: new InvocationUsage({
inputTokens: input,
outputTokens: output,
totalTokens: input + output,
}),
});
}
}

async function* legacyStreamGenerator(
chunks: AsyncIterable<StreamChunk>,
): AsyncGenerator<string | ToolCall> {
for await (const chunk of chunks) {
if (chunk instanceof TextChunk) {
yield chunk.value;
} else if (chunk instanceof ToolChunk) {
yield chunk.toolCall;
} else if (chunk instanceof ErrorChunk) {
throw new Error(chunk.message);
}
}
}

function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}

function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

// ---------------------------------------------------------------------------
// Non-streaming response processing
// ---------------------------------------------------------------------------
Expand Down
68 changes: 67 additions & 1 deletion runtime/typescript/packages/anthropic/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ import {
turn,
registerConnection,
clearConnections,
ErrorChunk,
TextChunk,
ThinkingChunk,
ToolChunk,
UsageChunk,
} from "@prompty/core";
import { AnthropicExecutor } from "../src/executor.js";
import { AnthropicProcessor, processResponse } from "../src/processor.js";
import { AnthropicProcessor, processResponse, processStream } from "../src/processor.js";
import { buildChatArgs, messageToWire, toolsToWire, outputsToWire } from "../src/wire.js";
import { registerExecutor, registerProcessor } from "@prompty/core";
import { Message } from "@prompty/core";
Expand Down Expand Up @@ -406,6 +411,67 @@ describe("processor", () => {
// ---------------------------------------------------------------------------

describe("streaming processor", () => {
it("emits canonical text, thinking, tool, usage, and error chunks", async () => {
async function* stream() {
yield { type: "message_start", message: { usage: { input_tokens: 4 } } };
yield {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_1", name: "lookup" },
};
yield {
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "Hello" },
};
yield {
type: "content_block_delta",
index: 1,
delta: { type: "thinking_delta", thinking: "Considering" },
};
yield {
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"q":"test"}' },
};
yield { type: "message_delta", usage: { output_tokens: 3 } };
}

const chunks: unknown[] = [];
for await (const chunk of processStream(stream())) chunks.push(chunk);

expect(chunks[0]).toBeInstanceOf(TextChunk);
expect((chunks[0] as TextChunk).value).toBe("Hello");
expect(chunks[1]).toBeInstanceOf(ThinkingChunk);
expect((chunks[1] as ThinkingChunk).value).toBe("Considering");
expect(chunks[2]).toBeInstanceOf(ToolChunk);
expect((chunks[2] as ToolChunk).toolCall).toMatchObject({
id: "toolu_1",
name: "lookup",
arguments: '{"q":"test"}',
});
expect(chunks[3]).toBeInstanceOf(UsageChunk);
expect((chunks[3] as UsageChunk).usage).toMatchObject({
inputTokens: 4,
outputTokens: 3,
totalTokens: 7,
});

async function* failed() {
yield { type: "error", error: { message: "overloaded" } };
yield {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "must not be emitted" },
};
}
const errors: unknown[] = [];
for await (const chunk of processStream(failed())) errors.push(chunk);
expect(errors).toHaveLength(1);
expect(errors[0]).toBeInstanceOf(ErrorChunk);
expect((errors[0] as ErrorChunk).message).toBe("overloaded");
});

it("yields text deltas from content_block_delta events", async () => {
const events = [
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
Expand Down
Loading
Loading