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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel

## Unreleased

- DeepSeek-V4.1 DSML calls whose first parameter is the long one now stream from the invoke name instead of waiting for that parameter to close, as XML and GLM calls already do; a block that breaks after its name leaves the call it began while the whole reply keeps its text (#2191, #1975).

- DeepSeek-V4.1 DSML calls now stream from the invoke name, DSML's own tag, unlike GLM and XML calls, which have none; before, the header waited for the first parameter to close, so a call whose first parameter is the long one now streams while it is written. A DSML call malformed after its name may stream before the whole reply keeps it as text (#2191, #1975).

- Vision: image patch preparation is rewritten from published model cards and tested behaviour, with byte-equal grid sizes, normalized pixels and cache identities in 55,503 host cases (#2271).

- GLM on CUDA: the recipe notes that `TF_GLM_CHUNK_SOLO=4096` made prompts 12-14% slower with NVIDIA's NVFP4 checkpoint on two GB10s at `--parallel 1`, so leave it unset there (#2288).
Expand Down
29 changes: 19 additions & 10 deletions zig/src/server/stream_dsml_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ test "unfinished and malformed DSML never emit their complete nested call in con
defer arena.deinit();
const a = arena.allocator();
for (spellings) |sp| {
const prefix = try std.mem.concat(a, u8, &.{ sp.prefix, "<tool_call><function=lookup></function></tool_call>" });
for ([_][]const u8{ prefix, try std.mem.concat(a, u8, &.{ prefix, sp.unclosed }) }) |raw| {
const nested = "<tool_call><function=lookup></function></tool_call>";
const prefix = try std.mem.concat(a, u8, &.{ sp.prefix, nested });
// the whole reply keeps each block as text; the stream sent the header, the nested XML as its value
for ([_][2][]const u8{
.{ prefix, "{\"q\":\"" ++ nested },
.{ try std.mem.concat(a, u8, &.{ prefix, sp.unclosed }), "{\"q\":\"" ++ nested ++ "\"" },
}) |case| {
const raw = case[0];
const tokens = try TestText.encode(undefined, a, raw, false);
for ([_]bool{ false, true }) |batched| {
var backend: CallEngine = .{ .tokens = tokens, .batched = batched };
Expand All @@ -75,8 +81,8 @@ test "unfinished and malformed DSML never emit their complete nested call in con
try std.testing.expectEqual(@as(?u16, 200), whole.status);
try std.testing.expect(streamed.opened and streamed.status == null);
try std.testing.expectEqual(@as(usize, 0), whole.calls);
try std.testing.expectEqual(@as(usize, 0), streamed.calls);
try std.testing.expectEqual(@as(usize, 0), streamed.arguments_len);
try std.testing.expectEqual(@as(usize, 1), streamed.calls);
try std.testing.expectEqualStrings(case[1], streamed.arguments[0..streamed.arguments_len]);
try std.testing.expectEqual(@as(i64, 0), streamed.preparing);
}
}
Expand Down Expand Up @@ -147,19 +153,22 @@ test "DSML spellings mixed in one block stay text in whole and streamed consumer
defer arena.deinit();
const a = arena.allocator();
const v41 = spellings[1];
const mixed = [_][]const u8{
try std.mem.concat(a, u8, &.{ v41.prefix, "a", spellings[0].suffix }),
try std.mem.concat(a, u8, &.{ spellings[0].prefix, "a", v41.suffix }),
"<|DSML| calls><|DSML|invoke name=\"lookup\"><|DSML|parameter name=\"q\" string=\"true\">a</|DSML|parameter></|DSML|invoke></|DSML| calls>",
// a block whose opener spelling and closers disagree: the whole reply keeps it as text, the stream has the header
const cases = [_]struct { []const u8, usize, []const u8 }{
.{ try std.mem.concat(a, u8, &.{ v41.prefix, "a", spellings[0].suffix }), 1, "{\"q\":\"a</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>" },
.{ try std.mem.concat(a, u8, &.{ spellings[0].prefix, "a", v41.suffix }), 1, "{\"q\":\"a</|DSML| parameter></|DSML| invoke></|DSML| calls>" },
.{ "<|DSML| calls><|DSML|invoke name=\"lookup\"><|DSML|parameter name=\"q\" string=\"true\">a</|DSML|parameter></|DSML|invoke></|DSML| calls>", 0, "" },
};
for (mixed) |raw| {
for (cases) |case| {
const raw = case[0];
const tokens = try TestText.encode(undefined, a, raw, false);
for ([_]bool{ false, true }) |batched| {
var backend: CallEngine = .{ .tokens = tokens, .batched = batched };
const whole = try sendWith(backend.engine(), .chat, dsml_body ++ ",\"stream\":false}", .{});
const streamed = try sendWith(backend.engine(), .chat, dsml_body ++ ",\"stream\":true}", .{});
try std.testing.expectEqual(@as(usize, 0), whole.calls);
try std.testing.expectEqual(@as(usize, 0), streamed.calls);
try std.testing.expectEqual(case[1], streamed.calls);
try std.testing.expectEqualStrings(case[2], streamed.arguments[0..streamed.arguments_len]);
try std.testing.expectEqualStrings(raw[0..@min(raw.len, whole.text.len)], whole.content());
try std.testing.expectEqualStrings(whole.content(), streamed.content());
try std.testing.expectEqual(.stop, whole.finish.?);
Expand Down
7 changes: 5 additions & 2 deletions zig/src/server/tool_dsml_reply_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test "DSML calls agree whole and streamed in every spelling, with prose, typed v
};
}

test "a DSML block that breaks before its first header fails as it did: no call, its text kept, whole and streamed" {
test "a DSML block that breaks before its invoke name fails as it did: no call, its text kept, whole and streamed" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
Expand All @@ -90,7 +90,10 @@ test "a DSML call max_tokens cuts after its header returns cut whole and streame
const reply = try std.mem.concat(a, u8, &.{ "Writing it.\n", try t.block(a, sp, &.{try t.invoke(a, sp, "write_file", &.{ .{ "path", true, "notes.txt" }, .{ "content", true, "The history of the bicycle" } })}) });
const cases = [_]struct { usize, usize, []const u8 }{
.{ std.mem.indexOf(u8, reply, " the bicycle").?, 1, "{\"path\":\"notes.txt\",\"content\":\"The history of" },
.{ std.mem.indexOf(u8, reply, "tes.txt").?, 0, "" },
// cut inside the first parameter: its header is out, so the cut reply returns the call it began
.{ std.mem.indexOf(u8, reply, "tes.txt").?, 1, "{\"path\":\"no" },
// cut inside the invoke name: no header went out, so both replies make no call
.{ std.mem.indexOf(u8, reply, "write_file").?, 0, "" },
};
for (cases) |case| {
const body = try chatBody(a, t.tools_text, try std.fmt.allocPrint(a, ",\"max_tokens\":{d}", .{case[0]}));
Expand Down
39 changes: 9 additions & 30 deletions zig/src/server/tool_dsml_stream.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! DSML invokes as written: a call goes out once its first parameter is whole in the block's spelling, then streams.
//! DSML invokes as written: a call goes out at its invoke name, then its values' JSON bytes as they arrive.
const std = @import("std");
const json = @import("json");
const reply_text = @import("reply_text.zig");
Expand All @@ -23,20 +23,17 @@ pub const Dsml = struct {
at: usize = 0, // where the stage's reading resumes
from: usize = 0, // the open name's, key's or value's first byte
name: []const u8 = "", // the open invoke's offered name
header: bool = false, // the open invoke's header went out
params: usize = 0, // the open invoke's whole parameters
sent: usize = 0, // the block's headers that went out
typed: bool = false, // the open value is JSON (``string="false"``)
value_sent: usize = 0, // the open value's next byte to send
held: std.ArrayList(u8) = .empty, // the open invoke's arguments, written before its header
};

/// The reply's ``text`` so far: the deltas the open DSML block adds, and what the Streamer should do next.
pub fn feed(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.Error!Next {
while (true) {
const next = switch (s.dsml.stage) {
.invokes => invokes(s, text),
.name => try name(s, text),
.name => try name(s, text, out),
.params => try params(s, text, out),
.key => try key(s, text, out),
.value => try value(s, text, out),
Expand All @@ -48,7 +45,7 @@ pub fn feed(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.
/// The cut the reply's end left inside a streamed value: the bytes held back for a closer that never came.
pub fn cut(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.Error!void {
const d = &s.dsml;
if (d.stage != .value or !d.header) return;
if (d.stage != .value) return;
const end = text.len - closerTail(text[@min(d.from, text.len)..], d.sp);
try sendValue(s, text, end, out);
if (d.typed) try send(s, out, try s.a.dupe(u8, s.buffer.items)); // the value's JSON never closed
Expand Down Expand Up @@ -77,8 +74,8 @@ fn invokes(s: anytype, text: []const u8) Next {
return if (open == .part or close == .part) .wait else .rejected;
}

/// An invoke's name: the bytes to the first quote, which ``>`` must follow, naming an offered tool.
fn name(s: anytype, text: []const u8) Allocator.Error!Next {
/// An invoke's name: the bytes to the first quote, then ``>``; an offered name sends its header.
fn name(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.Error!Next {
const d = &s.dsml;
const q = std.mem.indexOfScalarPos(u8, text, d.at, '"');
if (has(text, d.from, d.at, q orelse text.len, d.sp.close)) return .rejected; // the block closed in the name
Expand All @@ -91,10 +88,8 @@ fn name(s: anytype, text: []const u8) Allocator.Error!Next {
if (text[at + 1] != '>') return .rejected;
const written = reply_text.pyStrip(text[d.from..at]);
d.name = s.known.get(try std.ascii.allocLowerString(s.a, written)) orelse return .rejected;
d.header = false;
d.params = 0;
d.held.clearRetainingCapacity();
s.args_open = false;
try s.begin(d.name, out);
d.sent += 1;
d.at = at + 2;
d.stage = .params;
return .more;
Expand All @@ -107,14 +102,12 @@ fn params(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.Er
const param = tagAt(text, i, d.sp.param_open);
const close = tagAt(text, i, d.sp.invoke_close);
if (param == .whole) {
if (d.params > 0) try decide(s, out); // a whole parameter, then this spelling's next opener
d.from = i + d.sp.param_open.len;
d.at = d.from;
d.stage = .key;
return .more;
}
if (close == .whole) {
try decide(s, out);
try send(s, out, if (s.args_open) "}" else "{}");
d.at = i + d.sp.invoke_close.len;
d.stage = .invokes;
Expand Down Expand Up @@ -172,28 +165,14 @@ fn value(s: anytype, text: []const u8, out: *std.ArrayList(Value)) Allocator.Err
};
try send(s, out, try json.stringify(s.a, v, .{ .ascii = false, .compact = true }));
} else try send(s, out, "\"");
d.params += 1;
d.at = e + d.sp.param_close.len;
d.stage = .params;
return .more;
}

/// The open invoke's header, once, then the arguments written before it as one piece.
fn decide(s: anytype, out: *std.ArrayList(Value)) Allocator.Error!void {
const d = &s.dsml;
if (d.header) return;
const args_open = s.args_open;
try s.begin(d.name, out);
s.args_open = args_open;
d.header = true;
d.sent += 1;
if (d.held.items.len > 0) try out.append(s.a, try s.args(try s.a.dupe(u8, d.held.items)));
d.held.clearRetainingCapacity();
}

/// An arguments piece: out under the open header, else held until the header goes out.
/// An arguments piece: out under the invoke's header, which went out before any key or value wrote one.
fn send(s: anytype, out: *std.ArrayList(Value), piece: []const u8) Allocator.Error!void {
if (s.dsml.header) try out.append(s.a, try s.args(piece)) else try s.dsml.held.appendSlice(s.a, piece);
try out.append(s.a, try s.args(piece));
}

/// The value's bytes from ``value_sent`` to ``end``: escaped into the arguments, or into the buffer JSON decodes.
Expand Down
Loading
Loading