You can ignore any bot-user above (before mods kill it) that pasted AI output minutes between postings to the forum and with seconds of “typing”, virtually ensuring little read time nor thought nor contextual understanding.
A workaround you can use is indeed to place alphabetic characters in your attribute metadata strings so that, in no case, can they be cast to a number type in transmission or be interpreted as a float.
Ultimately, we must discover, though, if this is your code, OpenAI’s offered SDK code, or the API itself that is causing the symptom, in order to arrive at a solution or to make a bug report.
This topic discusses vector store files, and the attributes that can be assigned to each file within the vector store object (not attributes or metadata on a file uploaded to the files endpoint, which would be wishful thinking never offered).
Then, there are two APIs that can perform vector store filtering based on a file attribute with a comparison filter such as eq for equals - either the standalone vector store search giving the results directly, or the Responses API, where the internal tool file_search also can accept filtering parameters. You didn’t clearly specify which. The latter is a bit harder to surface in an end-user product, so I will assume discrete file search API use (or that the symptom would occur on both).
To design a reproduction, I first must bypass SDK or language - making RESTful calls (apart from the file upload itself, which is multipart/form-data.)
I already have assistants/vector store/responses library methods, so can cut that down to a reproduction library, to then make further tests and see if we can get fault directly out of the API or have the wrong type retrieved despite transmitting it correctly.
There’s several steps here to set it up:
- upload a file (or multiple)
- create a vector store
- add that file id (and polling because the API is lazy)
- use attributes at creation time or with “update”
- get them back to see damage
- do some searching and see if we can filter or not filter
We can try both input types beyond “boolean”, and also try the first workaround suggestion.
{
"document_id": "123456",
"numeric_control": 654321,
"string_control": "document-123456"
}
Seeing what comes back from list file or retrieve file gives a first answer.
First, the digit-only identifier is not being converted during transmission or storage. It remains a string at every boundary that the API exposes:
- The exact request body contains
"document_id":"123456".
- The response from attaching the file returns
"123456".
- Retrieving the vector-store file after ingestion returns
"123456".
- An unfiltered vector-store search also returns
"123456".
- The filtered search request sends the comparison value as the JSON string
"123456".
This reproduction also bypasses the OpenAI SDK and sends an explicitly serialized REST request. Therefore, there is no evidence here of Python, httpx, an SDK model, multipart upload handling, or the vector-store file creation endpoint changing the string into a number.
Having AI write a test harness and perform an analysis on a total of 17 billed searches, let’s turn it over to some AI analysis (that being Pro and slow, minimizes human thinking but maybe not human realtime).
The surrounding vector-store operations behaved normally: the vector store and files were created, both files completed ingestion, both appeared in an unfiltered search, the comparison controls ran successfully, and all server-side test assets were deleted afterward.
The client also transmitted the intended JSON types correctly. One file was attached with:
{
"document_id": "123456"
}
and the other was attached with:
{
"document_id": 123456
}
The exact serialized request bodies confirm that the first value was a JSON string and the second was a JSON number. Therefore, this is not Python, an OpenAI SDK, or the HTTP library changing the value before transmission.
There is, however, an important server-side type change. The file sent with numeric document_id: 123456 was already returned by the vector-store file attachment endpoint as the string "123456". Retrieving that vector-store file later also returned the value as a string. The first observable change therefore occurs no later than the vector-store file attachment/readback layer—not in the Files upload endpoint and not solely when search results are serialized.
That result suggests that the service normalizes values associated with an attribute key, possibly according to the type first established for that key. The internal implementation is not visible from the API, so that exact mechanism cannot be proven, but the client-side possibilities have been eliminated.
The comparison-filter tests then reproduce a clear, user-visible fault.
A string filter:
{
"type": "eq",
"key": "document_id",
"value": "123456"
}
returned both the file supplied with the string value and the file supplied with the numeric value.
A numeric filter:
{
"type": "eq",
"key": "document_id",
"value": 123456
}
also returned both files.
That happened identically in all three test rounds. The eq comparison is therefore treating the digit-only string "123456" and the number 123456 as equivalent instead of preserving the distinction between the two documented attribute-value types.
The controls rule out the filter simply being ignored:
"999999" and 999999 returned no files.
"id:string:123456" selected only the intended string file.
"id:number:123456" selected only the intended numeric file.
- Numeric
123456 did not match either alphabetically prefixed identifier.
The comparison filter is active and does compare values. The unexpected behavior is specifically associated with digit-only strings that can be interpreted as numbers.
Workaround
Identifiers that must retain string-only equality semantics should contain at least one nonnumeric character. Apply the same canonical representation when storing and filtering:
{
"document_id": "doc:123456"
}
{
"type": "eq",
"key": "document_id",
"value": "doc:123456"
}
Bug report for OpenAI
Note: “I” written below means I ran a harness from gpt-5.6-sol “pro” and “I” is the AI writing about the analysis of it.
Title
eq filtering for vector-store file attributes treats digit-only strings and numbers as equivalent
Description
I reproduced unexpected cross-type equality behavior using direct REST requests to the standalone vector-store search endpoint:
POST /v1/vector_stores/{vector_store_id}/search
No OpenAI SDK was used. Request bodies were serialized directly, and the exact transmitted JSON was recorded.
I created one vector store and attached two different files with the same attribute key and digits but different JSON types:
{
"document_id": "123456",
"prefixed_document_id": "id:string:123456"
}
{
"document_id": 123456,
"prefixed_document_id": "id:number:123456"
}
The exact attachment request for the second file contained the JSON number:
{
"document_id": 123456
}
However, the attachment response and subsequent vector-store-file retrieval exposed that value as the JSON string:
{
"document_id": "123456"
}
I then searched using this string equality filter:
{
"type": "eq",
"key": "document_id",
"value": "123456"
}
Expected: only the file originally supplied with the string attribute should be returned.
Actual: both the string-valued file and the number-valued file were returned.
I repeated the search with a numeric filter:
{
"type": "eq",
"key": "document_id",
"value": 123456
}
Expected: only the file originally supplied with the numeric attribute should be returned.
Actual: both files were again returned.
The result was stable across three consecutive rounds.
Negative controls demonstrate that filtering was active rather than ignored:
{"type":"eq","key":"document_id","value":"999999"}
and:
{"type":"eq","key":"document_id","value":999999}
both returned no files.
Prefixed-string controls behaved correctly and selected their corresponding files individually:
{
"type": "eq",
"key": "prefixed_document_id",
"value": "id:string:123456"
}
{
"type": "eq",
"key": "prefixed_document_id",
"value": "id:number:123456"
}
A numeric filter value of 123456 did not match either prefixed identifier.
This indicates that digit-only strings and JSON numbers are being normalized into the same equality domain, despite string and number being exposed as separate supported types for vector-store file attributes and filter values.
This can cause distinct application identifiers to collide and can return files that should have been excluded by an exact typed comparison.
Please confirm whether this cross-type coercion is intended. If it is not intended, the vector-store attribute representation and eq comparison should preserve the distinction between JSON strings and JSON numbers. If coercion is intended, it should be documented explicitly because the current typed request schema does not make that behavior apparent.
Test time
August 4, 2026, approximately 06:25 GMT
Relevant request IDs
Attribute attachment and retrieval showing the numeric input exposed as a string:
Attachment: req_0a67556fc5e44a73bbf9cc3cd0ba1c74
Retrieval: req_cefe550efe5841f5a2e57d9bbd59d7f0
Unfiltered search:
req_8beba3c957964275a582bdbb17fbd231
Typed equality matrix:
Round 1 string: req_53753c22ae764ab28384c123273c0152
Round 1 number: req_e76ca033566a4b2b87a29d0132a11a5c
Round 2 string: req_d9e0c747281f4d6eb74f456e7655c644
Round 2 number: req_8050cb36ea564279b4f9b987a3ecd004
Round 3 string: req_366b67149ffd43f3bde52924806fb91c
Round 3 number: req_d1610a6143e44929ba3984686e0d5e6c
Negative controls:
Wrong string: req_5c12bb3460b94f35806893fb8f1f22c0
Wrong number: req_b7e4b3a54f3c4cd0b33433c0e27fcbdb
Prefixed numeric probe:
req_808a062dd30a4fda8016a8ab8df0f6c9
All uploaded files and the vector store were deleted after the reproduction, though.
Python with RESTful library methods for vector stores
You can make use of the methods here, but they are cut down a bit from every parameter possible. The test harness is too much code to paste and would be exploratory API contract validation instead of immediately showing the issue.
"""Minimal reproduction for digit-only string attributes in vector-store search.
Python 3.11+:
python -m pip install httpx
export OPENAI_API_KEY="your-api-key"
python openai_vector_store_digit_string_eq_repro.py
The script calls POST /vector_stores/{vector_store_id}/search directly. It does
not use the Responses API file_search tool. It creates one in-memory text file
and one vector store, then deletes both in a finally block.
Exit codes:
0: The documented string/number distinction is preserved.
1: A type or eq-filter inconsistency is reproduced.
2: The run is inconclusive because setup or an API request failed.
"""
import asyncio
import json
import os
import sys
from io import BytesIO
from uuid import uuid4
import httpx
BASE_URL = "https://api.openai.com/v1"
TIMEOUT = httpx.Timeout(60.0, connect=10.0)
_MISSING = object()
def json_type(value: object) -> str:
if value is _MISSING:
return "missing"
if value is None:
return "null"
if type(value) is str:
return "string"
if type(value) is bool:
return "boolean"
if type(value) in (int, float):
return "number"
return type(value).__name__
def probe(value: object) -> dict[str, object]:
return {
"value": "<missing>" if value is _MISSING else value,
"json_type": json_type(value),
"python_type": "missing" if value is _MISSING else type(value).__name__,
}
def attributes(value: dict[str, object] | None) -> dict[str, object]:
if value is None or value.get("attributes") is None:
return {}
result = value["attributes"]
if type(result) is not dict:
raise TypeError("The API returned attributes in a non-object form")
return result
def attribute_report(value: dict[str, object]) -> dict[str, object]:
return {
key: probe(value.get(key, _MISSING))
for key in ("document_id", "numeric_control", "string_control")
}
def search_item(
response: dict[str, object],
file_id: str,
) -> dict[str, object] | None:
data = response.get("data")
if type(data) is not list:
return None
return next(
(
item
for item in data
if type(item) is dict and item.get("file_id") == file_id
),
None,
)
def file_ids(response: dict[str, object]) -> list[str]:
data = response.get("data")
if type(data) is not list:
return []
return [
item["file_id"]
for item in data
if type(item) is dict and type(item.get("file_id")) is str
]
async def json_call(
client: httpx.AsyncClient,
method: str,
path: str,
body: dict[str, object] | None = None,
) -> tuple[dict[str, object], str | None, str]:
"""Send JSON without coercion and retain the exact request/response text."""
request_text = None
request_options: dict[str, object] = {}
if body is not None:
request_text = json.dumps(
body,
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
)
request_options = {
"content": request_text.encode(),
"headers": {"Content-Type": "application/json"},
}
response = await client.request(
method,
f"{BASE_URL}{path}",
**request_options,
)
response.raise_for_status()
result = response.json()
if type(result) is not dict:
raise TypeError(f"Expected a JSON object from {response.request.url}")
return result, request_text, response.text
async def upload_virtual_file(
client: httpx.AsyncClient,
marker: str,
) -> dict[str, object]:
"""Upload an in-memory file_234.txt.
API form parameters:
file: Required multipart file; this function supplies BytesIO content.
purpose: assistants, batch, fine-tune, vision, user_data, or evals;
this function sends user_data.
expires_after: Optional created_at expiration policy; not exposed here.
"""
text = (
f"{marker}\n"
"This document tests exact equality filtering on vector-store file "
"attributes.\n"
f"Search marker: {marker}\n"
)
with BytesIO(text.encode()) as file_handle:
response = await client.post(
f"{BASE_URL}/files",
data={"purpose": "user_data"},
files={"file": ("file_234.txt", file_handle, "text/plain")},
)
response.raise_for_status()
result = response.json()
if type(result) is not dict:
raise TypeError("Expected a JSON object from POST /files")
return result
async def create_vector_store(
client: httpx.AsyncClient,
name: str,
) -> dict[str, object]:
"""Create an empty vector store.
API body parameters:
name: Sent by this function.
description, expires_after, file_ids, chunking_strategy, metadata:
Supported by the endpoint but not exposed here.
"""
result, _, _ = await json_call(
client,
"POST",
"/vector_stores",
{"name": name},
)
return result
async def attach_file(
client: httpx.AsyncClient,
vector_store_id: str,
file_id: str,
file_attributes: dict[str, str | int | float | bool],
) -> tuple[dict[str, object], str, str]:
"""Attach a file with typed attributes.
API path parameters:
vector_store_id: Required vector-store ID.
API body parameters:
file_id: Required uploaded File ID.
attributes: Up to 16 string, number, or boolean values; sent unchanged.
chunking_strategy: Optional; not exposed here.
"""
result, request_text, response_text = await json_call(
client,
"POST",
f"/vector_stores/{vector_store_id}/files",
{"file_id": file_id, "attributes": file_attributes},
)
if request_text is None:
raise RuntimeError("Attachment body was not serialized")
return result, request_text, response_text
async def retrieve_vector_store_file(
client: httpx.AsyncClient,
vector_store_id: str,
file_id: str,
) -> tuple[dict[str, object], str]:
"""Retrieve a vector-store file by vector_store_id and file_id."""
result, _, response_text = await json_call(
client,
"GET",
f"/vector_stores/{vector_store_id}/files/{file_id}",
)
return result, response_text
async def search_vector_store(
client: httpx.AsyncClient,
vector_store_id: str,
query: str,
filters: dict[str, object] | None = None,
) -> tuple[dict[str, object], str, str]:
"""Search a vector store.
API body parameters:
query: String or array of strings; this function sends a string.
filters: ComparisonFilter or CompoundFilter; the harness uses only eq.
max_num_results: 1 through 50; this function sends 10.
ranking_options: Optional ranker and threshold; not exposed here.
rewrite_query: Optional boolean; this function sends false.
"""
body: dict[str, object] = {
"query": query,
"max_num_results": 10,
"rewrite_query": False,
}
if filters is not None:
body["filters"] = filters
result, request_text, response_text = await json_call(
client,
"POST",
f"/vector_stores/{vector_store_id}/search",
body,
)
if request_text is None:
raise RuntimeError("Search body was not serialized")
return result, request_text, response_text
async def delete_file(
client: httpx.AsyncClient,
file_id: str,
) -> dict[str, object]:
"""DELETE /files/{file_id}; deletion also removes vector-store attachments."""
result, _, _ = await json_call(client, "DELETE", f"/files/{file_id}")
return result
async def delete_vector_store(
client: httpx.AsyncClient,
vector_store_id: str,
) -> dict[str, object]:
"""DELETE /vector_stores/{vector_store_id}."""
result, _, _ = await json_call(
client,
"DELETE",
f"/vector_stores/{vector_store_id}",
)
return result
async def eq_test(
client: httpx.AsyncClient,
vector_store_id: str,
file_id: str,
query: str,
name: str,
key: str,
value: str | int,
expected: bool,
) -> dict[str, object]:
result, request_text, response_text = await search_vector_store(
client,
vector_store_id,
query,
{"type": "eq", "key": key, "value": value},
)
request_filter = json.loads(request_text)["filters"]
actual = search_item(result, file_id) is not None
return {
"name": name,
"filter_value": probe(request_filter["value"]),
"expected_match": expected,
"actual_match": actual,
"passed": actual == expected,
"returned_file_ids": file_ids(result),
"request_json": request_text,
"response_json": response_text,
}
def diagnosis(
boundary_types: dict[str, str],
unfiltered: bool,
results: dict[str, dict[str, object]],
) -> dict[str, str | bool]:
if boundary_types["request"] != "string":
return {
"conclusive": True,
"status": "client_side_conversion",
"explanation": "document_id was no longer a string in the HTTP body.",
}
for boundary in ("attachment", "retrieval", "search_result"):
if boundary_types[boundary] == "number":
return {
"conclusive": True,
"status": "server_side_type_change",
"explanation": (
"document_id was sent as a string and first appeared as a "
f"number at the {boundary} boundary."
),
}
if not unfiltered:
return {
"conclusive": False,
"status": "inconclusive_unfiltered_search",
"explanation": "The file was not returned without a filter.",
}
numeric_control = bool(results["numeric_control"]["actual_match"])
string_control = bool(results["string_control"]["actual_match"])
string_target = bool(results["string_target"]["actual_match"])
numeric_probe = bool(results["numeric_probe"]["actual_match"])
if not numeric_control or not string_control:
return {
"conclusive": False,
"status": "inconclusive_positive_control_failure",
"explanation": "A numeric or ordinary-string eq control failed.",
}
if not string_target and numeric_probe:
return {
"conclusive": True,
"status": "numeric_coercion_in_eq_filter_reproduced",
"explanation": (
"Readback remains string, but string eq misses and numeric eq on "
"the same key and digits matches. The inconsistency is in the "
"search attribute index or eq comparison path."
),
}
if not string_target and not numeric_probe:
return {
"conclusive": True,
"status": "digit_string_eq_miss_without_numeric_match",
"explanation": (
"The string eq miss is reproduced, but numeric eq also misses; "
"conversion to number is not proven."
),
}
if string_target and numeric_probe:
return {
"conclusive": True,
"status": "type_coercing_eq_comparison",
"explanation": "Both string and numeric eq filters match the string.",
}
return {
"conclusive": True,
"status": "not_reproduced",
"explanation": "String eq matches and numeric eq does not.",
}
def error_report(error: Exception) -> dict[str, object]:
result: dict[str, object] = {
"type": type(error).__name__,
"message": str(error),
}
if isinstance(error, httpx.HTTPStatusError):
result.update(
status_code=error.response.status_code,
request_url=str(error.request.url),
response_json=error.response.text,
)
return result
async def cleanup(
client: httpx.AsyncClient,
file_id: str | None,
vector_store_id: str | None,
) -> dict[str, object]:
result: dict[str, object] = {}
if file_id is not None:
try:
result["file"] = await delete_file(client, file_id)
except Exception as error:
result["file_error"] = error_report(error)
if vector_store_id is not None:
try:
result["vector_store"] = await delete_vector_store(
client,
vector_store_id,
)
except Exception as error:
result["vector_store_error"] = error_report(error)
return result