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
27 changes: 27 additions & 0 deletions .changeset/post-merge-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@ts-dspy/anthropic': minor
'@ts-dspy/gemini': minor
'@ts-dspy/openai': minor
'@ts-dspy/core': minor
---

Close the gaps left where the 0.6 features met each other.

Images now reach the model through `Predict` and `ChainOfThought`. A signature
declaring an `image` input previously had it flattened to an `[image: …]`
placeholder before the request was built, so the model never saw the picture;
the prompt now travels as chat content whenever a field is declared `image`,
and as a plain string otherwise. Structured output over an image asks for the
schema in the prompt, since the provider methods that constrain decoding accept
only a string.

Every provider now overrides `cacheScope()`. Two clients differing only in
`maxTokens`, `safetySettings`, `baseURL`, or declared capabilities hashed to the
same cache key, so one could be served a reply the other's configuration would
never have produced.

`AnthropicRefusalError` is a subclass of `ContentFilterError` rather than an
alias of it. As an alias, `instanceof AnthropicRefusalError` also matched OpenAI
and Gemini content filters; as a subclass, a cross-provider `catch` on
`ContentFilterError` still works and narrowing to Anthropic means Anthropic
again.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ package-lock.json
CHANGELOG.md
.changeset
site/
.claude
11 changes: 10 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: ['site/**', '**/dist/**', '**/node_modules/**', '**/coverage/**', '**/*.d.ts'],
ignores: [
'site/**',
// Git worktrees live under .claude/, and linting another
// branch's checkout is never what you meant.
'.claude/**',
'**/dist/**',
'**/node_modules/**',
'**/coverage/**',
'**/*.d.ts',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
Expand Down
25 changes: 23 additions & 2 deletions packages/anthropic/src/anthropic-lm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('AnthropicLM', () => {
);
});

it('throws the shared ContentFilterError, which AnthropicRefusalError now aliases', async () => {
it('throws an AnthropicRefusalError, which is a ContentFilterError', async () => {
mocks.create.mockResolvedValue({
content: [],
stop_reason: 'refusal',
Expand All @@ -163,7 +163,11 @@ describe('AnthropicLM', () => {
const error = await new AnthropicLM({ apiKey: 'k' }).generate('Hi').catch((e) => e);

expect(error).toBeInstanceOf(ContentFilterError);
expect(AnthropicRefusalError).toBe(ContentFilterError);
// A subclass, not an alias: narrowing to AnthropicRefusalError has to
// keep meaning "Anthropic", while a cross-provider catch on
// ContentFilterError still works.
expect(AnthropicRefusalError.prototype).toBeInstanceOf(ContentFilterError);
expect(AnthropicRefusalError).not.toBe(ContentFilterError);
expect(error.provider).toBe('anthropic');
});
});
Expand Down Expand Up @@ -723,3 +727,20 @@ describe('AnthropicLM', () => {
});
});
});

describe('cache scoping', () => {
it('keys two differently configured clients apart', () => {
// Before cacheScope() was overridden here, these two hashed identically,
// so a reply truncated at 64 tokens could be served to a client that
// allows 8192.
const scopeOf = (lm: AnthropicLM) =>
JSON.stringify((lm as unknown as { cacheScope(): unknown }).cacheScope());

expect(scopeOf(new AnthropicLM({ apiKey: 'k', maxTokens: 64 }))).not.toBe(
scopeOf(new AnthropicLM({ apiKey: 'k', maxTokens: 8192 }))
);
expect(
scopeOf(new AnthropicLM({ apiKey: 'k', baseURL: 'https://a.example' }))
).not.toBe(scopeOf(new AnthropicLM({ apiKey: 'k', baseURL: 'https://b.example' })));
});
});
30 changes: 19 additions & 11 deletions packages/anthropic/src/anthropic-lm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,20 @@ export interface AnthropicConfig {
/**
* Raised when Claude's safety classifiers decline a request.
*
* @deprecated Renamed to `ContentFilterError` in `@ts-dspy/core`, which every
* provider now throws for the same condition. This is an alias of that class,
* not a subclass of it, so two things changed: the constructor now takes
* `(provider, message, options)` rather than `(category, explanation)`, and an
* `instanceof` check now also matches an OpenAI or Gemini content filter. Check
* `error.provider === 'anthropic'` if you need to tell them apart. The alias
* will be removed in a future release.
* @deprecated Prefer `ContentFilterError` from `@ts-dspy/core`, which every
* provider throws for the same condition. This is a **subclass** of it, so
* `catch (e) { if (e instanceof ContentFilterError) … }` handles all three
* providers while `instanceof AnthropicRefusalError` still means Anthropic
* specifically. The constructor did change, though: it now takes
* `(provider, message, options)` rather than `(category, explanation)`. This
* subclass will be removed in a future release.
*/
export const AnthropicRefusalError = ContentFilterError;
/** @deprecated Renamed to `ContentFilterError` in `@ts-dspy/core`. */
export type AnthropicRefusalError = ContentFilterError;
export class AnthropicRefusalError extends ContentFilterError {}

export class AnthropicLM extends BaseLM {
private readonly client: Anthropic;
private readonly defaultMaxTokens: number;
private readonly baseURL?: string;

constructor(config: AnthropicConfig = {}) {
super('anthropic', config.model ?? DEFAULT_ANTHROPIC_MODEL);
Expand All @@ -77,6 +76,15 @@ export class AnthropicLM extends BaseLM {
maxRetries: config.maxRetries,
});
this.defaultMaxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
this.baseURL = config.baseURL;
}

/**
* `maxTokens` is part of the request, so a client that truncates at 64
* tokens must not serve a cache entry recorded by one that allows 8192.
*/
protected cacheScope(): unknown {
return { maxTokens: this.defaultMaxTokens, baseURL: this.baseURL ?? null };
}

async chat(messages: ChatMessage[], options?: LLMCallOptions): Promise<string> {
Expand Down Expand Up @@ -283,7 +291,7 @@ export class AnthropicLM extends BaseLM {
const category = details?.category ?? undefined;
const explanation = details?.explanation ?? undefined;

throw new ContentFilterError(
throw new AnthropicRefusalError(
'anthropic',
`Request was declined by safety classifiers${category ? ` (${category})` : ''}` +
`${explanation ? `: ${explanation}` : ''}`,
Expand Down
35 changes: 18 additions & 17 deletions packages/core/src/evaluate/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Example } from '../core/example';
import { mapWithConcurrency } from '../utils/pool';
import { type Prediction } from '../core/prediction';
import { getDefaultLM } from '../core/config';
import type { ILanguageModel, LLMCallOptions, UsageStats } from '../types/language-model';
Expand All @@ -16,33 +17,33 @@ const DEFAULT_CONCURRENCY = 4;

/**
* Run `worker` over `items` with at most `limit` in flight, preserving input
* order in the returned array.
* order.
*
* Small and local on purpose: an evaluation needs no more than this, and
* `worker` is expected never to reject.
* Delegates to the shared pool in `utils/pool`; the clamp lives here because
* evaluation treats a computed `0` as "one at a time" — a caller deriving the
* limit from a rate-limit budget must not get the default four — whereas the
* shared pool rejects a non-positive limit outright.
*/
async function mapWithConcurrency<T, R>(
async function runPooled<T, R>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
// Only a missing or unusable limit falls back to the default: a caller who
// computed `0` from a rate-limit budget must not get four in flight.
if (items.length === 0) return [];

const requested = Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_CONCURRENCY;
const lanes = Math.min(Math.max(1, requested), items.length);

let cursor = 0;
const runners = Array.from({ length: lanes }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index], index);
}
const settled = await mapWithConcurrency([...items], (item, index) => worker(item, index), {
concurrency: lanes,
});

await Promise.all(runners);
return results;
return settled.map((result) => {
// The evaluation worker captures its own failures, so a rejection here
// is a bug in this module rather than a bad example.
if (result.status === 'rejected') throw result.reason;
return result.value as R;
});
}

function toError(cause: unknown): Error {
Expand Down Expand Up @@ -183,7 +184,7 @@ export async function evaluate(
const before = lm?.getUsage();
const startedAt = Date.now();

const results = await mapWithConcurrency(prepared, concurrency, async (example, index) => {
const results = await runPooled(prepared, concurrency, async (example, index) => {
const exampleStartedAt = Date.now();
let inputs: Record<string, any> = {};
// Held outside the try so a metric that throws still reports what the
Expand Down
43 changes: 42 additions & 1 deletion packages/core/src/modules/predict.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Predict } from './predict';
import { Signature, InputField, OutputField } from '../core/signature';
import { Signature, InputField, OutputField, ImageField } from '../core/signature';
import { ValidationError } from '../core/errors';
import { MockLM } from '../test-utils';

Expand Down Expand Up @@ -302,3 +302,44 @@ describe('Predict', () => {
await expect(predict.forward({})).rejects.toThrow('No signature provided');
});
});

describe('image inputs', () => {
const PNG = 'iVBORw0KGgo=';

class DescribeImage extends Signature {
static description = 'Describe the picture.';

@ImageField({ description: 'the picture' })
picture!: string;

@OutputField({ description: 'what it shows' })
caption!: string;
}

it('sends the image as content rather than a placeholder', async () => {
const lm = new MockLM({ responses: ['caption: a cat'] });

const result = await new Predict(DescribeImage, lm).forward({
picture: `data:image/png;base64,${PNG}`,
});

expect(result.caption).toBe('a cat');

// Predict used to flatten the image to "[image: image/png]", so the
// model never actually saw it.
const content = lm.calls.at(-1)?.messages[0]?.content;
expect(Array.isArray(content)).toBe(true);
expect(content).toContainEqual({
type: 'image',
source: { kind: 'base64', mediaType: 'image/png', data: PNG },
});
});

it('leaves a text-only prompt as a plain string', async () => {
const lm = new MockLM({ responses: ['answer: Paris\nconfidence: 0.9'] });

await new Predict(QA, lm).forward({ question: 'Capital of France?' });

expect(typeof lm.calls.at(-1)?.messages[0]?.content).toBe('string');
});
});
Loading
Loading