Skip to content
Open
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
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