hyf

Context-aware query service for Radroots
git clone https://radroots.dev/git/hyf.git
Log | Files | Refs | README | LICENSE

commit a09be2b0d1e862d276eec5158572f96be42bd635
parent d1b66559f0fb9f51993ae88cf15bbf63a628b747
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 16:06:02 -0700

provider: port max local adapter source

- add pinned flare Git dependency through Pixi

- reintroduce max_local provider config, client, health, schema, and parser modules

- map provider config from strict runtime TOML instead of environment variables

- add adapter tests for config, request body, and response parsing

Diffstat:
Mpixi.lock | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpixi.toml | 8+++++++-
Asrc/hyf_provider/__init__.mojo | 0
Asrc/hyf_provider/client.mojo | 17+++++++++++++++++
Asrc/hyf_provider/config.mojo | 29+++++++++++++++++++++++++++++
Asrc/hyf_provider/health.mojo | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hyf_provider/max_local.mojo | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hyf_provider/result.mojo | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hyf_provider/schema.mojo | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_provider_adapter.mojo | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 677 insertions(+), 1 deletion(-)

diff --git a/pixi.lock b/pixi.lock @@ -61,6 +61,7 @@ environments: - conda: https://conda.modular.com/max-nightly/linux-64/mojo-compiler-1.0.0b1-release.conda - conda: https://conda.modular.com/max-nightly/noarch/mblack-26.3.0-release.conda - conda: https://conda.modular.com/max-nightly/noarch/mojo-python-1.0.0b1-release.conda + - conda_source: flare[373ea143] @ git+https://github.com/triesap/mojo_flare?rev=14aa338d8c4f352ebb2d590cddfde0bde5ea1971#14aa338d8c4f352ebb2d590cddfde0bde5ea1971 - conda_source: json[9dd23c1f] @ git+https://github.com/triesap/mojo_json?rev=56025d73631054d7803143fa5e68c5de578e33f5#56025d73631054d7803143fa5e68c5de578e33f5 - conda_source: morph[39ce1c7e] @ git+https://github.com/triesap/mojo_morph?rev=ea73f909990f3d3f5d12b786fec2f09e1e09f462#ea73f909990f3d3f5d12b786fec2f09e1e09f462 osx-arm64: @@ -109,6 +110,7 @@ environments: - conda: https://conda.modular.com/max-nightly/noarch/mojo-python-1.0.0b1-release.conda - conda: https://conda.modular.com/max-nightly/osx-arm64/mojo-1.0.0b1-release.conda - conda: https://conda.modular.com/max-nightly/osx-arm64/mojo-compiler-1.0.0b1-release.conda + - conda_source: flare[c903ec05] @ git+https://github.com/triesap/mojo_flare?rev=14aa338d8c4f352ebb2d590cddfde0bde5ea1971#14aa338d8c4f352ebb2d590cddfde0bde5ea1971 - conda_source: json[1efb775d] @ git+https://github.com/triesap/mojo_json?rev=56025d73631054d7803143fa5e68c5de578e33f5#56025d73631054d7803143fa5e68c5de578e33f5 - conda_source: morph[70142729] @ git+https://github.com/triesap/mojo_morph?rev=ea73f909990f3d3f5d12b786fec2f09e1e09f462#ea73f909990f3d3f5d12b786fec2f09e1e09f462 packages: @@ -1812,6 +1814,102 @@ packages: run_exports: {} size: 67503416 timestamp: 1777596016502 +- conda_source: flare[373ea143] @ git+https://github.com/triesap/mojo_flare?rev=14aa338d8c4f352ebb2d590cddfde0bde5ea1971#14aa338d8c4f352ebb2d590cddfde0bde5ea1971 + version: 0.8.0 + build: hb0f4dca_0 + subdir: linux-64 + variants: + target_platform: linux-64 + depends: + - mojo ==1.0.0b1 + - json >=0.2.0,<0.3.0 + - openssl >=3.0,<4 + - openssl >=3.6.3,<4.0a0 + - ca-certificates + - libstdcxx >=15 + - libgcc >=15 + license: MIT + build_packages: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.45.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-he0086c7_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-15.2.0-h7be306e_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-15.2.0-hda75c37_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-15.2.0-hcb00b6d_27.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + host_packages: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda +- conda_source: flare[c903ec05] @ git+https://github.com/triesap/mojo_flare?rev=14aa338d8c4f352ebb2d590cddfde0bde5ea1971#14aa338d8c4f352ebb2d590cddfde0bde5ea1971 + version: 0.8.0 + build: h60d57d3_0 + subdir: osx-arm64 + variants: + target_platform: osx-arm64 + depends: + - mojo ==1.0.0b1 + - json >=0.2.0,<0.3.0 + - openssl >=3.0,<4 + - openssl >=3.6.3,<4.0a0 + - ca-certificates + - libcxx >=22 + license: MIT + build_packages: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt22_osx-arm64-22.1.7-h7e67a1e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-22.1.7-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libcxx-headers-22.1.7-h707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm22_1_hb5e89dc_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm22_1_hbe26303_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-22-22.1.7-default_hd632d02_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-scan-deps-22.1.7-default_h8e162e0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-22.1.7-default_h17d1ed9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-22.1.7-hf119d2b_32.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_impl_osx-arm64-22.1.7-default_h17d1ed9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clangxx_osx-arm64-22.1.7-hf119d2b_32.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-22.1.7-hce30654_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt22-22.1.7-hd34ed20_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm22_1_h692d5aa_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp22.1-22.1.7-default_h8e162e0_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang13-22.1.7-default_h6dd9417_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcompiler-rt-22.1.7-hd34ed20_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.7-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-devel-22.1.7-h6dc3340_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm22-22.1.7-h89af1be_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-22-22.1.7-hb545844_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-22.1.7-hd34ed20_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-h997e182_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + host_packages: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.7-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - conda_source: json[1efb775d] @ git+https://github.com/triesap/mojo_json?rev=56025d73631054d7803143fa5e68c5de578e33f5#56025d73631054d7803143fa5e68c5de578e33f5 version: 0.2.0 build: h60d57d3_0 diff --git a/pixi.toml b/pixi.toml @@ -15,6 +15,7 @@ preview = ["pixi-build"] mojo = "==1.0.0b1" json = { git = "https://github.com/triesap/mojo_json.git", rev = "56025d73631054d7803143fa5e68c5de578e33f5" } morph = { git = "https://github.com/triesap/mojo_morph.git", rev = "ea73f909990f3d3f5d12b786fec2f09e1e09f462" } +flare = { git = "https://github.com/triesap/mojo_flare.git", rev = "14aa338d8c4f352ebb2d590cddfde0bde5ea1971" } [tasks] run = "mojo run -I src src/main.mojo" @@ -22,9 +23,14 @@ test-unit = "mojo -I src tests/test_hyf.mojo" test-runtime = "mojo -I src tests/test_runtime_paths.mojo" test-repo-local-process = "mojo -I src tests/test_repo_local_process_contract.mojo" test-stdio = "mojo -I src tests/test_stdio_contract.mojo" +test-provider-adapter = "mojo -I src tests/test_provider_adapter.mojo" test-runtime-contract = { depends-on = [ "test-runtime", "test-repo-local-process", "test-stdio", ] } -test = { depends-on = ["test-unit", "test-runtime-contract"] } +test = { depends-on = [ + "test-unit", + "test-provider-adapter", + "test-runtime-contract", +] } diff --git a/src/hyf_provider/__init__.mojo b/src/hyf_provider/__init__.mojo diff --git a/src/hyf_provider/client.mojo b/src/hyf_provider/client.mojo @@ -0,0 +1,17 @@ +from flare.http import HttpClient + +from hyf_provider.config import MaxLocalProviderConfig + + +def _trim_trailing_slash(url: String) -> String: + if url.endswith("/") and url.byte_length() > 1: + return String(url[byte = 0 : url.byte_length() - 1]) + return String(url) + + +def make_max_local_http_client(config: MaxLocalProviderConfig) -> HttpClient: + return HttpClient(timeout_ms=config.request_timeout_ms) + + +def max_local_chat_completions_url(config: MaxLocalProviderConfig) -> String: + return _trim_trailing_slash(config.base_url) + "/chat/completions" diff --git a/src/hyf_provider/config.mojo b/src/hyf_provider/config.mojo @@ -0,0 +1,29 @@ +from hyf_runtime.config import ( + HyfLoadedRuntimeConfig, + max_local_provider_configured, +) + + +@fieldwise_init +struct MaxLocalProviderConfig(Copyable, Movable): + var base_url: String + var health_url: String + var model: String + var route: String + var request_timeout_ms: Int + + +def max_local_provider_config_from_runtime( + config: HyfLoadedRuntimeConfig, +) raises -> MaxLocalProviderConfig: + if not max_local_provider_configured(config): + raise Error("max_local provider runtime is not configured") + + var source = config.effective.assisted.max_local.copy() + return MaxLocalProviderConfig( + base_url=String(source.base_url), + health_url=String(source.health_url), + model=String(source.model), + route=String(source.route), + request_timeout_ms=source.request_timeout_ms, + ) diff --git a/src/hyf_provider/health.mojo b/src/hyf_provider/health.mojo @@ -0,0 +1,47 @@ +from hyf_provider.client import make_max_local_http_client +from hyf_provider.config import MaxLocalProviderConfig +from hyf_provider.result import MaxLocalProviderStatus + + +def _provider_status( + config: MaxLocalProviderConfig, + reachable: Bool, + state: String, + reason: String, +) -> MaxLocalProviderStatus: + return MaxLocalProviderStatus( + backend_kind="max_local", + provider="max_local", + route=String(config.route), + model=String(config.model), + reachable=reachable, + state=String(state), + reason=String(reason), + ) + + +def _classify_health_error(message: String) -> String: + var lower = message.lower() + if lower.find("timeout") >= 0 or lower.find("timed out") >= 0: + return "timeout" + if lower.find("url") >= 0 or lower.find("scheme") >= 0: + return "invalid_url" + return "connection_failed" + + +def resolve_max_local_provider_status( + config: MaxLocalProviderConfig, +) -> MaxLocalProviderStatus: + try: + with make_max_local_http_client(config) as client: + var response = client.get(config.health_url) + if response.ok(): + return _provider_status(config, True, "ready", "ready") + return _provider_status(config, False, "unavailable", "non_2xx") + except e: + return _provider_status( + config, + False, + "unavailable", + _classify_health_error(String(e)), + ) diff --git a/src/hyf_provider/max_local.mojo b/src/hyf_provider/max_local.mojo @@ -0,0 +1,61 @@ +from hyf_core.capabilities.query_analysis import QueryAnalysis +from hyf_core.request_context import RequestContext +from hyf_provider.client import ( + make_max_local_http_client, + max_local_chat_completions_url, +) +from hyf_provider.config import MaxLocalProviderConfig +from hyf_provider.health import resolve_max_local_provider_status +from hyf_provider.result import ( + MaxLocalProviderStatus, + parse_query_analysis_from_chat_completion, +) +from hyf_provider.schema import ( + build_query_rewrite_request_body, + query_rewrite_prompt_version, + query_rewrite_schema_version, +) + + +@fieldwise_init +struct MaxLocalQueryRewriteResult(Copyable, Movable): + var analysis: QueryAnalysis + var provider: String + var route: String + var model: String + var latency_ms: Int + var schema_version: Int + var prompt_version: String + + +def execute_query_rewrite_via_max_local_provider( + config: MaxLocalProviderConfig, text: String, context: RequestContext +) raises -> MaxLocalQueryRewriteResult: + with make_max_local_http_client(config) as client: + var response = client.post( + max_local_chat_completions_url(config), + build_query_rewrite_request_body(config, text, context), + ) + if not response.ok(): + raise Error( + "max_local provider returned HTTP " + + String(response.status) + ) + + return MaxLocalQueryRewriteResult( + analysis=parse_query_analysis_from_chat_completion( + response.json() + ), + provider="max_local", + route=String(config.route), + model=String(config.model), + latency_ms=0, + schema_version=query_rewrite_schema_version(), + prompt_version=query_rewrite_prompt_version(), + ) + + +def max_local_provider_status( + config: MaxLocalProviderConfig, +) -> MaxLocalProviderStatus: + return resolve_max_local_provider_status(config) diff --git a/src/hyf_provider/result.mojo b/src/hyf_provider/result.mojo @@ -0,0 +1,121 @@ +from std.collections import List + +from json import Value, loads, validate + +from hyf_core.capabilities.query_analysis import ( + ExtractedFilters, + QueryAnalysis, +) +from hyf_provider.schema import query_rewrite_schema + + +@fieldwise_init +struct MaxLocalProviderStatus(Copyable, Movable): + var backend_kind: String + var provider: String + var route: String + var model: String + var reachable: Bool + var state: String + var reason: String + + +def _has_key(value: Value, key: String) -> Bool: + for candidate in value.object_keys(): + if candidate == key: + return True + return False + + +def _string_array(value: Value, context: String) raises -> List[String]: + if not value.is_array(): + raise Error(context + " must be an array") + + var items = List[String]() + for item in value.array_items(): + if not item.is_string(): + raise Error(context + " items must be strings") + items.append(item.string_value()) + return items^ + + +def _first_validation_error(value: Value) raises -> String: + var validation = validate(value, query_rewrite_schema()) + if validation.valid: + return "" + if len(validation.errors) == 0: + return "query_rewrite structured output failed schema validation" + var error = validation.errors[0].copy() + if error.path == "": + return String(error.message) + return String(error.path) + ": " + String(error.message) + + +def extract_chat_completion_text(response: Value) raises -> String: + if not response.is_object(): + raise Error("max_local response must be a JSON object") + if not _has_key(response, "choices"): + raise Error("max_local response must contain choices") + if ( + not response["choices"].is_array() + or len(response["choices"].array_items()) == 0 + ): + raise Error("max_local response choices must be a non-empty array") + + var message = response["choices"][0]["message"].clone() + if not message.is_object(): + raise Error("max_local response choice message must be an object") + + var content = message["content"].clone() + if content.is_string(): + return content.string_value() + + if content.is_array(): + var collected = String("") + for part in content.array_items(): + if ( + part.is_object() + and _has_key(part, "type") + and part["type"].is_string() + and part["type"].string_value() == "text" + and _has_key(part, "text") + and part["text"].is_string() + ): + collected += part["text"].string_value() + + if collected != "": + return collected^ + + raise Error("max_local response contained no text content") + + +def parse_query_analysis_json(value: Value) raises -> QueryAnalysis: + if not value.is_object(): + raise Error("query_rewrite structured output must be an object") + + var validation_error = _first_validation_error(value.clone()) + if validation_error != "": + raise Error(validation_error) + + var filters = value["extracted_filters"].clone() + return QueryAnalysis( + original_text=value["original_text"].string_value(), + normalized_text=value["normalized_text"].string_value(), + rewritten_text=value["rewritten_text"].string_value(), + query_terms=_string_array(value["query_terms"], "query_terms"), + normalization_signals=_string_array( + value["normalization_signals"], "normalization_signals" + ), + ranking_hints=_string_array(value["ranking_hints"], "ranking_hints"), + extracted_filters=ExtractedFilters( + local_intent=filters["local_intent"].bool_value(), + fulfillment=filters["fulfillment"].string_value(), + time_window=filters["time_window"].string_value(), + ), + ) + + +def parse_query_analysis_from_chat_completion( + response: Value, +) raises -> QueryAnalysis: + return parse_query_analysis_json(loads(extract_chat_completion_text(response))) diff --git a/src/hyf_provider/schema.mojo b/src/hyf_provider/schema.mojo @@ -0,0 +1,133 @@ +from json import Value, loads + +from hyf_core.request_context import RequestContext +from hyf_provider.config import MaxLocalProviderConfig + + +def query_rewrite_schema_version() -> Int: + return 1 + + +def query_rewrite_prompt_version() -> String: + return "max_local_query_rewrite_v1" + + +def query_rewrite_schema() raises -> Value: + var schema = loads("{}") + schema.set("type", Value("object")) + schema.set("additionalProperties", Value(False)) + + var required = loads("[]") + required.append(Value("original_text")) + required.append(Value("normalized_text")) + required.append(Value("rewritten_text")) + required.append(Value("query_terms")) + required.append(Value("normalization_signals")) + required.append(Value("ranking_hints")) + required.append(Value("extracted_filters")) + schema.set("required", required) + + var properties = loads("{}") + properties.set("original_text", loads('{"type":"string"}')) + properties.set("normalized_text", loads('{"type":"string"}')) + properties.set("rewritten_text", loads('{"type":"string"}')) + properties.set( + "query_terms", + loads('{"type":"array","items":{"type":"string"}}'), + ) + properties.set( + "normalization_signals", + loads('{"type":"array","items":{"type":"string"}}'), + ) + properties.set( + "ranking_hints", + loads('{"type":"array","items":{"type":"string"}}'), + ) + properties.set( + "extracted_filters", + loads( + '{"type":"object","additionalProperties":false,"required":["local_intent","fulfillment","time_window"],"properties":{"local_intent":{"type":"boolean"},"fulfillment":{"type":"string"},"time_window":{"type":"string"}}}' + ), + ) + schema.set("properties", properties) + return schema^ + + +def query_rewrite_system_prompt() -> String: + return ( + "Return only strict JSON matching the supplied schema. Preserve " + + "original_text, normalized_text, rewritten_text, query_terms, " + + "normalization_signals, ranking_hints, and extracted_filters." + ) + + +def build_query_rewrite_user_prompt( + text: String, context: RequestContext +) -> String: + var prompt = ( + "Rewrite the market search query into normalized search terms and " + + "extracted filters.\nquery: " + + text + + "\n" + ) + + if context.scope and len(context.scope.value().listing_ids) > 0: + var first = True + prompt += "scope_listing_ids: " + for listing_id in context.scope.value().listing_ids: + if not first: + prompt += "," + prompt += String(listing_id) + first = False + prompt += "\n" + + if context.time_range: + prompt += ( + "time_range: " + + context.time_range.value().start + + " -> " + + context.time_range.value().end + + "\n" + ) + + prompt += "consistency: " + context.consistency + "\n" + prompt += "evidence_limit: " + String(context.evidence_limit) + "\n" + prompt += "explain_plan: " + String(context.explain_plan) + "\n" + return prompt^ + + +def build_query_rewrite_request_body( + config: MaxLocalProviderConfig, text: String, context: RequestContext +) raises -> Value: + var body = loads("{}") + body.set("model", Value(String(config.model))) + + var messages = loads("[]") + + var system_message = loads("{}") + system_message.set("role", Value("system")) + system_message.set("content", Value(query_rewrite_system_prompt())) + messages.append(system_message) + + var user_message = loads("{}") + user_message.set("role", Value("user")) + user_message.set( + "content", Value(build_query_rewrite_user_prompt(text, context)) + ) + messages.append(user_message) + + body.set("messages", messages) + body.set("temperature", Value(0.1)) + body.set("max_tokens", Value(256)) + + var response_format = loads("{}") + response_format.set("type", Value("json_schema")) + + var json_schema = loads("{}") + json_schema.set("name", Value("query_rewrite")) + json_schema.set("strict", Value(True)) + json_schema.set("schema", query_rewrite_schema()) + response_format.set("json_schema", json_schema) + + body.set("response_format", response_format) + return body^ diff --git a/tests/test_provider_adapter.mojo b/tests/test_provider_adapter.mojo @@ -0,0 +1,164 @@ +from std.testing import TestSuite, assert_equal, assert_raises, assert_true + +from json import Value, loads + +from hyf_core.request_context import default_request_context +from hyf_provider.client import max_local_chat_completions_url +from hyf_provider.config import ( + MaxLocalProviderConfig, + max_local_provider_config_from_runtime, +) +from hyf_provider.result import parse_query_analysis_from_chat_completion +from hyf_provider.schema import build_query_rewrite_request_body +from hyf_runtime.config import ( + HyfAssistedRuntimeConfig, + HyfExecutionRuntimeConfig, + HyfLoadedRuntimeConfig, + HyfMaxLocalProviderRuntimeConfig, + HyfRuntimeConfig, + HyfServiceRuntimeConfig, + default_loaded_runtime_config, +) + + +def _provider_runtime_config() -> HyfLoadedRuntimeConfig: + return HyfLoadedRuntimeConfig( + artifact_present=True, + loaded=True, + compiled_defaults_active=False, + load_state="loaded", + load_error="", + effective=HyfRuntimeConfig( + service=HyfServiceRuntimeConfig(transport="stdio"), + runtime=HyfExecutionRuntimeConfig( + default_execution_mode="deterministic", + allow_assisted=True, + ), + assisted=HyfAssistedRuntimeConfig( + provider="max_local", + max_local=HyfMaxLocalProviderRuntimeConfig( + enabled=True, + base_url="http://127.0.0.1:8000/v1/", + health_url="http://127.0.0.1:8000/health", + model="max-local-query-rewrite", + route="provider_runtime.query_rewrite.max_local", + request_timeout_ms=15000, + ), + ), + ), + ) + + +def _provider_config() -> MaxLocalProviderConfig: + return MaxLocalProviderConfig( + base_url="http://127.0.0.1:8000/v1/", + health_url="http://127.0.0.1:8000/health", + model="max-local-query-rewrite", + route="provider_runtime.query_rewrite.max_local", + request_timeout_ms=15000, + ) + + +def _analysis_json_text() -> String: + return ( + '{"original_text":"eggs near me",' + '"normalized_text":"eggs near me",' + '"rewritten_text":"eggs",' + '"query_terms":["eggs"],' + '"normalization_signals":["local_intent_detected"],' + '"ranking_hints":["prefer_local_results"],' + '"extracted_filters":{' + '"local_intent":true,' + '"fulfillment":"unspecified",' + '"time_window":"unspecified"' + "}}" + ) + + +def _chat_completion_response() raises -> Value: + var response = loads("{}") + var choices = loads("[]") + var choice = loads("{}") + var message = loads("{}") + message.set("content", Value(_analysis_json_text())) + choice.set("message", message) + choices.append(choice) + response.set("choices", choices) + return response^ + + +def test_provider_config_maps_runtime_config() raises: + var config = max_local_provider_config_from_runtime( + _provider_runtime_config() + ) + + assert_equal(config.base_url, "http://127.0.0.1:8000/v1/") + assert_equal(config.health_url, "http://127.0.0.1:8000/health") + assert_equal(config.model, "max-local-query-rewrite") + assert_equal(config.route, "provider_runtime.query_rewrite.max_local") + assert_equal(config.request_timeout_ms, 15000) + + +def test_provider_config_rejects_unconfigured_runtime() raises: + with assert_raises(): + _ = max_local_provider_config_from_runtime( + default_loaded_runtime_config() + ) + + +def test_max_local_chat_completions_url_trims_base_url() raises: + assert_equal( + max_local_chat_completions_url(_provider_config()), + "http://127.0.0.1:8000/v1/chat/completions", + ) + + +def test_query_rewrite_request_body_sets_schema_contract() raises: + var context = default_request_context() + context.return_provenance = True + var body = build_query_rewrite_request_body( + _provider_config(), "eggs near me", context + ) + + assert_equal(body["model"].string_value(), "max-local-query-rewrite") + assert_equal(body["messages"][0]["role"].string_value(), "system") + assert_equal(body["messages"][1]["role"].string_value(), "user") + assert_true( + body["messages"][1]["content"].string_value().find("eggs near me") + >= 0 + ) + assert_equal(body["response_format"]["type"].string_value(), "json_schema") + assert_equal( + body["response_format"]["json_schema"]["name"].string_value(), + "query_rewrite", + ) + assert_equal( + body["response_format"]["json_schema"]["strict"].bool_value(), True + ) + assert_equal( + body["response_format"]["json_schema"]["schema"]["type"] + .string_value(), + "object", + ) + + +def test_chat_completion_response_parses_query_analysis() raises: + var analysis = parse_query_analysis_from_chat_completion( + _chat_completion_response() + ) + + assert_equal(analysis.original_text, "eggs near me") + assert_equal(analysis.normalized_text, "eggs near me") + assert_equal(analysis.rewritten_text, "eggs") + assert_equal(len(analysis.query_terms), 1) + assert_equal(analysis.query_terms[0], "eggs") + assert_equal(analysis.extracted_filters.local_intent, True) + + +def test_chat_completion_response_rejects_empty_choices() raises: + with assert_raises(): + _ = parse_query_analysis_from_chat_completion(loads('{"choices":[]}')) + + +def main() raises: + TestSuite.discover_tests[__functions_in_module()]().run()