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
17 changes: 9 additions & 8 deletions common/json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,15 @@ common_json common_json::make(const common_json_value & val) {
return common_json(val);
}

bool common_json::is_null() const { return as_json(this).is_null(); }
bool common_json::is_object() const { return as_json(this).is_object(); }
bool common_json::is_array() const { return as_json(this).is_array(); }
bool common_json::is_string() const { return as_json(this).is_string(); }
bool common_json::is_boolean() const { return as_json(this).is_boolean(); }
bool common_json::is_number() const { return as_json(this).is_number(); }
bool common_json::is_number_integer() const { return as_json(this).is_number_integer(); }
bool common_json::is_number_float() const { return as_json(this).is_number_float(); }
bool common_json::is_null() const { return as_json(this).is_null(); }
bool common_json::is_object() const { return as_json(this).is_object(); }
bool common_json::is_array() const { return as_json(this).is_array(); }
bool common_json::is_string() const { return as_json(this).is_string(); }
bool common_json::is_boolean() const { return as_json(this).is_boolean(); }
bool common_json::is_number() const { return as_json(this).is_number(); }
bool common_json::is_number_integer() const { return as_json(this).is_number_integer(); }
bool common_json::is_number_unsigned() const { return as_json(this).is_number_unsigned(); }
bool common_json::is_number_float() const { return as_json(this).is_number_float(); }

bool common_json::empty() const { return as_json(this).empty(); }
size_t common_json::size() const { return as_json(this).size(); }
Expand Down
1 change: 1 addition & 0 deletions common/json.h
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ class common_json {
bool is_boolean() const;
bool is_number() const;
bool is_number_integer() const;
bool is_number_unsigned() const;
bool is_number_float() const;

bool empty() const;
Expand Down
41 changes: 36 additions & 5 deletions tools/server/server-common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ json format_error_response(const std::string & message, const enum error_type ty
type_str = "exceed_context_size_error";
code = 400;
break;
case ERROR_TYPE_INVALID_PROMPT:
type_str = "invalid_prompt";
code = 400;
break;
}
return json {
{"code", code},
Expand Down Expand Up @@ -940,6 +944,26 @@ bool json_is_array_of_numbers(const json & data) {
return false;
}

static llama_token json_get_token(const json & elem) {
if (!elem.is_number_integer()) {
throw std::invalid_argument("Token ID must be an integer");
}

// Check uint64 range first to prevent false validation due to int overflow
if (elem.is_number_unsigned()) {
uint64_t uval = elem.get<uint64_t>();
if (uval > static_cast<uint64_t>(INT32_MAX)) {
throw std::invalid_argument("Token ID out of int32 range: " + std::to_string(uval));
}
}

int64_t val = elem.get<int64_t>();
if (val < INT32_MIN || val > INT32_MAX) {
throw std::invalid_argument("Token ID out of int32 range: " + std::to_string(val));
}
return static_cast<llama_token>(val);
}

bool json_is_array_of_mixed_numbers_strings(const json & data) {
bool seen_string = false;
bool seen_number = false;
Expand Down Expand Up @@ -1013,7 +1037,7 @@ llama_tokens tokenize_mixed(const llama_vocab * vocab, const json & json_prompt,
first = false;
}

prompt_tokens.push_back(p.template get<llama_token>());
prompt_tokens.push_back(json_get_token(p));
}
}
} else {
Expand Down Expand Up @@ -1111,14 +1135,17 @@ server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context *
llama_tokens tmp = tokenize_mixed(vocab, json_prompt, add_special, parse_special);
return server_tokens(tmp, false);
} else if (json_is_array_of_numbers(json_prompt)) {
// array of tokens
llama_tokens tmp = json_prompt.get<llama_tokens>();
llama_tokens tmp;
tmp.reserve(json_prompt.size());
for (const auto & elem : json_prompt) {
tmp.push_back(json_get_token(elem));
}
return server_tokens(tmp, false);
} else if (json_prompt.contains(JSON_STRING_PROMPT_KEY)) {
// JSON object with prompt key.
if (json_prompt.contains(JSON_MTMD_DATA_KEY)) {
if (!has_mtmd)
throw std::runtime_error("Multimodal data provided, but model does not support multimodal requests.");
throw std::invalid_argument("Multimodal data provided, but model does not support multimodal requests.");

// JSON object with prompt and multimodal key.
std::vector<raw_buffer> files;
Expand All @@ -1128,7 +1155,11 @@ server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context *
return process_mtmd_prompt(mctx, json_prompt.at(JSON_STRING_PROMPT_KEY), files, init_opt);
} else {
// Not multimodal, but contains a subobject.
llama_tokens tmp = tokenize_mixed(vocab, json_prompt.at(JSON_STRING_PROMPT_KEY), add_special, parse_special);
const auto & prompt_str = json_prompt.at(JSON_STRING_PROMPT_KEY);
if (!prompt_str.is_string() && !prompt_str.is_array()) {
throw std::invalid_argument("\"prompt_string\" must be a string or array of tokens");
}
llama_tokens tmp = tokenize_mixed(vocab, prompt_str, add_special, parse_special);
return server_tokens(tmp, false);
}
} else {
Expand Down
1 change: 1 addition & 0 deletions tools/server/server-common.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ enum error_type {
ERROR_TYPE_UNAVAILABLE, // custom error
ERROR_TYPE_NOT_SUPPORTED, // custom error
ERROR_TYPE_EXCEED_CONTEXT_SIZE, // custom error
ERROR_TYPE_INVALID_PROMPT, // custom error - for bad token IDs in prompt array
};

// thin wrapper around common_grammar_trigger with (de)serialization functions
Expand Down
31 changes: 25 additions & 6 deletions tools/server/server-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2024,7 +2024,10 @@ struct server_context_impl {
}

if (!task.tokens.validate(ctx_tgt)) {
send_error(task, "Prompt contains invalid tokens", ERROR_TYPE_INVALID_REQUEST);
error_type err_type = (task.type == SERVER_TASK_TYPE_COMPLETION)
? ERROR_TYPE_INVALID_PROMPT
: ERROR_TYPE_INVALID_REQUEST;
send_error(task, "Invalid token IDs", err_type);
return false;
}

Expand Down Expand Up @@ -5375,9 +5378,10 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(

int32_t sse_ping_interval = params.sse_ping_interval;

try {
std::vector<server_task> tasks;
std::vector<server_task> tasks;

// Extract prompt
try {
const auto & prompt = data.at("prompt");
Comment thread
amd-callumm marked this conversation as resolved.
Resolved
// TODO: this log can become very long, put it behind a flag or think about a more compact format
//SRV_DBG("Prompt: %s\n", prompt.is_string() ? prompt.get<std::string>().c_str() : prompt.dump(2).c_str());
Expand All @@ -5391,20 +5395,35 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
SRV_ERR("failed to create %s\n", file_path.string().c_str());
}
}
} catch (const std::exception & e) {
res->error(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
return res;
}

// process prompt
std::vector<server_tokens> inputs;
// process prompt
std::vector<server_tokens> inputs;

try {
const auto & prompt = data.at("prompt");
if (res_type != TASK_RESPONSE_TYPE_NONE && ctx_server.mctx != nullptr) {
// This is the case used by OAI compatible chat path with MTMD. TODO It can be moved to the path below.
inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files, ctx_server.init_opt));
} else {
// Everything else, including multimodal completions.
inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true, ctx_server.init_opt);
}
} catch (const std::invalid_argument & e) {
Comment thread
amd-callumm marked this conversation as resolved.
Resolved
Comment thread
jimw567 marked this conversation as resolved.
Resolved
res->error(format_error_response(e.what(), ERROR_TYPE_INVALID_PROMPT));
return res;
} catch (const std::runtime_error & e) {
// Multimodal loading/tokenization failures
res->error(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
return res;
}

// tasks.reserve(inputs.size()); // TODO: this is inaccurate due to child tasks
// tasks.reserve(inputs.size()); // TODO: this is inaccurate due to child tasks

try {
for (size_t i = 0; i < inputs.size(); i++) {
server_task task = server_task(type);

Expand Down
65 changes: 65 additions & 0 deletions tools/server/tests/unit/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,3 +772,68 @@ def cache_n(prompt):
for suffix in suffixes[1:]:
assert len(suffix) > server.n_ubatch + 4
assert cache_n(prefix + suffix) == len(prefix) - 1


@pytest.mark.parametrize("prompt,expected_status,expected_error_type", [
# Valid
([1], 200, None),
([1, 2, 3], 200, None),
([1, "hello", 2], 200, None),
(["HeLlO 世界! 🎉, [Thi$ shou1d_be-\"valid\'..}"], 200, None),
# Invalid: negative (except -1)
([1, -5], 400, "invalid_prompt"),
# Invalid: token ID out of vocabulary
([99999999], 400, "invalid_prompt"),
# Invalid: LLAMA_TOKEN_NULL (-1) without media chunk
([1, -1], 400, "invalid_prompt"),
# Invalid: float
([2.5, 1], 400, "invalid_prompt"),
([1, "x", 2.0], 400, "invalid_prompt"),
# Invalid: bool
([False], 400, "invalid_prompt"),
([1, "x", True], 400, "invalid_prompt"),
# Invalid: null
([None], 400, "invalid_prompt"),
([1, "x", None], 400, "invalid_prompt"),
# Invalid: int32 overflow
([1, 4294967297], 400, "invalid_prompt"),
# Invalid: uint64 max - must not overflow to -1 (valid)
([18446744073709551615], 400, "invalid_prompt"),
])
def test_token_validation(prompt, expected_status, expected_error_type):
"""
POST /v1/completions with integer-array prompt must reject negative,
out-of-vocabulary, or non-integer token IDs with HTTP 400 and
error.type "invalid_prompt".
"""
global server
server.start()
res = server.make_request("POST", "/v1/completions", data={
"prompt": prompt,
"max_tokens": 1,
})
assert res.status_code == expected_status
if expected_error_type:
assert "error" in res.body
assert res.body["error"]["type"] == expected_error_type
# uint64 max: verify error message to confirm proper bounds checking
# vs. silent wrap-around to -1 (LLAMA_TOKEN_NULL) which would trigger "Invalid token IDs"
if 18446744073709551615 in prompt:
assert "Token ID out of int32 range: 18446744073709551615" in res.body["error"]["message"]


@pytest.mark.parametrize("data,expected_error_type", [
({"prompt": "hello", "temperature": "hot"}, "invalid_request_error"),
({"prompt": "hello", "top_k": "many"}, "invalid_request_error"),
({}, "invalid_request_error"),
({"prompt": []}, "invalid_prompt"), # Empty array
({"prompt": {"prompt_string": 123}}, "invalid_prompt"), # Non-string prompt_string
({"prompt": {"prompt_string": "test", "multimodal_data": []}}, "invalid_prompt"), # Multimodal on non-multimodal model
])
def test_invalid_request_params(data, expected_error_type):
"""Invalid request parameters should return 400 with correct error type."""
global server
server.start()
res = server.make_request("POST", "/v1/completions", data=data)
assert res.status_code == 400
assert res.body["error"]["type"] == expected_error_type
64 changes: 62 additions & 2 deletions tools/server/tests/unit/test_embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ def test_embedding_content_text_same_as_string():
@pytest.mark.parametrize(
"input",
[
[],
{"content": "string"},
{"content": [{"type": "unknown"}]},
# model is not multimodal
Expand Down Expand Up @@ -332,7 +331,6 @@ def test_embedding_openai_library_base64():
@pytest.mark.parametrize(
"data",
[
{"input": []},
{"input": True},
{"input": "hello", "encoding_format": 1},
]
Expand All @@ -345,3 +343,65 @@ def test_embedding_invalid_request(data):
assert res.status_code == 400
assert "error" in res.body
assert res.body["error"]["type"] == "invalid_request_error"


@pytest.mark.parametrize("input", [
[1, "x", 2.5],
[1, True],
[False],
[1, "x", None],
[1, 4294967297],
])
def test_embedding_invalid_token_ids(input):
"""Invalid token IDs in input arrays should return 400."""
global server
server.pooling = 'last'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={"input": input})
assert res.status_code == 400
assert res.body["error"]["type"] == "invalid_request_error"


@pytest.mark.parametrize("input", [
[1, -5],
[1, 99999999],
[1, -1], # Token -1 without media
[18446744073709551615], # uint64 max - must not overflow to -1 (valid)
])
def test_embedding_invalid_token_range(input):
"""Negative and out-of-vocab tokens should return 400 invalid_request_error."""
global server
server.pooling = 'last'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={"input": input})
assert res.status_code == 400
assert res.body["error"]["type"] == "invalid_request_error"
# uint64 max: verify error message to confirm proper bounds checking
# vs. silent wrap-around to -1 (LLAMA_TOKEN_NULL) which would trigger "Invalid token IDs"
if 18446744073709551615 in input:
assert "Token ID out of int32 range: 18446744073709551615" in res.body["error"]["message"]


@pytest.mark.parametrize("input", [
[], # Empty array
{"prompt_string": 123}, # Non-string prompt_string
{"prompt_string": "test", "multimodal_data": []}, # Multimodal on non-multimodal model
])
def test_embedding_invalid_prompt_format(input):
"""Invalid prompt formats should return 400 invalid_request_error."""
global server
server.pooling = 'last'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={"input": input})
assert res.status_code == 400
assert res.body["error"]["type"] == "invalid_request_error"


def test_embedding_missing_input():
"""Missing input field should return 400 invalid_request_error."""
global server
server.pooling = 'last'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={})
assert res.status_code == 400
assert res.body["error"]["type"] == "invalid_request_error"
21 changes: 21 additions & 0 deletions tools/server/tests/unit/test_tokenize.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,24 @@ def test_tokenize_with_pieces():
assert token["id"] > 0
assert "piece" in token
assert len(token["piece"]) > 0


@pytest.mark.parametrize("content", [
[1, "x", 2.5],
[1, True],
[False],
[1, "x", None],
[1, 4294967297],
[18446744073709551615], # uint64 max - must not overflow to -1 (valid)
])
def test_tokenize_invalid_token_ids(content):
"""Invalid token IDs in mixed arrays should return 400."""
global server
server.start()
res = server.make_request("POST", "/tokenize", data={"content": content})
assert res.status_code == 400
assert res.body["error"]["type"] == "invalid_request_error"
# uint64 max: verify error message to confirm proper bounds checking
# vs. silent wrap-around to -1 which would return 200 success with [-1] tokens
if 18446744073709551615 in content:
assert "Token ID out of int32 range: 18446744073709551615" in res.body["error"]["message"]
Loading