Skip to content
Draft
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
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
if: >-
github.event.action != 'labeled' ||
github.event.label.name == 'model-e2e'
runs-on: macos-latest
runs-on: macos-26
steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -129,7 +129,7 @@ jobs:
# Unit/integration tests don't depend on the label; skip the redundant re-run
# a `labeled` event would otherwise spawn (build artifacts are unchanged).
if: github.event.action != 'labeled'
runs-on: macos-latest
runs-on: macos-26
needs: build
strategy:
fail-fast: false
Expand Down Expand Up @@ -227,7 +227,7 @@ jobs:
github.event.label.name == 'model-e2e' ||
(github.event.action != 'labeled' &&
contains(github.event.pull_request.labels.*.name, 'model-e2e'))
runs-on: macos-latest
runs-on: macos-26
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -344,16 +344,22 @@ jobs:
else
# cargo leg: point this family's gating env var(s) at the converted
# checkpoint so the #[ignore]'d tests un-skip; `-- --ignored` runs only those.
# `--test-threads=1` serializes them: each ignored test loads 2-4 full
# models, and libtest's default per-test thread pool would otherwise run
# several at once, oversubscribing the single shared Metal GPU until a
# command buffer trips the GPU watchdog (kIOGPUCommandBufferCallbackErrorTimeout
# -> SIGABRT) on the shared CI runner. One shared GPU means parallelism
# never sped these up anyway, so serializing costs ~nothing.
for v in ${{ matrix.env }}; do export "$v=$ckpt"; done
cargo test -p mlx-core ${{ matrix.tests }} -- --ignored --nocapture
cargo test -p mlx-core ${{ matrix.tests }} -- --ignored --test-threads=1 --nocapture
fi

lint:
name: Rust Lint
# Lint doesn't depend on the label; skip the redundant re-run a `labeled`
# event would otherwise spawn.
if: github.event.action != 'labeled'
runs-on: macos-latest
runs-on: macos-26
steps:
- uses: actions/checkout@v6
with:
Expand Down
10 changes: 7 additions & 3 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ approvedGitRepositories:

catalog:
"@types/node": "npm:@types/node@24.12.2"
vite: "npm:@voidzero-dev/vite-plus-core@0.1.24"
vite-plus: 0.1.24
vitest: "npm:@voidzero-dev/vite-plus-test@0.1.24"
vite: npm:@voidzero-dev/vite-plus-core@0.0.0-commit.1d7ba811f0e456640b0e761e8e8acf77d1ad3251
vite-plus: 0.0.0-commit.1d7ba811f0e456640b0e761e8e8acf77d1ad3251
vitest: 4.1.9

enableScripts: true

Expand All @@ -18,3 +18,7 @@ npmScopes:
npmRegistryServer: "https://npm.jsr.io"

yarnPath: .yarn/releases/yarn-4.17.0.cjs
npmPreapprovedPackages:
- vitest
- '@vitest/*'
npmRegistryServer: https://registry-bridge.viteplus.dev/
76 changes: 58 additions & 18 deletions __test__/server/anthropic-request-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,31 +988,71 @@ describe('mapAnthropicRequest', () => {
expect(config.tools).toHaveLength(2);
});

it('rejects non-empty stop_sequences (no native ChatConfig.stopSequences yet)', () => {
// `stop_sequences` is parsed into the type but `ChatConfig` has no
// matching field. Silently dropping the field would let a client believe
// its stop strings are honoured. Reject explicitly until native support
// lands.
expect(() =>
mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: ['STOP'],
}),
).toThrow(/stop_sequences.*not supported/i);
it('accepts non-empty stop_sequences and threads them out as stopSequences', () => {
// `stop_sequences` is now carried out of the mapper (on the widened
// return) so a downstream consumer can honour the stop strings. It must
// NOT be added to `ChatConfig` (that would need a native rebuild).
const { config, stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: ['STOP'],
});
expect(stopSequences).toEqual(['STOP']);
expect(config).not.toHaveProperty('stopSequences');
expect(config).not.toHaveProperty('stop');
});

it('accepts empty stop_sequences array (treated as absent)', () => {
// Empty array carries no semantics — accept silently rather than 400 on
// a no-op field.
const { messages } = mapAnthropicRequest({
it('treats empty stop_sequences array as no stop strings', () => {
const { stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: [],
});
expect(messages).toEqual([{ role: 'user', content: 'Hello' }]);
expect(stopSequences).toEqual([]);
});

it('treats absent stop_sequences as no stop strings', () => {
const { stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
});
expect(stopSequences).toEqual([]);
});

it('filters empty strings out of stop_sequences', () => {
const { stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: ['', 'X'],
});
expect(stopSequences).toEqual(['X']);
});

it('filters whitespace-only strings out of stop_sequences', () => {
// The real Anthropic API rejects whitespace-only stops with a 400, and
// keeping them live would silently truncate normal output at the first
// space/newline. Dropping them makes such entries a no-op.
const { stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: [' ', '\n', '\t', ' ', 'X'],
});
expect(stopSequences).toEqual(['X']);
});

it('treats an all-whitespace stop_sequences array as no stop strings', () => {
const { stopSequences } = mapAnthropicRequest({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
stop_sequences: [' ', '\n'],
});
expect(stopSequences).toEqual([]);
});

it('maps max_tokens to maxNewTokens in config', () => {
Expand Down
51 changes: 51 additions & 0 deletions __test__/server/anthropic-response-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ describe('mapStopReason', () => {
it('maps unknown reason with tool calls to "tool_use"', () => {
expect(mapStopReason('unknown', true)).toBe('tool_use');
});

it('returns "stop_sequence" when a stop sequence matched', () => {
expect(mapStopReason('stop', false, 'HALT')).toBe('stop_sequence');
});

it('prioritises a matched stop sequence over "max_tokens"', () => {
expect(mapStopReason('length', false, 'HALT')).toBe('stop_sequence');
});

it('ignores a null matched stop sequence and keeps existing mapping', () => {
expect(mapStopReason('length', false, null)).toBe('max_tokens');
});
});

describe('buildAnthropicResponse', () => {
Expand Down Expand Up @@ -199,6 +211,38 @@ describe('buildAnthropicResponse', () => {
expect(response.stop_reason).toBe('max_tokens');
});

it('stop_reason is "stop_sequence" with a non-null stop_sequence when a stop sequence matched', () => {
const result = makeChatResult();
const response = buildAnthropicResponse(result, baseReq, 'msg_stop_seq', undefined, true, undefined, 'HALT');

expect(response.stop_reason).toBe('stop_sequence');
expect(response.stop_sequence).toBe('HALT');
});

it('suppresses tool_use blocks when a stop sequence matched (stop wins over tool_use)', () => {
// A matched stop halts generation at its position, so a tool call whose
// tag would have followed the stop boundary must not be emitted alongside
// `stop_reason: 'stop_sequence'`. The truncated visible text stays.
const result = makeChatResult({
text: 'keep this ',
toolCalls: [
{
id: 'toolu_stop',
name: 'get_weather',
arguments: '{"city":"SF"}',
status: 'ok',
rawContent: '',
},
],
});
const response = buildAnthropicResponse(result, baseReq, 'msg_stop_tool', undefined, true, undefined, 'HALT');

expect(response.stop_reason).toBe('stop_sequence');
expect(response.stop_sequence).toBe('HALT');
expect(response.content.some((b) => b.type === 'tool_use')).toBe(false);
expect(response.content).toEqual([{ type: 'text', text: 'keep this ' }]);
});

it('usage maps promptTokens → input_tokens and numTokens → output_tokens', () => {
const result = makeChatResult({ promptTokens: 42, numTokens: 7 });
const response = buildAnthropicResponse(result, baseReq, 'msg_usage');
Expand Down Expand Up @@ -539,6 +583,13 @@ describe('buildMessageDelta', () => {
expect(event.usage.output_tokens).toBe(42);
});

it('emits stop_reason "stop_sequence" with the matched stop_sequence', () => {
const event = buildMessageDelta('stop_sequence', 5, undefined, undefined, undefined, undefined, 'HALT');

expect(event.delta.stop_reason).toBe('stop_sequence');
expect(event.delta.stop_sequence).toBe('HALT');
});

it('passes through input_tokens when supplied without cachedTokens', () => {
const event = buildMessageDelta('end_turn', 5, 11);

Expand Down
Loading
Loading