Preserve live vision context across server tool calls
Reuse bounded image embeddings, preserve exact image identities while appending new images, and splice image tokens into authenticated live continuations. Handle image tool results in Responses and Anthropic requests as well as Chat Completions. Normalize request-local image markers for visible replay without trusting text placeholders as image identities. Add prefix and encoder-cache unit coverage plus real-model image replay and Pi coding-client regressions. Validated GLM 5.3 Flash and DeepSeek Vision-Exp on M5 Max, including 53K/56K prefixes, batched sessions, all three API formats, fresh logit replay, and frontend ASan/UBSan.
This commit is contained in:
1 parent
6546ae5473
commit
81a76585ff
6 files changed
+844
-63
No files matched your search
@@ -66666,12 +66666,12 @@ static void ds4_session_note_prefill_progress(void *ud, const char *event, int c
|
||||
*/
|
||||
static int ds4_session_sync_internal(ds4_session *s, const ds4_tokens *prompt, char *err, size_t errlen);
|
||||
|
||||
static bool ds4_session_vision_prefix_matches(
|
||||
bool ds4_session_vision_prefix_matches(
|
||||
const ds4_session *s,
|
||||
const ds4_vision_span *images,
|
||||
size_t image_count) {
|
||||
if (!s || (image_count != 0 && !images)) return false;
|
||||
if (!s->checkpoint_valid) return true;
|
||||
if (!s->checkpoint_valid) return false;
|
||||
if (s->checkpoint_image_count > image_count) return false;
|
||||
for (size_t i = 0; i < s->checkpoint_image_count; i++) {
|
||||
const ds4_vision_identity *old = &s->checkpoint_images[i];
|
||||
@@ -66697,6 +66697,20 @@ bool ds4_session_vision_state_matches(
|
||||
ds4_session_vision_prefix_matches(s, images, image_count);
|
||||
}
|
||||
|
||||
bool ds4_session_rebase_vision_state(const ds4_session *s,
|
||||
ds4_vision_span *images, size_t image_count) {
|
||||
if (!s || !s->checkpoint_valid || (image_count && !images) ||
|
||||
image_count != s->checkpoint_image_count) return false;
|
||||
for (size_t i = 0; i < image_count; i++) {
|
||||
if (images[i].embedding.token_count != s->checkpoint_images[i].token_count ||
|
||||
memcmp(images[i].embedding.fingerprint, s->checkpoint_images[i].fingerprint,
|
||||
sizeof(images[i].embedding.fingerprint))) return false;
|
||||
}
|
||||
for (size_t i = 0; i < image_count; i++)
|
||||
images[i].token_start = s->checkpoint_images[i].token_start;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ds4_session_has_vision_state(const ds4_session *s) {
|
||||
return s && (s->checkpoint_image_count != 0 || s->sync_image_count != 0);
|
||||
}
|
||||
@@ -66742,7 +66756,7 @@ static bool ds4_session_store_vision_identities(ds4_session *s) {
|
||||
* once its matching prefill completes, surfacing worker-side failures
|
||||
* here instead of as a gate timeout mid-decode. */
|
||||
int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, char *err, size_t errlen) {
|
||||
if (s && !ds4_session_vision_prefix_matches(
|
||||
if (s && s->checkpoint_valid && !ds4_session_vision_prefix_matches(
|
||||
s, s->sync_images, s->sync_image_count)) {
|
||||
ds4_session_invalidate(s);
|
||||
}
|
||||
|
||||
@@ -428,11 +428,21 @@ int ds4_session_sync_multimodal(ds4_session *s,
|
||||
size_t image_count,
|
||||
char *err,
|
||||
size_t errlen);
|
||||
/* Return true only when every image that conditioned the live checkpoint has
|
||||
* the same token span and embedding fingerprint in the supplied prompt. */
|
||||
/* A reusable image prefix has unchanged spans/fingerprints for all historical
|
||||
* images, with any new images starting at or after the live token frontier.
|
||||
* The caller must also check the token prefix. Invalid checkpoints never match. */
|
||||
bool ds4_session_vision_prefix_matches(const ds4_session *s,
|
||||
const ds4_vision_span *images,
|
||||
size_t image_count);
|
||||
/* Like the prefix check, but also require exactly the same image count. */
|
||||
bool ds4_session_vision_state_matches(const ds4_session *s,
|
||||
const ds4_vision_span *images,
|
||||
size_t image_count);
|
||||
/* Restore image positions from an independently authenticated live continuation
|
||||
* (for example, matching tool-call IDs). Checks every fingerprint and row count;
|
||||
* on failure, leaves spans unchanged. This does not verify the text history. */
|
||||
bool ds4_session_rebase_vision_state(const ds4_session *s,
|
||||
ds4_vision_span *images, size_t image_count);
|
||||
/* True while a session contains, or is actively syncing, image-conditioned
|
||||
* state. Such state must not be written to the text-keyed disk KV cache. */
|
||||
bool ds4_session_has_vision_state(const ds4_session *s);
|
||||
|
||||
+487
-58
@@ -698,6 +698,9 @@ static void random_tool_id(char *dst, size_t dstlen, api_style api) {
|
||||
typedef struct server server;
|
||||
static void server_inference_lock(server *s);
|
||||
static void server_inference_unlock(server *s);
|
||||
static bool server_encode_image(server *s, const server_image_input *input,
|
||||
ds4_vision_embedding *out,
|
||||
char *err, size_t errlen);
|
||||
|
||||
typedef struct {
|
||||
char *id;
|
||||
@@ -780,6 +783,7 @@ typedef struct {
|
||||
server_model_syntax model_syntax;
|
||||
ds4_tokens prompt;
|
||||
ds4_vision_span *images;
|
||||
char (*image_markers)[SERVER_IMAGE_MARKER_BYTES];
|
||||
size_t image_count;
|
||||
char *model;
|
||||
bool model_from_request;
|
||||
@@ -980,6 +984,7 @@ static void request_free(request *r) {
|
||||
for (size_t i = 0; i < r->image_count; i++)
|
||||
ds4_vision_embedding_free(&r->images[i].embedding);
|
||||
free(r->images);
|
||||
free(r->image_markers);
|
||||
free(r->model);
|
||||
for (int i = 0; i < r->stops.len; i++) free(r->stops.v[i]);
|
||||
free(r->stops.v);
|
||||
@@ -2090,12 +2095,13 @@ static bool parse_anthropic_image_source(const char **p,
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_anthropic_content(const char **p, chat_msg *msg, bool allow_tools);
|
||||
|
||||
/* Anthropic content is block-structured, while the engine consumes one compact
|
||||
* chat_msg per role. Parsing collapses text/thinking into strings, converts
|
||||
* assistant tool_use blocks to tool_calls, and keeps tool_result blocks as
|
||||
* escaped text because DS4 sees tool results in its chat template. */
|
||||
static bool parse_anthropic_content_block(const char **p, const char *role, chat_msg *msg) {
|
||||
(void)role;
|
||||
static bool parse_anthropic_content_block(const char **p, bool allow_tools, chat_msg *msg) {
|
||||
if (**p != '{') return false;
|
||||
(*p)++;
|
||||
char *type = NULL;
|
||||
@@ -2150,7 +2156,7 @@ static bool parse_anthropic_content_block(const char **p, const char *role, chat
|
||||
goto bad;
|
||||
}
|
||||
} else if (!strcmp(key, "content")) {
|
||||
if (!json_content_replace(p, &tool_result)) {
|
||||
if (!json_raw_value_replace(p, &tool_result)) {
|
||||
free(key);
|
||||
goto bad;
|
||||
}
|
||||
@@ -2177,6 +2183,8 @@ static bool parse_anthropic_content_block(const char **p, const char *role, chat
|
||||
* caller may not know the enclosing role yet while parsing content blocks.
|
||||
* Classify protocol blocks by their own "type" field; later rendering and
|
||||
* validation use the final message role. */
|
||||
if (!allow_tools && (!type || (strcmp(type, "text") && strcmp(type, "image"))))
|
||||
goto bad;
|
||||
if (type && !strcmp(type, "tool_use")) {
|
||||
tool_call tc = {0};
|
||||
tc.id = id ? xstrdup(id) : NULL;
|
||||
@@ -2184,14 +2192,29 @@ static bool parse_anthropic_content_block(const char **p, const char *role, chat
|
||||
tc.arguments = input ? xstrdup(input) : xstrdup("{}");
|
||||
tool_calls_push(&msg->calls, tc);
|
||||
} else if (type && !strcmp(type, "tool_result")) {
|
||||
chat_msg nested = {0};
|
||||
const char *content = tool_result ? tool_result : "\"\"";
|
||||
if (!parse_anthropic_content(&content, &nested, false)) {
|
||||
chat_msg_free(&nested);
|
||||
goto bad;
|
||||
}
|
||||
chat_msg_add_tool_call_id(msg, id);
|
||||
buf b = {0};
|
||||
buf_puts(&b, msg->content ? msg->content : "");
|
||||
buf_puts(&b, "<tool_result>");
|
||||
append_tool_result_text(&b, tool_result);
|
||||
append_tool_result_text(&b, nested.content);
|
||||
buf_puts(&b, "</tool_result>");
|
||||
free(msg->content);
|
||||
msg->content = buf_take(&b);
|
||||
if (nested.images.len) {
|
||||
size_t total = msg->images.len + nested.images.len;
|
||||
msg->images.v = xrealloc(msg->images.v, total * sizeof(msg->images.v[0]));
|
||||
memcpy(msg->images.v + msg->images.len, nested.images.v,
|
||||
nested.images.len * sizeof(msg->images.v[0]));
|
||||
msg->images.len = msg->images.cap = total;
|
||||
nested.images.len = 0;
|
||||
}
|
||||
chat_msg_free(&nested);
|
||||
} else if (type && !strcmp(type, "image")) {
|
||||
char marker[SERVER_IMAGE_MARKER_BYTES];
|
||||
if (!source_type || strcmp(source_type, "base64") ||
|
||||
@@ -2241,14 +2264,14 @@ bad:
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool parse_anthropic_content(const char **p, chat_msg *msg) {
|
||||
static bool parse_anthropic_content(const char **p, chat_msg *msg, bool allow_tools) {
|
||||
json_ws(p);
|
||||
if (**p == '"') return json_string(p, &msg->content);
|
||||
if (json_lit(p, "null")) {
|
||||
msg->content = xstrdup("");
|
||||
return true;
|
||||
}
|
||||
if (**p != '[') return json_skip_value(p);
|
||||
if (**p != '[') return allow_tools ? json_skip_value(p) : false;
|
||||
(*p)++;
|
||||
json_ws(p);
|
||||
while (**p && **p != ']') {
|
||||
@@ -2262,7 +2285,7 @@ static bool parse_anthropic_content(const char **p, chat_msg *msg) {
|
||||
msg->content = buf_take(&b);
|
||||
free(s);
|
||||
} else if (**p == '{') {
|
||||
if (!parse_anthropic_content_block(p, msg->role ? msg->role : "", msg)) return false;
|
||||
if (!parse_anthropic_content_block(p, allow_tools, msg)) return false;
|
||||
} else if (!json_skip_value(p)) {
|
||||
return false;
|
||||
}
|
||||
@@ -2304,7 +2327,7 @@ static bool parse_anthropic_messages(const char **p, chat_msgs *msgs) {
|
||||
} else if (!strcmp(key, "content")) {
|
||||
free(msg.content);
|
||||
msg.content = NULL;
|
||||
if (!parse_anthropic_content(p, &msg)) {
|
||||
if (!parse_anthropic_content(p, &msg, true)) {
|
||||
free(key);
|
||||
goto fail;
|
||||
}
|
||||
@@ -3119,9 +3142,7 @@ static bool request_tokenize_multimodal_prompt(ds4_engine *e, server *s,
|
||||
bool ok = true;
|
||||
server_inference_lock(s);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (!ds4_engine_vision_encode_memory(e, inputs[i]->encoded,
|
||||
inputs[i]->encoded_len,
|
||||
&embeddings[i], err, errlen)) {
|
||||
if (!server_encode_image(s, inputs[i], &embeddings[i], err, errlen)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -3130,6 +3151,7 @@ static bool request_tokenize_multimodal_prompt(ds4_engine *e, server *s,
|
||||
if (!ok) goto done;
|
||||
|
||||
r->images = xmalloc(count * sizeof(r->images[0]));
|
||||
r->image_markers = xmalloc(count * sizeof(r->image_markers[0]));
|
||||
memset(r->images, 0, count * sizeof(r->images[0]));
|
||||
const char *cursor = r->prompt_text;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
@@ -3148,6 +3170,7 @@ static bool request_tokenize_multimodal_prompt(ds4_engine *e, server *s,
|
||||
break;
|
||||
}
|
||||
r->image_count++;
|
||||
memcpy(r->image_markers[i], inputs[i]->marker, sizeof(r->image_markers[i]));
|
||||
cursor = marker + strlen(inputs[i]->marker);
|
||||
}
|
||||
if (ok) ds4_tokenize_rendered_chat(e, cursor, &r->prompt);
|
||||
@@ -3162,6 +3185,8 @@ done:
|
||||
for (size_t i = 0; i < r->image_count; i++)
|
||||
ds4_vision_embedding_free(&r->images[i].embedding);
|
||||
free(r->images);
|
||||
free(r->image_markers);
|
||||
r->image_markers = NULL;
|
||||
r->images = NULL;
|
||||
r->image_count = 0;
|
||||
}
|
||||
@@ -4145,7 +4170,7 @@ static bool parse_responses_input(const char **p, chat_msgs *msgs,
|
||||
char *result = NULL;
|
||||
char *tools_json = NULL;
|
||||
char *status_str = NULL;
|
||||
server_image_inputs content_images = {0};
|
||||
server_image_inputs content_images = {0}, output_images = {0};
|
||||
json_ws(p);
|
||||
while (**p && **p != '}') {
|
||||
char *key = NULL;
|
||||
@@ -4204,9 +4229,10 @@ static bool parse_responses_input(const char **p, chat_msgs *msgs,
|
||||
goto item_fail;
|
||||
}
|
||||
} else if (!strcmp(key, "output")) {
|
||||
server_image_inputs_free(&output_images);
|
||||
json_ws(p);
|
||||
if (**p == '[') {
|
||||
if (!parse_responses_content_array_replace(p, &output)) {
|
||||
if (!parse_responses_content_array_multimodal(p, &output, &output_images)) {
|
||||
free(key);
|
||||
goto item_fail;
|
||||
}
|
||||
@@ -4291,6 +4317,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
buf_free(&pending_reasoning);
|
||||
return false;
|
||||
}
|
||||
@@ -4311,6 +4338,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
goto fail;
|
||||
}
|
||||
(*p)++;
|
||||
@@ -4339,11 +4367,14 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
buf_free(&pending_reasoning);
|
||||
return false;
|
||||
}
|
||||
if (content_images.len &&
|
||||
(strcmp(t, "message") || (role && strcmp(role, "user")))) {
|
||||
if ((content_images.len &&
|
||||
(strcmp(t, "message") || (role && strcmp(role, "user")))) ||
|
||||
(output_images.len && strcmp(t, "function_call_output") &&
|
||||
strcmp(t, "custom_tool_call_output"))) {
|
||||
free(type);
|
||||
free(role);
|
||||
free(content);
|
||||
@@ -4360,6 +4391,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
buf_free(&pending_reasoning);
|
||||
return false;
|
||||
}
|
||||
@@ -4441,6 +4473,8 @@ item_fail:
|
||||
msg.role = xstrdup("tool");
|
||||
msg.content = output ? output : xstrdup("");
|
||||
output = NULL;
|
||||
msg.images = output_images;
|
||||
memset(&output_images, 0, sizeof(output_images));
|
||||
if (call_id || item_id) {
|
||||
chat_msg_add_tool_call_id(&msg, call_id ? call_id : item_id);
|
||||
}
|
||||
@@ -4520,6 +4554,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
buf_free(&pending_reasoning);
|
||||
return false;
|
||||
}
|
||||
@@ -4561,6 +4596,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
buf_free(&pending_reasoning);
|
||||
return false;
|
||||
}
|
||||
@@ -4581,6 +4617,7 @@ item_fail:
|
||||
free(tools_json);
|
||||
free(status_str);
|
||||
server_image_inputs_free(&content_images);
|
||||
server_image_inputs_free(&output_images);
|
||||
json_ws(p);
|
||||
if (**p == ',') (*p)++;
|
||||
json_ws(p);
|
||||
@@ -9073,6 +9110,46 @@ typedef struct {
|
||||
uint64_t scan_clock;
|
||||
} tool_memory;
|
||||
|
||||
/* Image markers have request-local nonces. Normalize only actual marker spans
|
||||
* for visible-history matching, and retain their byte offsets so literal text
|
||||
* cannot impersonate an image. Full fingerprints are checked against live KV
|
||||
* before any prefix is reused. Normalization preserves all byte offsets. */
|
||||
typedef struct {
|
||||
size_t count;
|
||||
size_t offsets[16];
|
||||
} visible_image_key;
|
||||
|
||||
static char *visible_prompt_key(const request *req, const char *text,
|
||||
visible_image_key *images) {
|
||||
memset(images, 0, sizeof(*images));
|
||||
if (!req || !text || req->image_count > 16 ||
|
||||
(req->image_count && !req->image_markers)) return NULL;
|
||||
char *key = xstrdup(text);
|
||||
const char *cursor = text;
|
||||
for (size_t i = 0; i < req->image_count; i++) {
|
||||
const char *marker = strstr(cursor, req->image_markers[i]);
|
||||
size_t len = strlen(req->image_markers[i]);
|
||||
if (!marker || !len) {
|
||||
free(key);
|
||||
return NULL;
|
||||
}
|
||||
images->offsets[images->count++] = (size_t)(marker - text);
|
||||
memset(key + (marker - text), '\036', len);
|
||||
cursor = marker + len;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
static bool visible_image_prefix_matches(const visible_image_key *incoming,
|
||||
const visible_image_key *old,
|
||||
size_t boundary) {
|
||||
if (incoming->count < old->count) return false;
|
||||
for (size_t i = 0; i < old->count; i++)
|
||||
if (incoming->offsets[i] != old->offsets[i]) return false;
|
||||
return incoming->count == old->count ||
|
||||
incoming->offsets[old->count] >= boundary;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
bool valid;
|
||||
/* Token frontier of a live assistant tool-call turn. Continuing from this
|
||||
@@ -9084,6 +9161,7 @@ typedef struct {
|
||||
* Anthropic currently uses only the call-id side of the state. */
|
||||
char *visible_text;
|
||||
size_t visible_len;
|
||||
visible_image_key images;
|
||||
/* Tool-call ids generated at the same live frontier. A following tool
|
||||
* result for these ids is a direct protocol continuation and should not
|
||||
* trigger prompt-prefix matching or checkpoint canonicalization. */
|
||||
@@ -9099,6 +9177,7 @@ typedef struct {
|
||||
int live_tokens;
|
||||
char *visible_text;
|
||||
size_t visible_len;
|
||||
visible_image_key images;
|
||||
} visible_live_state;
|
||||
|
||||
struct server_slot {
|
||||
@@ -9123,6 +9202,101 @@ struct server_slot {
|
||||
char decode_err[160];
|
||||
};
|
||||
|
||||
#define SERVER_IMAGE_CACHE_ENTRIES 32
|
||||
#define SERVER_IMAGE_CACHE_BYTES (128u * 1024u * 1024u)
|
||||
|
||||
typedef struct {
|
||||
uint8_t *encoded;
|
||||
size_t encoded_len;
|
||||
ds4_vision_embedding embedding;
|
||||
size_t data_bytes;
|
||||
uint64_t used;
|
||||
} server_image_cache_entry;
|
||||
|
||||
typedef struct {
|
||||
server_image_cache_entry entries[SERVER_IMAGE_CACHE_ENTRIES];
|
||||
size_t bytes;
|
||||
uint64_t clock;
|
||||
} server_image_cache;
|
||||
|
||||
static void server_image_cache_remove(server_image_cache *cache,
|
||||
server_image_cache_entry *entry) {
|
||||
cache->bytes -= entry->encoded_len + entry->data_bytes;
|
||||
free(entry->encoded);
|
||||
ds4_vision_embedding_free(&entry->embedding);
|
||||
memset(entry, 0, sizeof(*entry));
|
||||
}
|
||||
|
||||
static void server_image_cache_clear(server_image_cache *cache) {
|
||||
for (size_t i = 0; i < SERVER_IMAGE_CACHE_ENTRIES; i++)
|
||||
server_image_cache_remove(cache, &cache->entries[i]);
|
||||
cache->clock = 0;
|
||||
}
|
||||
|
||||
static bool server_image_cache_get(server_image_cache *cache,
|
||||
const server_image_input *input,
|
||||
ds4_vision_embedding *out) {
|
||||
for (size_t i = 0; i < SERVER_IMAGE_CACHE_ENTRIES; i++) {
|
||||
server_image_cache_entry *entry = &cache->entries[i];
|
||||
if (!entry->encoded || entry->encoded_len != input->encoded_len ||
|
||||
memcmp(entry->encoded, input->encoded, input->encoded_len)) continue;
|
||||
float *data = malloc(entry->data_bytes);
|
||||
if (!data) return false;
|
||||
memcpy(data, entry->embedding.data, entry->data_bytes);
|
||||
*out = entry->embedding;
|
||||
out->data = data;
|
||||
entry->used = ++cache->clock;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Cache only successful encodes. Exact byte keys avoid trusting a client hash;
|
||||
* callers own a copy, since DeepSeek expands the natural embedding in place. */
|
||||
static void server_image_cache_put(server_image_cache *cache,
|
||||
const server_image_input *input,
|
||||
const ds4_vision_embedding *embedding,
|
||||
uint32_t dim, size_t budget) {
|
||||
if (!embedding->data || !dim || !embedding->token_count ||
|
||||
!input->encoded_len ||
|
||||
embedding->token_count > budget / sizeof(float) / dim) return;
|
||||
size_t bytes = (size_t)embedding->token_count * dim * sizeof(float);
|
||||
if (input->encoded_len > budget - bytes) return;
|
||||
size_t total = input->encoded_len + bytes;
|
||||
server_image_cache_entry *dest;
|
||||
for (;;) {
|
||||
dest = &cache->entries[0];
|
||||
for (size_t i = 1; i < SERVER_IMAGE_CACHE_ENTRIES; i++) {
|
||||
if (cache->entries[i].used < dest->used) dest = &cache->entries[i];
|
||||
}
|
||||
if (!dest->encoded && cache->bytes <= budget - total) break;
|
||||
if (!dest->encoded) {
|
||||
for (size_t i = 0; i < SERVER_IMAGE_CACHE_ENTRIES; i++) {
|
||||
server_image_cache_entry *entry = &cache->entries[i];
|
||||
if (entry->encoded && (!dest->encoded || entry->used < dest->used))
|
||||
dest = entry;
|
||||
}
|
||||
}
|
||||
server_image_cache_remove(cache, dest);
|
||||
}
|
||||
uint8_t *encoded = malloc(input->encoded_len);
|
||||
float *data = malloc((size_t)bytes);
|
||||
if (!encoded || !data) {
|
||||
free(encoded);
|
||||
free(data);
|
||||
return;
|
||||
}
|
||||
memcpy(encoded, input->encoded, input->encoded_len);
|
||||
memcpy(data, embedding->data, (size_t)bytes);
|
||||
*dest = (server_image_cache_entry) {
|
||||
.encoded = encoded, .encoded_len = input->encoded_len,
|
||||
.embedding = *embedding, .data_bytes = (size_t)bytes,
|
||||
.used = ++cache->clock,
|
||||
};
|
||||
dest->embedding.data = data;
|
||||
cache->bytes += total;
|
||||
}
|
||||
|
||||
static bool id_list_contains(const stop_list *ids, const char *id);
|
||||
static void id_list_push_unique(stop_list *ids, const char *id);
|
||||
|
||||
@@ -9138,6 +9312,7 @@ struct server {
|
||||
int default_tokens;
|
||||
kv_disk_cache kv;
|
||||
tool_memory tool_mem;
|
||||
server_image_cache image_cache; /* Protected by inference_mu. */
|
||||
bool disable_exact_dsml_tool_replay;
|
||||
bool enable_cors;
|
||||
pthread_mutex_t tool_mu;
|
||||
@@ -9171,6 +9346,25 @@ static void server_inference_unlock(server *s) {
|
||||
pthread_mutex_unlock(&s->inference_mu);
|
||||
}
|
||||
|
||||
/* The caller holds inference_mu across cache lookup and encoder execution. */
|
||||
static bool server_encode_image(server *s, const server_image_input *input,
|
||||
ds4_vision_embedding *out,
|
||||
char *err, size_t errlen) {
|
||||
if (server_image_cache_get(&s->image_cache, input, out)) {
|
||||
server_log(DS4_LOG_KVCACHE, "ds4-server: vision embedding cache hit");
|
||||
return true;
|
||||
}
|
||||
if (!ds4_engine_vision_encode_memory(s->engine, input->encoded,
|
||||
input->encoded_len, out, err, errlen))
|
||||
return false;
|
||||
server_image_cache_put(&s->image_cache, input, out,
|
||||
4096, /* Both supported vision encoders emit 4096-wide rows. */
|
||||
SERVER_IMAGE_CACHE_BYTES);
|
||||
server_log(DS4_LOG_KVCACHE, "ds4-server: vision embedding encoded; cache=%zu bytes",
|
||||
s->image_cache.bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Jobs are stack-owned by the client thread. A resident-slot worker signals
|
||||
* completion after it has written the response, so request data and the socket
|
||||
* remain valid without heap-allocating per-request job objects. */
|
||||
@@ -9412,6 +9606,7 @@ static void live_tool_state_clear_locked(live_tool_state *st) {
|
||||
free(st->visible_text);
|
||||
st->visible_text = NULL;
|
||||
st->visible_len = 0;
|
||||
memset(&st->images, 0, sizeof(st->images));
|
||||
st->valid = false;
|
||||
st->live_tokens = 0;
|
||||
}
|
||||
@@ -9428,6 +9623,7 @@ static void visible_live_clear_locked(visible_live_state *st) {
|
||||
free(st->visible_text);
|
||||
st->visible_text = NULL;
|
||||
st->visible_len = 0;
|
||||
memset(&st->images, 0, sizeof(st->images));
|
||||
st->live_tokens = 0;
|
||||
st->valid = false;
|
||||
}
|
||||
@@ -9446,32 +9642,38 @@ static void thinking_live_clear(server *s, server_slot *slot) {
|
||||
}
|
||||
|
||||
static void thinking_live_remember(server *s, server_slot *slot,
|
||||
const char *visible_text) {
|
||||
const char *visible_text, const request *req) {
|
||||
if (!s || !slot || !visible_text || !visible_text[0]) return;
|
||||
visible_image_key images;
|
||||
char *key = visible_prompt_key(req, visible_text, &images);
|
||||
pthread_mutex_lock(&s->tool_mu);
|
||||
visible_live_clear_locked(&slot->thinking_live);
|
||||
slot->thinking_live.visible_text = xstrdup(visible_text);
|
||||
slot->thinking_live.visible_len = strlen(visible_text);
|
||||
slot->thinking_live.visible_text = key;
|
||||
slot->thinking_live.visible_len = key ? strlen(key) : 0;
|
||||
slot->thinking_live.images = images;
|
||||
slot->thinking_live.live_tokens = ds4_session_pos(slot->session);
|
||||
slot->thinking_live.valid = true;
|
||||
slot->thinking_live.valid = key != NULL;
|
||||
pthread_mutex_unlock(&s->tool_mu);
|
||||
}
|
||||
|
||||
static void responses_live_remember(server *s, server_slot *slot,
|
||||
const char *visible_text,
|
||||
const tool_calls *calls) {
|
||||
const tool_calls *calls, const request *req) {
|
||||
if (!s || !slot || !visible_text || !visible_text[0]) return;
|
||||
visible_image_key images;
|
||||
char *key = visible_prompt_key(req, visible_text, &images);
|
||||
pthread_mutex_lock(&s->tool_mu);
|
||||
live_tool_state_clear_locked(&slot->responses_live);
|
||||
slot->responses_live.visible_text = xstrdup(visible_text);
|
||||
slot->responses_live.visible_len = strlen(visible_text);
|
||||
slot->responses_live.visible_text = key;
|
||||
slot->responses_live.visible_len = key ? strlen(key) : 0;
|
||||
slot->responses_live.images = images;
|
||||
if (calls) {
|
||||
for (int i = 0; i < calls->len; i++) {
|
||||
id_list_push_unique(&slot->responses_live.call_ids, calls->v[i].id);
|
||||
}
|
||||
}
|
||||
slot->responses_live.live_tokens = ds4_session_pos(slot->session);
|
||||
slot->responses_live.valid = true;
|
||||
slot->responses_live.valid = key != NULL;
|
||||
pthread_mutex_unlock(&s->tool_mu);
|
||||
}
|
||||
|
||||
@@ -10392,6 +10594,57 @@ static int kv_cache_try_load(server *s, server_slot *slot, const request *req,
|
||||
req && req->api == API_RESPONSES);
|
||||
}
|
||||
|
||||
/* A text-only suffix tokenizer would turn image markers into literal text.
|
||||
* Keep the exact live tokens and splice each new image's already-built token
|
||||
* block into the suffix. Historical image positions follow the live frontier,
|
||||
* which may retain reasoning omitted from a client's visible replay. */
|
||||
static bool build_live_prompt_suffix(server *s, server_slot *slot,
|
||||
const request *req, const char *suffix,
|
||||
ds4_tokens *out) {
|
||||
const ds4_tokens *live = ds4_session_tokens(slot->session);
|
||||
if (!live || !suffix || req->image_count > 16) return false;
|
||||
ds4_vision_span spans[16];
|
||||
size_t old_count = req->image_count;
|
||||
for (size_t i = 0; i < req->image_count; i++) {
|
||||
if (!req->image_markers) return false;
|
||||
spans[i] = req->images[i];
|
||||
if (old_count == req->image_count &&
|
||||
strstr(suffix, req->image_markers[i])) old_count = i;
|
||||
}
|
||||
if (!ds4_session_rebase_vision_state(slot->session, spans, old_count)) return false;
|
||||
ds4_tokens prompt = {0};
|
||||
ds4_tokens_copy(&prompt, live);
|
||||
const char *cursor = suffix;
|
||||
const int wrapper = ds4_engine_is_glm_dsa(s->engine) ? 1 : 0;
|
||||
for (size_t i = old_count; i < req->image_count; i++) {
|
||||
const char *marker = strstr(cursor, req->image_markers[i]);
|
||||
int64_t start = (int64_t)req->images[i].token_start - wrapper;
|
||||
uint64_t end = (uint64_t)req->images[i].token_start +
|
||||
req->images[i].embedding.token_count + wrapper;
|
||||
if (!marker || start < 0 || end > (uint64_t)req->prompt.len) {
|
||||
ds4_tokens_free(&prompt);
|
||||
return false;
|
||||
}
|
||||
char *text = xstrndup(cursor, (size_t)(marker - cursor));
|
||||
ds4_tokenize_rendered_chat(s->engine, text, &prompt);
|
||||
free(text);
|
||||
spans[i].token_start = (uint32_t)(prompt.len + wrapper);
|
||||
for (int64_t p = start; p < (int64_t)end; p++)
|
||||
ds4_tokens_push(&prompt, req->prompt.v[p]);
|
||||
cursor = marker + strlen(req->image_markers[i]);
|
||||
}
|
||||
ds4_tokenize_rendered_chat(s->engine, cursor, &prompt);
|
||||
if (!ds4_session_vision_prefix_matches(slot->session, spans, req->image_count)) {
|
||||
ds4_tokens_free(&prompt);
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < req->image_count; i++)
|
||||
req->images[i].token_start = spans[i].token_start;
|
||||
ds4_tokens_free(out);
|
||||
*out = prompt;
|
||||
return true;
|
||||
}
|
||||
|
||||
static int live_text_prefix_prompt(server *s, server_slot *slot,
|
||||
const request *req,
|
||||
ds4_tokens *effective_prompt) {
|
||||
@@ -10413,11 +10666,10 @@ static int live_text_prefix_prompt(server *s, server_slot *slot,
|
||||
* keep its sampled tokenization and tokenize only the request bytes that
|
||||
* come after it. Reusing req->prompt's token suffix would be wrong: full
|
||||
* prompt BPE may have merged across this byte boundary. */
|
||||
build_prompt_from_exact_prefix_and_text_suffix(
|
||||
s->engine, live_tokens, req->prompt_text + live_text_len,
|
||||
effective_prompt);
|
||||
bool ok = build_live_prompt_suffix(s, slot, req, req->prompt_text + live_text_len,
|
||||
effective_prompt);
|
||||
free(live_text);
|
||||
return live_tokens->len;
|
||||
return ok ? live_tokens->len : 0;
|
||||
}
|
||||
|
||||
/* Tool-output-only Responses continuation.
|
||||
@@ -10441,9 +10693,8 @@ static int responses_live_continuation_prompt(server *s, server_slot *slot,
|
||||
const ds4_tokens *live_tokens = ds4_session_tokens(slot->session);
|
||||
if (!live_tokens || live_tokens->len != live_pos) return 0;
|
||||
|
||||
build_prompt_from_exact_prefix_and_text_suffix(
|
||||
s->engine, live_tokens, req->responses_live_suffix_text,
|
||||
effective_prompt);
|
||||
if (!build_live_prompt_suffix(s, slot, req, req->responses_live_suffix_text,
|
||||
effective_prompt)) return 0;
|
||||
if (matched_ids) *matched_ids = req->responses_live_call_ids.len;
|
||||
return live_tokens->len;
|
||||
}
|
||||
@@ -10469,9 +10720,8 @@ static int anthropic_live_continuation_prompt(server *s, server_slot *slot,
|
||||
const ds4_tokens *live_tokens = ds4_session_tokens(slot->session);
|
||||
if (!live_tokens || live_tokens->len != live_pos) return 0;
|
||||
|
||||
build_prompt_from_exact_prefix_and_text_suffix(
|
||||
s->engine, live_tokens, req->anthropic_live_suffix_text,
|
||||
effective_prompt);
|
||||
if (!build_live_prompt_suffix(s, slot, req, req->anthropic_live_suffix_text,
|
||||
effective_prompt)) return 0;
|
||||
if (matched_ids) *matched_ids = req->anthropic_live_call_ids.len;
|
||||
return live_tokens->len;
|
||||
}
|
||||
@@ -10496,26 +10746,31 @@ static int responses_live_visible_prefix_prompt(server *s, server_slot *slot,
|
||||
if (!s || !slot || !req || !req->prompt_text || !effective_prompt) return 0;
|
||||
if (req->api != API_RESPONSES) return 0;
|
||||
|
||||
const size_t prompt_len = strlen(req->prompt_text);
|
||||
visible_image_key images;
|
||||
char *key = visible_prompt_key(req, req->prompt_text, &images);
|
||||
if (!key) return 0;
|
||||
const size_t prompt_len = strlen(key);
|
||||
size_t visible_len = 0;
|
||||
pthread_mutex_lock(&s->tool_mu);
|
||||
bool ok = slot->responses_live.valid &&
|
||||
slot->responses_live.live_tokens == live_pos &&
|
||||
slot->responses_live.visible_text &&
|
||||
slot->responses_live.visible_len < prompt_len &&
|
||||
byte_prefix_match(req->prompt_text, prompt_len,
|
||||
visible_image_prefix_matches(&images, &slot->responses_live.images,
|
||||
slot->responses_live.visible_len) &&
|
||||
byte_prefix_match(key, prompt_len,
|
||||
slot->responses_live.visible_text,
|
||||
slot->responses_live.visible_len);
|
||||
if (ok) visible_len = slot->responses_live.visible_len;
|
||||
pthread_mutex_unlock(&s->tool_mu);
|
||||
free(key);
|
||||
if (!ok) return 0;
|
||||
|
||||
const ds4_tokens *live_tokens = ds4_session_tokens(slot->session);
|
||||
if (!live_tokens || live_tokens->len != live_pos) return 0;
|
||||
|
||||
build_prompt_from_exact_prefix_and_text_suffix(
|
||||
s->engine, live_tokens, req->prompt_text + visible_len,
|
||||
effective_prompt);
|
||||
if (!build_live_prompt_suffix(s, slot, req, req->prompt_text + visible_len,
|
||||
effective_prompt)) return 0;
|
||||
return live_tokens->len;
|
||||
}
|
||||
|
||||
@@ -10539,26 +10794,31 @@ static int thinking_live_visible_prefix_prompt(server *s, server_slot *slot,
|
||||
if (!s || !slot || !req || !req->prompt_text || !effective_prompt) return 0;
|
||||
if (req->kind != REQ_CHAT || req->api == API_RESPONSES) return 0;
|
||||
|
||||
const size_t prompt_len = strlen(req->prompt_text);
|
||||
visible_image_key images;
|
||||
char *key = visible_prompt_key(req, req->prompt_text, &images);
|
||||
if (!key) return 0;
|
||||
const size_t prompt_len = strlen(key);
|
||||
size_t visible_len = 0;
|
||||
pthread_mutex_lock(&s->tool_mu);
|
||||
bool ok = slot->thinking_live.valid &&
|
||||
slot->thinking_live.live_tokens == live_pos &&
|
||||
slot->thinking_live.visible_text &&
|
||||
slot->thinking_live.visible_len < prompt_len &&
|
||||
byte_prefix_match(req->prompt_text, prompt_len,
|
||||
visible_image_prefix_matches(&images, &slot->thinking_live.images,
|
||||
slot->thinking_live.visible_len) &&
|
||||
byte_prefix_match(key, prompt_len,
|
||||
slot->thinking_live.visible_text,
|
||||
slot->thinking_live.visible_len);
|
||||
if (ok) visible_len = slot->thinking_live.visible_len;
|
||||
pthread_mutex_unlock(&s->tool_mu);
|
||||
free(key);
|
||||
if (!ok) return 0;
|
||||
|
||||
const ds4_tokens *live_tokens = ds4_session_tokens(slot->session);
|
||||
if (!live_tokens || live_tokens->len != live_pos) return 0;
|
||||
|
||||
build_prompt_from_exact_prefix_and_text_suffix(
|
||||
s->engine, live_tokens, req->prompt_text + visible_len,
|
||||
effective_prompt);
|
||||
if (!build_live_prompt_suffix(s, slot, req, req->prompt_text + visible_len,
|
||||
effective_prompt)) return 0;
|
||||
return live_tokens->len;
|
||||
}
|
||||
|
||||
@@ -11253,7 +11513,7 @@ static int server_multimodal_resume_pos(ds4_session *session,
|
||||
const int common = ds4_session_common_prefix(session, prompt);
|
||||
return server_multimodal_resume_frontier(
|
||||
live, common, prompt->len,
|
||||
ds4_session_vision_state_matches(session, images, image_count));
|
||||
ds4_session_vision_prefix_matches(session, images, image_count));
|
||||
}
|
||||
|
||||
static int server_session_sync_multimodal(server *s, server_slot *slot,
|
||||
@@ -11604,9 +11864,14 @@ static char *build_toolless_thinking_visible_text(const request *r,
|
||||
|
||||
buf visible = {0};
|
||||
buf_append(&visible, r->prompt_text, pt_len - tag_len);
|
||||
buf_puts(&visible, "</think>");
|
||||
buf_puts(&visible, content ? content : "");
|
||||
buf_puts(&visible, "<|end▁of▁sentence|>");
|
||||
if (r->model_syntax == SERVER_MODEL_SYNTAX_GLM) {
|
||||
buf_puts(&visible, "<think></think>");
|
||||
append_trimmed_text(&visible, content);
|
||||
} else {
|
||||
buf_puts(&visible, "</think>");
|
||||
buf_puts(&visible, content ? content : "");
|
||||
buf_puts(&visible, "<|end▁of▁sentence|>");
|
||||
}
|
||||
return buf_take(&visible);
|
||||
}
|
||||
|
||||
@@ -11616,7 +11881,7 @@ static void remember_thinking_checkpoint(server *s, server_slot *slot,
|
||||
char *visible = build_toolless_thinking_visible_text(&j->req, content);
|
||||
if (!visible) return;
|
||||
|
||||
thinking_live_remember(s, slot, visible);
|
||||
thinking_live_remember(s, slot, visible, &j->req);
|
||||
server_log(DS4_LOG_KVCACHE,
|
||||
"ds4-server: thinking live checkpoint remembered ctx=%s live=%d visible=%zu",
|
||||
ctx, ds4_session_pos(slot->session), strlen(visible));
|
||||
@@ -12029,7 +12294,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
const int old_pos = ds4_session_pos(slot->session);
|
||||
const int common = ds4_session_common_prefix(slot->session, &j->req.prompt);
|
||||
const bool live_vision_match =
|
||||
ds4_session_vision_state_matches(slot->session,
|
||||
ds4_session_vision_prefix_matches(slot->session,
|
||||
j->req.images, j->req.image_count);
|
||||
pthread_mutex_unlock(&s->inference_mu);
|
||||
trace_cache_diag cache_diag = {0};
|
||||
@@ -12050,9 +12315,9 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
* exact token-prefix match. Exact token/text/disk matching remains the
|
||||
* fallback when the live state is absent or no longer describes the
|
||||
* request. */
|
||||
int cached = live_vision_match ?
|
||||
int cached =
|
||||
responses_live_visible_prefix_prompt(s, slot, &j->req, old_pos,
|
||||
&effective_prompt) : 0;
|
||||
&effective_prompt);
|
||||
const char *cache_source = cached > 0 ? "responses-visible" : "none";
|
||||
if (cached > 0) {
|
||||
responses_live_match = "visible-prefix";
|
||||
@@ -12063,7 +12328,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
responses_live_match_ids = j->req.responses_live_call_ids.len;
|
||||
}
|
||||
}
|
||||
if (cached == 0 && live_vision_match) {
|
||||
if (cached == 0) {
|
||||
cached = responses_live_continuation_prompt(s, slot, &j->req, old_pos,
|
||||
&effective_prompt,
|
||||
&responses_live_match_ids);
|
||||
@@ -12073,7 +12338,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
if (cached > 0) {
|
||||
responses_live_continuation = true;
|
||||
prompt_for_sync = &effective_prompt;
|
||||
} else if (live_vision_match) {
|
||||
} else {
|
||||
cached = anthropic_live_continuation_prompt(s, slot, &j->req, old_pos,
|
||||
&effective_prompt,
|
||||
&anthropic_live_match_ids);
|
||||
@@ -12112,7 +12377,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
ds4_session_common_prefix(slot->session, &j->req.prompt) ==
|
||||
rewind_to &&
|
||||
(!multimodal ||
|
||||
ds4_session_vision_state_matches(slot->session,
|
||||
ds4_session_vision_prefix_matches(slot->session,
|
||||
j->req.images,
|
||||
j->req.image_count));
|
||||
pthread_mutex_unlock(&s->inference_mu);
|
||||
@@ -12133,7 +12398,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
cache_source = cached > 0 ? "memory-token" : "none";
|
||||
}
|
||||
}
|
||||
if (cached == 0 && live_vision_match) {
|
||||
if (cached == 0) {
|
||||
int thinking_cached =
|
||||
thinking_live_visible_prefix_prompt(s, slot, &j->req, old_pos,
|
||||
&effective_prompt);
|
||||
@@ -12147,7 +12412,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) {
|
||||
int disk_cached = 0;
|
||||
char *disk_cache_path = NULL;
|
||||
uint8_t disk_cache_ext_flags = 0;
|
||||
if (cached == 0 && live_vision_match) {
|
||||
if (cached == 0) {
|
||||
int text_cached = live_text_prefix_prompt(s, slot, &j->req,
|
||||
&effective_prompt);
|
||||
if (text_cached > 0) {
|
||||
@@ -13123,7 +13388,7 @@ decode_again:
|
||||
buf_puts(&visible, j->req.prompt_text ? j->req.prompt_text : "");
|
||||
buf_puts(&visible, visible_suffix ? visible_suffix : "");
|
||||
responses_live_remember(s, slot, visible.ptr ? visible.ptr : "",
|
||||
parsed_calls.len ? &parsed_calls : NULL);
|
||||
parsed_calls.len ? &parsed_calls : NULL, &j->req);
|
||||
buf_free(&visible);
|
||||
free(visible_suffix);
|
||||
} else {
|
||||
@@ -13353,8 +13618,32 @@ static int job_slot_score(server *s, server_slot *slot, const job *j,
|
||||
if (!s || !slot || !j || slot->busy || slot->assigned) return INT_MIN;
|
||||
if (required_slot >= 0 && slot->id != required_slot) return INT_MIN;
|
||||
if (required_slot == slot->id) return INT_MAX;
|
||||
/* A visible replay can omit sampled reasoning and shift image positions.
|
||||
* Select its live slot before falling back to token-prefix scoring. The
|
||||
* continuation builder independently verifies image identities on reuse.
|
||||
* The dispatcher already holds tool_mu here. */
|
||||
int live_pos = ds4_session_pos(slot->session);
|
||||
const request *req = &j->req;
|
||||
visible_image_key images;
|
||||
char *key = req->prompt_text ? visible_prompt_key(req, req->prompt_text, &images) : NULL;
|
||||
bool visible_match = false;
|
||||
if (key && req->api == API_RESPONSES) {
|
||||
const live_tool_state *state = &slot->responses_live;
|
||||
visible_match = state->valid && state->live_tokens == live_pos &&
|
||||
state->visible_text && state->visible_len < strlen(key) &&
|
||||
visible_image_prefix_matches(&images, &state->images, state->visible_len) &&
|
||||
byte_prefix_match(key, strlen(key), state->visible_text, state->visible_len);
|
||||
} else if (key && req->kind == REQ_CHAT) {
|
||||
const visible_live_state *state = &slot->thinking_live;
|
||||
visible_match = state->valid && state->live_tokens == live_pos &&
|
||||
state->visible_text && state->visible_len < strlen(key) &&
|
||||
visible_image_prefix_matches(&images, &state->images, state->visible_len) &&
|
||||
byte_prefix_match(key, strlen(key), state->visible_text, state->visible_len);
|
||||
}
|
||||
free(key);
|
||||
if (visible_match) return live_pos;
|
||||
if (ds4_session_pos(slot->session) > 0 &&
|
||||
!ds4_session_vision_state_matches(slot->session,
|
||||
!ds4_session_vision_prefix_matches(slot->session,
|
||||
j->req.images, j->req.image_count)) {
|
||||
return -1;
|
||||
}
|
||||
@@ -13976,6 +14265,7 @@ static void server_close_resources(server *s) {
|
||||
}
|
||||
kv_cache_close(&s->kv);
|
||||
tool_memory_free(&s->tool_mem);
|
||||
server_image_cache_clear(&s->image_cache);
|
||||
for (int i = 0; i < s->slot_count; i++) {
|
||||
server_slot *slot = &s->slots[i];
|
||||
live_tool_state_free(&slot->responses_live);
|
||||
@@ -19741,7 +20031,146 @@ static void test_responses_inline_image_content(void) {
|
||||
buf_free(&json);
|
||||
}
|
||||
|
||||
static void test_visible_image_key(void) {
|
||||
char markers[2][SERVER_IMAGE_MARKER_BYTES] = {"nonce_A", "nonce_B"};
|
||||
request req = {.image_count = 1, .image_markers = markers};
|
||||
visible_image_key first, next;
|
||||
char *a = visible_prompt_key(&req, "xnonce_Ay", &first);
|
||||
TEST_ASSERT(a && first.count == 1 && first.offsets[0] == 1);
|
||||
memcpy(markers[0], "nonce_Z", 8);
|
||||
char *b = visible_prompt_key(&req, "xnonce_Zy", &next);
|
||||
TEST_ASSERT(a && b && !strcmp(a, b));
|
||||
TEST_ASSERT(visible_image_prefix_matches(&next, &first, strlen(a)));
|
||||
free(b);
|
||||
req.image_count = 0;
|
||||
b = visible_prompt_key(&req, a, &next);
|
||||
TEST_ASSERT(b && !strcmp(a, b));
|
||||
TEST_ASSERT(!visible_image_prefix_matches(&next, &first, strlen(a)));
|
||||
free(b);
|
||||
req.image_count = 2;
|
||||
b = visible_prompt_key(&req, "xnonce_Zy-nextnonce_B", &next);
|
||||
TEST_ASSERT(b && byte_prefix_match(b, strlen(b), a, strlen(a)));
|
||||
TEST_ASSERT(visible_image_prefix_matches(&next, &first, strlen(a)));
|
||||
next.offsets[0]++;
|
||||
TEST_ASSERT(!visible_image_prefix_matches(&next, &first, strlen(a)));
|
||||
free(b);
|
||||
free(a);
|
||||
TEST_ASSERT(visible_prompt_key(&req, "marker missing", &next) == NULL);
|
||||
|
||||
request glm = {.model_syntax = SERVER_MODEL_SYNTAX_GLM, .think_mode = DS4_THINK_HIGH,
|
||||
.prompt_text = "<|user|>hello<|assistant|><think>"};
|
||||
char *visible = build_toolless_thinking_visible_text(&glm, " hello ");
|
||||
TEST_ASSERT(!strcmp(visible, "<|user|>hello<|assistant|><think></think>hello"));
|
||||
free(visible);
|
||||
}
|
||||
|
||||
static void test_anthropic_tool_image_output(void) {
|
||||
buf json = {0};
|
||||
buf_puts(&json, "[{\"content\":[{\"type\":\"tool_result\","
|
||||
"\"tool_use_id\":\"tool_test\",\"content\":["
|
||||
"{\"type\":\"text\",\"text\":\"read image\"},"
|
||||
"{\"type\":\"image\",\"source\":{\"type\":\"base64\","
|
||||
"\"media_type\":\"image/png\",\"data\":\"");
|
||||
buf_puts(&json, test_inline_png_base64);
|
||||
buf_puts(&json, "\"}}]}],\"role\":\"user\"}]");
|
||||
const char *p = json.ptr;
|
||||
chat_msgs msgs = {0};
|
||||
TEST_ASSERT(parse_anthropic_messages(&p, &msgs));
|
||||
TEST_ASSERT(msgs.len == 1 && msgs.v[0].images.len == 1);
|
||||
TEST_ASSERT(strstr(msgs.v[0].content, "<tool_result>read image") != NULL);
|
||||
TEST_ASSERT(strstr(msgs.v[0].content, msgs.v[0].images.v[0].marker) != NULL);
|
||||
chat_msgs_free(&msgs);
|
||||
buf_free(&json);
|
||||
const char *invalid[] = {
|
||||
"[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"content\":["
|
||||
"{\"type\":\"tool_use\",\"name\":\"bash\",\"input\":{}}]}]}]",
|
||||
"[{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"content\":["
|
||||
"{\"type\":\"tool_result\",\"content\":\"nested\"}]}]}]",
|
||||
};
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
p = invalid[i];
|
||||
TEST_ASSERT(!parse_anthropic_messages(&p, &msgs));
|
||||
chat_msgs_free(&msgs);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_responses_tool_image_output(void) {
|
||||
const char *types[] = {"function_call_output", "custom_tool_call_output", "reasoning"};
|
||||
for (size_t i = 0; i < 3; i++) {
|
||||
buf json = {0};
|
||||
buf_puts(&json, "[{\"type\":\"");
|
||||
buf_puts(&json, types[i]);
|
||||
buf_puts(&json, "\",\"call_id\":\"call_test\",\"output\":["
|
||||
"{\"type\":\"input_text\",\"text\":\"read image\"},"
|
||||
"{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,");
|
||||
buf_puts(&json, test_inline_png_base64);
|
||||
buf_puts(&json, "\"}]}]");
|
||||
const char *p = json.ptr;
|
||||
chat_msgs msgs = {0};
|
||||
bool ok = parse_responses_input(&p, &msgs, NULL, NULL);
|
||||
TEST_ASSERT(ok == (i < 2));
|
||||
if (ok) {
|
||||
TEST_ASSERT(msgs.len == 1 && !strcmp(msgs.v[0].role, "tool"));
|
||||
TEST_ASSERT(msgs.v[0].images.len == 1);
|
||||
TEST_ASSERT(strstr(msgs.v[0].content, msgs.v[0].images.v[0].marker) != NULL);
|
||||
}
|
||||
chat_msgs_free(&msgs);
|
||||
buf_free(&json);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_server_image_embedding_cache(void) {
|
||||
server_image_cache cache = {0};
|
||||
uint8_t key = 1;
|
||||
float data[2] = {1.25f, -2.5f};
|
||||
server_image_input input = {.encoded = &key, .encoded_len = 1};
|
||||
ds4_vision_embedding src = {.data = data, .token_count = 1,
|
||||
.width = 42, .fingerprint = {7}};
|
||||
ds4_vision_embedding out = {0};
|
||||
const size_t budget = 2 * (sizeof(data) + 1);
|
||||
TEST_ASSERT(!server_image_cache_get(&cache, &input, &out));
|
||||
server_image_cache_put(&cache, &input, &src, 2, budget);
|
||||
TEST_ASSERT(server_image_cache_get(&cache, &input, &out));
|
||||
TEST_ASSERT(out.data != data && !memcmp(out.data, data, sizeof(data)));
|
||||
TEST_ASSERT(out.width == 42 && out.fingerprint[0] == 7);
|
||||
out.data[0] = 99;
|
||||
ds4_vision_embedding_free(&out);
|
||||
key = 2;
|
||||
TEST_ASSERT(!server_image_cache_get(&cache, &input, &out));
|
||||
server_image_cache_put(&cache, &input, &src, 2, budget);
|
||||
key = 1;
|
||||
TEST_ASSERT(server_image_cache_get(&cache, &input, &out));
|
||||
TEST_ASSERT(out.data[0] == data[0]);
|
||||
ds4_vision_embedding_free(&out);
|
||||
key = 3;
|
||||
server_image_cache_put(&cache, &input, &src, 2, budget);
|
||||
TEST_ASSERT(cache.bytes == budget);
|
||||
key = 2;
|
||||
TEST_ASSERT(!server_image_cache_get(&cache, &input, &out));
|
||||
key = 1;
|
||||
TEST_ASSERT(server_image_cache_get(&cache, &input, &out));
|
||||
ds4_vision_embedding_free(&out);
|
||||
server_image_cache_clear(&cache);
|
||||
TEST_ASSERT(cache.bytes == 0 && cache.clock == 0);
|
||||
for (key = 1; key <= SERVER_IMAGE_CACHE_ENTRIES + 1; key++)
|
||||
server_image_cache_put(&cache, &input, &src, 2, 4096);
|
||||
TEST_ASSERT(cache.bytes == SERVER_IMAGE_CACHE_ENTRIES * (sizeof(data) + 1));
|
||||
key = 1;
|
||||
TEST_ASSERT(!server_image_cache_get(&cache, &input, &out));
|
||||
server_image_cache_clear(&cache);
|
||||
src.token_count = UINT32_MAX;
|
||||
server_image_cache_put(&cache, &input, &src, UINT32_MAX, SIZE_MAX);
|
||||
TEST_ASSERT(cache.bytes == 0);
|
||||
src.token_count = 1;
|
||||
server_image_cache_put(&cache, &input, &src, 2, sizeof(data));
|
||||
TEST_ASSERT(cache.bytes == 0);
|
||||
}
|
||||
|
||||
static void ds4_server_unit_tests_run(void) {
|
||||
test_visible_image_key();
|
||||
test_anthropic_tool_image_output();
|
||||
test_responses_tool_image_output();
|
||||
test_server_image_embedding_cache();
|
||||
test_batched_prefill_round_robin();
|
||||
test_mixed_prefill_quantum_option();
|
||||
test_multimodal_prefill_resume_frontier();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Pi image-reading/editing tasks against an otherwise idle vision server."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--pi", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--api", action="append", choices=[
|
||||
"openai-completions", "openai-responses", "anthropic-messages"])
|
||||
args = parser.parse_args()
|
||||
base = args.url.rstrip("/")
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
for api in args.api or ["openai-completions", "openai-responses", "anthropic-messages"]:
|
||||
project = out / api
|
||||
project.mkdir(exist_ok=True)
|
||||
settings = project / "pi-settings"
|
||||
settings.mkdir(exist_ok=True)
|
||||
(settings / "settings.json").write_text(json.dumps({"compaction": {"enabled": False}}))
|
||||
provider = {"baseUrl": base + ("" if api == "anthropic-messages" else "/v1"),
|
||||
"api": api, "apiKey": "local-test",
|
||||
"models": [{"id": "deepseek-v4-flash", "name": "Vision QA", "reasoning": True,
|
||||
"input": ["text", "image"], "contextWindow": 16384, "maxTokens": 1536,
|
||||
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}]}
|
||||
if api == "openai-completions":
|
||||
provider["compat"] = {"supportsDeveloperRole": False}
|
||||
(settings / "models.json").write_text(json.dumps({"providers": {"ds4": provider}}))
|
||||
for name in ["text.png", "spatial.png"]:
|
||||
shutil.copyfile(root / "tests/vision-fixtures/glm53" / name, project / name)
|
||||
(project / "ticket.py").write_text("def ticket():\n return {}\n\ndef shapes():\n return []\n")
|
||||
(project / "CONTEXT.txt").write_text("\n".join(
|
||||
"Archive record %d: the build passed with no warnings." % i for i in range(200)))
|
||||
cmd = [str(args.pi.resolve()),
|
||||
"--print", "--mode", "json", "--provider", "ds4", "--model", "deepseek-v4-flash",
|
||||
"--thinking", "off", "--no-session", "--no-extensions", "--no-skills",
|
||||
"--no-context-files", "--no-prompt-templates",
|
||||
"Read CONTEXT.txt for background, then read ticket.py. Use the read tool to inspect "
|
||||
"text.png. Implement ticket() returning a dictionary with keys train (integer), "
|
||||
"gate (string), access (string), using the actual image. After editing ticket(), "
|
||||
"use read to inspect spatial.png. Implement shapes() returning a list of lowercase "
|
||||
"color/shape strings like 'purple oval', using the actual shapes. "
|
||||
"Read each image separately, in that order. Run Python to check the implementation. "
|
||||
"Do not create other files. Finish with a brief result."]
|
||||
(project / "command.json").write_text(json.dumps(cmd))
|
||||
with (project / "pi.jsonl").open("w") as log:
|
||||
subprocess.run(cmd, cwd=project, stdout=log, stderr=subprocess.STDOUT,
|
||||
env=dict(os.environ, PI_CODING_AGENT_DIR=str(settings),
|
||||
PATH=os.environ.get("PATH", "")),
|
||||
timeout=600, check=True)
|
||||
oracle = "import ticket; assert ticket.ticket() == {'train': 482, 'gate': 'C7', 'access': 'MINT-731'}; assert set(ticket.shapes()) == {'blue circle', 'red triangle', 'green square'}"
|
||||
subprocess.run([sys.executable, "-c", oracle], cwd=project, check=True)
|
||||
events = []
|
||||
saw_image = False
|
||||
for line in (project / "pi.jsonl").read_text().splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if event.get("type") == "tool_execution_end":
|
||||
saw_image |= any(c.get("type") == "image"
|
||||
for c in event.get("result", {}).get("content", []))
|
||||
if event.get("type") == "message_end" and event.get("message", {}).get("role") == "assistant":
|
||||
assert event["message"].get("stopReason") != "error", event["message"].get("errorMessage")
|
||||
if saw_image:
|
||||
assert event["message"].get("usage", {}).get("cacheRead", 0) > 0, \
|
||||
"image tool result lost the matching context prefix"
|
||||
if event.get("type") == "tool_execution_start":
|
||||
events.append(event)
|
||||
read_images = [e for e in events if e.get("toolName") == "read" and
|
||||
any(name in json.dumps(e.get("args", {})) for name in ["text.png", "spatial.png"])]
|
||||
assert len(read_images) >= 2, "Pi did not actually read both images"
|
||||
print("PASS Pi " + api + ": both image reads, code edits, independent output oracle", flush=True)
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real-model image-cache regression. Run against an otherwise idle vision server."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--url", default="http://127.0.0.1:8080")
|
||||
parser.add_argument("--model", default="deepseek-v4-flash")
|
||||
parser.add_argument("--archive-lines", type=int, default=320)
|
||||
parser.add_argument("--append-only", action="store_true",
|
||||
help="only run the long-prefix image append sequence")
|
||||
parser.add_argument("--thinking-only", action="store_true")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
fixtures = Path(__file__).resolve().parent / "vision-fixtures/glm53"
|
||||
results = []
|
||||
results_lock = Lock()
|
||||
|
||||
def image(name):
|
||||
data = base64.b64encode((fixtures / name).read_bytes()).decode()
|
||||
return {"type": "image_url", "image_url": {"url": "data:image/png;base64," + data}}
|
||||
|
||||
def ask(label, history, expected_cache=None, tools=None, thinking=False):
|
||||
previous_frontier = results[-1]["usage"]["total_tokens"] if expected_cache and results else 0
|
||||
body = {"model": args.model, "messages": history, "temperature": 0,
|
||||
"reasoning_effort": "low" if thinking else "none",
|
||||
"max_tokens": 512 if tools or thinking else 48, "stream": False}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
(args.output / (label + ".request.json")).write_text(json.dumps(body))
|
||||
request = urllib.request.Request(args.url.rstrip("/") + "/v1/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
reply = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(exc.read().decode()) from exc
|
||||
(args.output / (label + ".response.json")).write_text(json.dumps(reply, indent=2))
|
||||
usage = reply["usage"]
|
||||
cached = usage.get("prompt_tokens_details", {}).get("cached_tokens", 0)
|
||||
result = {"label": label, "seconds": time.monotonic() - started, "usage": usage,
|
||||
"message": reply["choices"][0]["message"]}
|
||||
with results_lock:
|
||||
results.append(result)
|
||||
(args.output / "results.json").write_text(json.dumps(results, indent=2))
|
||||
print(json.dumps(result), flush=True)
|
||||
if expected_cache is not None:
|
||||
if expected_cache:
|
||||
assert cached > 0 and cached >= previous_frontier, \
|
||||
label + ": lost part of the live image/text prefix"
|
||||
else:
|
||||
assert cached == 0, label + ": reused an incompatible image prefix"
|
||||
return result["message"]
|
||||
|
||||
def thinking_replay():
|
||||
history = [{"role": "user", "content": [
|
||||
{"type": "text", "text": "What is the access code in this image? Reply with just the code."},
|
||||
image("text.png")]}]
|
||||
answer = ask("thinking-image", history, thinking=True)
|
||||
assert "MINT-731" in answer.get("content", "")
|
||||
# These clients replay the visible answer, not its hidden reasoning.
|
||||
history.append({"role": "assistant", "content": answer["content"]})
|
||||
history.append({"role": "user", "content": "Give just the train number from that image."})
|
||||
answer = ask("thinking-replay", history, True, thinking=True)
|
||||
assert "482" in answer.get("content", "")
|
||||
history.append({"role": "assistant", "content": answer["content"]})
|
||||
history.append({"role": "user", "content": [
|
||||
{"type": "text", "text": "How many shapes in this image? Reply with just a number."},
|
||||
image("spatial.png")]})
|
||||
answer = ask("thinking-new-image", history, True, thinking=True)
|
||||
assert "3" in answer.get("content", "") or "three" in answer.get("content", "").lower()
|
||||
history.append({"role": "assistant", "content": answer["content"]})
|
||||
history.append({"role": "user", "content": "Give just the access code from the first image."})
|
||||
answer = ask("thinking-two-images", history, True, thinking=True)
|
||||
assert "MINT-731" in answer.get("content", "")
|
||||
history.append({"role": "assistant", "content": answer["content"]})
|
||||
history.append({"role": "user", "content": "Give just the train number from the first image."})
|
||||
answer = ask("thinking-replay-again", history, True, thinking=True)
|
||||
assert "482" in answer.get("content", "")
|
||||
changed = copy.deepcopy(history)
|
||||
changed[0]["content"][1] = image("spatial.png")
|
||||
ask("thinking-changed-image", changed, False, thinking=True)
|
||||
|
||||
if args.thinking_only:
|
||||
thinking_replay()
|
||||
print("PASS: image-bearing visible replay without hidden reasoning")
|
||||
return
|
||||
|
||||
archive = "\n".join("Archive record %d: the build passed with no warnings." % i
|
||||
for i in range(args.archive_lines))
|
||||
history = [{"role": "system", "content": "Answer briefly and accurately."},
|
||||
{"role": "user", "content": archive + "\nReply with exactly READY."}]
|
||||
history.append(ask("text", history))
|
||||
history.append({"role": "user", "content": "Reply with exactly TEXT_OK."})
|
||||
history.append(ask("text-append", history, True))
|
||||
history.append({"role": "user", "content": [
|
||||
{"type": "text", "text": "Read the text in this picture. Reply briefly."},
|
||||
image("text.png")]})
|
||||
history.append(ask("first-image", history, True))
|
||||
history.append({"role": "user", "content": "Repeat only the text in the image."})
|
||||
history.append(ask("same-image", history, True))
|
||||
history.append({"role": "user", "content": [
|
||||
{"type": "text", "text": "Describe the shapes in this second image briefly."},
|
||||
image("spatial.png")]})
|
||||
history.append(ask("second-image", history, True))
|
||||
history.append({"role": "user", "content": "Reply with exactly FINISHED."})
|
||||
history.append(ask("two-images", history, True))
|
||||
if args.append_only:
|
||||
print("PASS: long-prefix image appends")
|
||||
return
|
||||
|
||||
# The text/token placeholders can stay identical while pixels change.
|
||||
changed = copy.deepcopy(history[:-1])
|
||||
changed[5]["content"][1] = image("spatial.png")
|
||||
ask("changed-old-image", changed, False)
|
||||
removed = copy.deepcopy(changed)
|
||||
removed[5]["content"] = [{"type": "text", "text": "No image was supplied."}]
|
||||
ask("removed-old-image", removed, False)
|
||||
reordered = copy.deepcopy(history[:-1])
|
||||
reordered[5]["content"][1], reordered[9]["content"][1] = (
|
||||
reordered[9]["content"][1], reordered[5]["content"][1])
|
||||
ask("reordered-images", reordered, False)
|
||||
|
||||
# A real tool-call exchange, with an image added in the following turn.
|
||||
tools = [{"type": "function", "function": {
|
||||
"name": "record", "description": "Record the exact text visible in the image.",
|
||||
"parameters": {"type": "object", "properties": {"text": {"type": "string"}},
|
||||
"required": ["text"], "additionalProperties": False}}}]
|
||||
history = [{"role": "system", "content": "Use record when asked to record image text."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "Record the text in this image using the record tool."},
|
||||
image("text.png")]}]
|
||||
answer = ask("tool-image", history, tools=tools)
|
||||
assert answer.get("tool_calls"), "model did not produce the required tool call"
|
||||
history.append(answer)
|
||||
for call in answer["tool_calls"]:
|
||||
assert call["function"]["name"] == "record"
|
||||
arguments = json.loads(call["function"]["arguments"])
|
||||
assert isinstance(arguments.get("text"), str) and arguments["text"]
|
||||
history.append({"role": "tool", "tool_call_id": call["id"],
|
||||
"content": "Recorded successfully: " + arguments["text"]})
|
||||
history.append(ask("tool-result", history, True, tools=tools))
|
||||
history.append({"role": "user", "content": [
|
||||
{"type": "text", "text": "Now describe this second image. Do not call tools."},
|
||||
image("spatial.png")]})
|
||||
history.append(ask("tool-new-image", history, True, tools=tools))
|
||||
history.append({"role": "user", "content": "Reply with exactly DONE."})
|
||||
ask("tool-final", history, True, tools=tools)
|
||||
probes = [
|
||||
[{"role": "user", "content": [
|
||||
{"type": "text", "text": "Read the text in the image. Be brief."}, image(name)]}]
|
||||
for name in ("text.png", "spatial.png")
|
||||
]
|
||||
controls = [ask("control-" + str(i), probe) for i, probe in enumerate(probes)]
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(ask, "concurrent-" + str(i), probe)
|
||||
for i, probe in enumerate(probes)]
|
||||
for i, future in enumerate(futures):
|
||||
assert future.result()["content"] == controls[i]["content"], "cross-session image mismatch"
|
||||
thinking_replay()
|
||||
print("PASS: image appends, invalidation, tool calls, concurrent sessions, and thinking replay")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,6 +3,74 @@
|
||||
#include "../ds4.c"
|
||||
#include <assert.h>
|
||||
|
||||
static void test_vision_prefix(void) {
|
||||
ds4_session *s = calloc(1, sizeof(*s));
|
||||
assert(s);
|
||||
s->checkpoint_valid = true;
|
||||
s->checkpoint.len = 100;
|
||||
ds4_vision_span images[2] = {
|
||||
{.token_start = 100, .embedding = {.token_count = 10, .fingerprint = {1}}},
|
||||
{.token_start = 150, .embedding = {.token_count = 10, .fingerprint = {2}}},
|
||||
};
|
||||
assert(ds4_session_vision_prefix_matches(s, NULL, 0));
|
||||
assert(ds4_session_vision_prefix_matches(s, images, 1));
|
||||
assert(!ds4_session_vision_state_matches(s, images, 1));
|
||||
images[0].token_start = 99;
|
||||
assert(!ds4_session_vision_prefix_matches(s, images, 1));
|
||||
ds4_vision_identity old = {.token_start = 50, .token_count = 10, .fingerprint = {1}};
|
||||
s->checkpoint_images = &old;
|
||||
s->checkpoint_image_count = 1;
|
||||
images[0].token_start = 50;
|
||||
assert(ds4_session_vision_state_matches(s, images, 1));
|
||||
assert(ds4_session_vision_prefix_matches(s, images, 2));
|
||||
assert(!ds4_session_vision_state_matches(s, images, 2));
|
||||
assert(!ds4_session_vision_prefix_matches(s, NULL, 0));
|
||||
images[0].embedding.fingerprint[0] ^= 1;
|
||||
assert(!ds4_session_vision_prefix_matches(s, images, 2));
|
||||
images[0].embedding.fingerprint[0] ^= 1;
|
||||
images[0].token_start++;
|
||||
assert(!ds4_session_vision_prefix_matches(s, images, 2));
|
||||
images[0].token_start--;
|
||||
images[0].token_start = 7;
|
||||
assert(ds4_session_rebase_vision_state(s, images, 1));
|
||||
assert(images[0].token_start == 50);
|
||||
images[0].token_start = 7;
|
||||
assert(!ds4_session_rebase_vision_state(s, images, 2));
|
||||
assert(images[0].token_start == 7);
|
||||
images[0].embedding.fingerprint[0] ^= 1;
|
||||
assert(!ds4_session_rebase_vision_state(s, images, 1));
|
||||
assert(images[0].token_start == 7);
|
||||
images[0].embedding.fingerprint[0] ^= 1;
|
||||
images[0].embedding.token_count++;
|
||||
assert(!ds4_session_rebase_vision_state(s, images, 1));
|
||||
images[0].embedding.token_count--;
|
||||
images[0].token_start = 50;
|
||||
images[1].token_start = 99;
|
||||
assert(!ds4_session_vision_prefix_matches(s, images, 2));
|
||||
ds4_vision_identity pair[2] = {
|
||||
{.token_start = 50, .token_count = 10, .fingerprint = {1}},
|
||||
{.token_start = 70, .token_count = 10, .fingerprint = {2}},
|
||||
};
|
||||
s->checkpoint_images = pair;
|
||||
s->checkpoint_image_count = 2;
|
||||
images[0].token_start = 7;
|
||||
images[1].token_start = 8;
|
||||
images[1].embedding.fingerprint[31] ^= 1;
|
||||
assert(!ds4_session_rebase_vision_state(s, images, 2));
|
||||
assert(images[0].token_start == 7 && images[1].token_start == 8);
|
||||
images[1].embedding.fingerprint[31] ^= 1;
|
||||
assert(ds4_session_rebase_vision_state(s, images, 2));
|
||||
assert(images[0].token_start == 50 && images[1].token_start == 70);
|
||||
assert(ds4_session_vision_state_matches(s, images, 2));
|
||||
ds4_vision_span swapped[2] = {images[1], images[0]};
|
||||
assert(!ds4_session_rebase_vision_state(s, swapped, 2));
|
||||
assert(!ds4_session_vision_prefix_matches(s, swapped, 2));
|
||||
s->checkpoint_valid = false;
|
||||
assert(!ds4_session_vision_prefix_matches(s, images, 2));
|
||||
assert(!ds4_session_rebase_vision_state(s, images, 2));
|
||||
free(s);
|
||||
}
|
||||
|
||||
static void test_rewind(void) {
|
||||
ds4_engine e = { .backend = DS4_BACKEND_CPU };
|
||||
ds4_session *s = calloc(1, sizeof(*s));
|
||||
@@ -342,6 +410,7 @@ static void test_glm_spec_rollback(void) {
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
test_vision_prefix();
|
||||
test_rewind();
|
||||
test_session_memory();
|
||||
test_payload_tokens();
|
||||
|
||||
Reference in new issue
Block a user