From e557774b0bd50393b5f9a1f7c0a321e9731fcc2e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 17:26:57 -0600 Subject: [PATCH] Rebuild fabro-llm on lithos-llm and adopt its stream contract in fabro-agent fabro-llm is now a thin integration crate: catalog construction from the lithos built-ins, the Fabro policy layer, and operator overlays; client construction from catalog plus credentials; a Fabro `ModelResolver` that enforces `metadata.fabro` policy; model selection; a server gateway adapter; attachment inlining middleware; reasoning normalization; one-shot structured output; probe wiring; and catalog API views. The in-house codecs, transports, providers, tool loop, retry, cost, and token-count code are deleted along with the wire snapshots that covered them. fabro-agent consumes lithos `StreamEvent`s and `Response`s directly. Retry is split: lithos's retry middleware handles failures before any visible output, and the agent replays the turn after. Co-Authored-By: Claude Fable 5.1 --- lib/components/fabro-agent/Cargo.toml | 2 +- .../fabro-agent/src/agent_profile.rs | 45 +- lib/components/fabro-agent/src/apply_patch.rs | 64 +- lib/components/fabro-agent/src/cli.rs | 525 ++-- lib/components/fabro-agent/src/compaction.rs | 203 +- lib/components/fabro-agent/src/config.rs | 14 +- .../fabro-agent/src/context_window.rs | 132 +- lib/components/fabro-agent/src/error.rs | 76 +- .../fabro-agent/src/file_tracker.rs | 54 +- lib/components/fabro-agent/src/history.rs | 235 +- lib/components/fabro-agent/src/lib.rs | 2 +- .../fabro-agent/src/loop_detection.rs | 10 +- .../fabro-agent/src/mcp_integration.rs | 12 +- lib/components/fabro-agent/src/memory.rs | 2 +- .../fabro-agent/src/profiles/anthropic.rs | 13 +- .../fabro-agent/src/profiles/claude5.rs | 7 +- .../fabro-agent/src/profiles/claude5_tools.rs | 28 +- .../fabro-agent/src/profiles/gemini.rs | 11 +- .../fabro-agent/src/profiles/gpt56.rs | 54 +- .../fabro-agent/src/profiles/kimi.rs | 26 +- .../fabro-agent/src/profiles/kimi_tools.rs | 23 +- .../fabro-agent/src/profiles/mod.rs | 78 +- .../fabro-agent/src/profiles/openai.rs | 17 +- .../fabro-agent/src/question_tools.rs | 47 +- lib/components/fabro-agent/src/session.rs | 1765 ++++++----- lib/components/fabro-agent/src/skills.rs | 31 +- lib/components/fabro-agent/src/subagent.rs | 86 +- .../fabro-agent/src/task_reminder.rs | 6 +- .../fabro-agent/src/test_support.rs | 323 +- lib/components/fabro-agent/src/todo_tools.rs | 69 +- .../fabro-agent/src/tool_execution.rs | 203 +- .../fabro-agent/src/tool_registry.rs | 66 +- lib/components/fabro-agent/src/tools.rs | 184 +- lib/components/fabro-agent/src/truncation.rs | 6 +- lib/components/fabro-agent/src/types.rs | 156 +- lib/components/fabro-agent/src/web_search.rs | 19 +- .../fabro-agent/tests/it/compaction.rs | 32 +- .../fabro-agent/tests/it/guardrails.rs | 33 +- .../fabro-agent/tests/it/parity_matrix.rs | 177 +- lib/components/fabro-llm/Cargo.toml | 56 +- lib/components/fabro-llm/README.md | 301 -- .../fabro-llm/src/adapter_registry.rs | 431 --- lib/components/fabro-llm/src/api.rs | 164 + lib/components/fabro-llm/src/attachments.rs | 337 ++- lib/components/fabro-llm/src/catalog.rs | 498 ++++ lib/components/fabro-llm/src/client.rs | 2429 ++------------- .../src/codec/anthropic_messages/decode.rs | 265 -- .../src/codec/anthropic_messages/encode.rs | 1594 ---------- .../src/codec/anthropic_messages/mod.rs | 63 - .../src/codec/anthropic_messages/stream.rs | 653 ---- .../src/codec/anthropic_messages/wire.rs | 141 - .../src/codec/bedrock_converse/decode.rs | 311 -- .../src/codec/bedrock_converse/encode.rs | 765 ----- .../src/codec/bedrock_converse/mod.rs | 59 - .../src/codec/bedrock_converse/sanitize.rs | 122 - .../src/codec/bedrock_converse/stream.rs | 527 ---- lib/components/fabro-llm/src/codec/cache.rs | 98 - .../src/codec/gemini_generate/decode.rs | 370 --- .../src/codec/gemini_generate/encode.rs | 656 ---- .../src/codec/gemini_generate/mod.rs | 65 - .../src/codec/gemini_generate/stream.rs | 458 --- .../src/codec/gemini_generate/wire.rs | 98 - lib/components/fabro-llm/src/codec/mod.rs | 419 --- .../src/codec/openai_compatible/mod.rs | 43 - .../src/codec/openai_compatible/request.rs | 427 --- .../src/codec/openai_compatible/response.rs | 89 - .../src/codec/openai_compatible/stream.rs | 544 ---- .../src/codec/openai_compatible/translate.rs | 486 --- .../src/codec/openai_compatible/wire.rs | 669 ----- .../src/codec/openai_responses/decode.rs | 384 --- .../src/codec/openai_responses/encode.rs | 949 ------ .../src/codec/openai_responses/mod.rs | 55 - .../src/codec/openai_responses/stream.rs | 836 ------ .../src/codec/openai_responses/wire.rs | 67 - lib/components/fabro-llm/src/cost.rs | 250 -- lib/components/fabro-llm/src/error.rs | 1614 ++-------- lib/components/fabro-llm/src/gateway.rs | 306 ++ lib/components/fabro-llm/src/generate.rs | 2634 ----------------- lib/components/fabro-llm/src/lib.rs | 73 +- lib/components/fabro-llm/src/middleware.rs | 31 - lib/components/fabro-llm/src/model_test.rs | 400 --- lib/components/fabro-llm/src/probe.rs | 129 + lib/components/fabro-llm/src/provider.rs | 185 -- .../fabro-llm/src/providers/anthropic.rs | 414 --- .../src/providers/bedrock/eventstream.rs | 219 -- .../fabro-llm/src/providers/bedrock/mod.rs | 716 ----- .../fabro-llm/src/providers/bedrock/sigv4.rs | 249 -- .../fabro-llm/src/providers/common.rs | 145 - .../fabro-llm/src/providers/fabro_server.rs | 529 ---- .../fabro-llm/src/providers/gemini.rs | 272 -- lib/components/fabro-llm/src/providers/mod.rs | 14 - .../fabro-llm/src/providers/openai.rs | 590 ---- .../src/providers/openai_compatible.rs | 188 -- lib/components/fabro-llm/src/reasoning.rs | 178 +- lib/components/fabro-llm/src/resolver.rs | 206 ++ lib/components/fabro-llm/src/retry.rs | 337 --- lib/components/fabro-llm/src/selection.rs | 425 +++ lib/components/fabro-llm/src/structured.rs | 103 + lib/components/fabro-llm/src/test_support.rs | 253 ++ lib/components/fabro-llm/src/token_count.rs | 460 --- lib/components/fabro-llm/src/tools.rs | 523 ---- lib/components/fabro-llm/src/transport.rs | 610 ---- lib/components/fabro-llm/src/types.rs | 1041 ------- lib/components/fabro-llm/tests/integration.rs | 814 ----- lib/components/fabro-llm/tests/it/main.rs | 7 - lib/components/fabro-llm/tests/it/support.rs | 482 --- .../fabro-llm/tests/it/wire/anthropic.rs | 912 ------ .../fabro-llm/tests/it/wire/gemini.rs | 536 ---- lib/components/fabro-llm/tests/it/wire/mod.rs | 22 - .../tests/it/wire/openai_compatible.rs | 953 ------ .../tests/it/wire/openai_responses.rs | 655 ---- ...e__anthropic__count_tokens_wire_shape.snap | 78 - ...c__custom_named_stream_error_identity.snap | 14 - ...thropic__custom_named_stream_identity.snap | 58 - ..._anthropic__custom_named_stream_route.snap | 47 - ...hropic__decode_max_tokens_stop_reason.snap | 46 - ...decode_thinking_and_redacted_thinking.snap | 71 - ...nthropic__decode_tool_use_stop_reason.snap | 66 - ...e__anthropic__encode_audio_attachment.snap | 24 - ...ode_bad_file_path_attachments_dropped.snap | 20 - ..._anthropic__encode_inline_attachments.snap | 36 - ...t__wire__anthropic__encode_multi_turn.snap | 69 - ...pic__encode_prompt_cache_with_catalog.snap | 95 - ..._provider_options_anthropic_namespace.snap | 21 - ..._reasoning_effort_with_levels_catalog.snap | 26 - ...c__encode_response_format_json_object.snap | 21 - ...c__encode_response_format_json_schema.snap | 41 - ...re__anthropic__encode_sampling_params.snap | 27 - ...anthropic__encode_thinking_round_trip.snap | 43 - ...e__anthropic__encode_tool_choice_auto.snap | 52 - ...__anthropic__encode_tool_choice_named.snap | 53 - ...e__anthropic__encode_tool_choice_none.snap | 20 - ...nthropic__encode_tool_choice_required.snap | 52 - ...re__anthropic__encode_tool_round_trip.snap | 91 - ...re__anthropic__encode_url_attachments.snap | 34 - ...hropic__stream_error_event_mid_stream.snap | 14 - ...hropic__stream_text_happy_path_events.snap | 63 - ...ropic__stream_text_happy_path_request.snap | 21 - ..._stream_thinking_with_signature_delta.snap | 76 - ...e__anthropic__stream_tool_call_deltas.snap | 95 - ..._without_message_stop_emits_no_finish.snap | 22 - ...e__anthropic__system_and_tools_decode.snap | 48 - ...e__anthropic__system_and_tools_encode.snap | 66 - ...wire__gemini__count_tokens_wire_shape.snap | 93 - ...ini__custom_named_http_error_identity.snap | 9 - ..._gemini__custom_named_stream_identity.snap | 58 - ..._function_call_with_thought_signature.snap | 71 - ...mini__decode_max_tokens_finish_reason.snap | 5 - ...__gemini__decode_safety_finish_reason.snap | 5 - ...t__wire__gemini__decode_thought_parts.snap | 59 - ...wire__gemini__decode_usage_arithmetic.snap | 50 - ...wire__gemini__encode_audio_attachment.snap | 31 - ...ode_bad_file_path_attachments_dropped.snap | 25 - ...re__gemini__encode_inline_attachments.snap | 37 - .../it__wire__gemini__encode_multi_turn.snap | 48 - ..._options_can_override_safety_settings.snap | 20 - ...ode_provider_options_gemini_namespace.snap | 26 - ..._reasoning_effort_with_levels_catalog.snap | 25 - ...i__encode_response_format_json_object.snap | 26 - ...i__encode_response_format_json_schema.snap | 37 - ..._wire__gemini__encode_sampling_params.snap | 30 - ...e__gemini__encode_thinking_round_trip.snap | 41 - ...wire__gemini__encode_tool_choice_auto.snap | 63 - ...ire__gemini__encode_tool_choice_named.snap | 66 - ...wire__gemini__encode_tool_choice_none.snap | 63 - ...__gemini__encode_tool_choice_required.snap | 63 - ..._wire__gemini__encode_tool_round_trip.snap | 108 - ..._wire__gemini__encode_url_attachments.snap | 37 - ...thesizes_finish_without_finish_reason.snap | 54 - ...t__wire__gemini__stream_function_call.snap | 86 - ...gemini__stream_text_happy_path_events.snap | 63 - ...emini__stream_text_happy_path_request.snap | 51 - ...t__wire__gemini__stream_thought_parts.snap | 76 - ...wire__gemini__system_and_tools_decode.snap | 48 - ...wire__gemini__system_and_tools_encode.snap | 77 - ..._decode_reasoning_content_as_thinking.snap | 58 - ...code_tool_calls_with_string_arguments.snap | 67 - ...usage_openrouter_cost_and_cache_write.snap | 65 - ...le__decode_usage_parses_token_details.snap | 55 - ...e__decode_usage_venice_top_level_cost.snap | 55 - ...i_compatible__encode_audio_attachment.snap | 14 - ...ble__encode_bad_file_path_attachments.snap | 14 - ...compatible__encode_inline_attachments.snap | 14 - ..._openai_compatible__encode_multi_turn.snap | 26 - ...rovider_options_keyed_by_adapter_name.snap | 15 - ...vider_options_other_namespace_ignored.snap | 14 - ...e__encode_response_format_json_object.snap | 17 - ...e__encode_response_format_json_schema.snap | 32 - ...ai_compatible__encode_sampling_params.snap | 19 - ...nking_round_trip_as_reasoning_content.snap | 23 - ...i_compatible__encode_tool_choice_auto.snap | 50 - ..._compatible__encode_tool_choice_named.snap | 55 - ...i_compatible__encode_tool_choice_none.snap | 50 - ...mpatible__encode_tool_choice_required.snap | 50 - ...ai_compatible__encode_tool_round_trip.snap | 81 - ...ai_compatible__encode_url_attachments.snap | 14 - ...i_compatible__stream_reasoning_deltas.snap | 66 - ...atible__stream_text_happy_path_events.snap | 63 - ...tible__stream_text_happy_path_request.snap | 18 - ...i_compatible__stream_tool_call_deltas.snap | 95 - ...patible__stream_usage_openrouter_cost.snap | 60 - ...e__stream_usage_venice_top_level_cost.snap | 60 - ...t_done_or_content_synthesizes_nothing.snap | 9 - ...nthesizes_finish_when_content_started.snap | 58 - ...i_compatible__system_and_tools_decode.snap | 49 - ...i_compatible__system_and_tools_encode.snap | 62 - ...ai_responses__count_tokens_wire_shape.snap | 77 - ...om_named_stream_failed_event_identity.snap | 14 - ...code_incomplete_status_maps_to_length.snap | 65 - ...ode_reasoning_and_function_call_items.snap | 81 - ..._usage_subtracts_cached_and_reasoning.snap | 71 - ...ai_responses__encode_audio_attachment.snap | 28 - ...ode_bad_file_path_attachments_dropped.snap | 28 - ...ode_forces_streaming_and_omits_params.snap | 51 - ...onses__encode_dual_id_tool_round_trip.snap | 67 - ..._responses__encode_inline_attachments.snap | 32 - ...__openai_responses__encode_multi_turn.snap | 45 - ...onses__encode_opaque_items_round_trip.snap | 55 - ...ode_provider_options_openai_namespace.snap | 25 - ..._reasoning_effort_with_levels_catalog.snap | 27 - ...s__encode_response_format_json_object.snap | 29 - ...s__encode_response_format_json_schema.snap | 42 - ...nai_responses__encode_sampling_params.snap | 32 - ...responses__encode_thinking_round_trip.snap | 44 - ...ai_responses__encode_tool_choice_auto.snap | 56 - ...i_responses__encode_tool_choice_named.snap | 59 - ...ai_responses__encode_tool_choice_none.snap | 56 - ...esponses__encode_tool_choice_required.snap | 56 - ...nai_responses__encode_tool_round_trip.snap | 90 - ...nai_responses__encode_url_attachments.snap | 32 - ...es__stream_failed_event_maps_to_error.snap | 14 - ...ses__stream_incomplete_maps_to_length.snap | 63 - ...nses__stream_reasoning_summary_deltas.snap | 77 - ...ponses__stream_text_happy_path_events.snap | 74 - ...onses__stream_text_happy_path_request.snap | 25 - ...ai_responses__stream_tool_call_deltas.snap | 119 - ...ai_responses__system_and_tools_decode.snap | 71 - ...ai_responses__system_and_tools_encode.snap | 67 - 238 files changed, 5447 insertions(+), 38354 deletions(-) delete mode 100644 lib/components/fabro-llm/README.md delete mode 100644 lib/components/fabro-llm/src/adapter_registry.rs create mode 100644 lib/components/fabro-llm/src/api.rs create mode 100644 lib/components/fabro-llm/src/catalog.rs delete mode 100644 lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs delete mode 100644 lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs delete mode 100644 lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs delete mode 100644 lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs delete mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs delete mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs delete mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs delete mode 100644 lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs delete mode 100644 lib/components/fabro-llm/src/codec/cache.rs delete mode 100644 lib/components/fabro-llm/src/codec/gemini_generate/decode.rs delete mode 100644 lib/components/fabro-llm/src/codec/gemini_generate/encode.rs delete mode 100644 lib/components/fabro-llm/src/codec/gemini_generate/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/gemini_generate/stream.rs delete mode 100644 lib/components/fabro-llm/src/codec/gemini_generate/wire.rs delete mode 100644 lib/components/fabro-llm/src/codec/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/request.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/response.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/stream.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/translate.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_compatible/wire.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_responses/decode.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_responses/encode.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_responses/mod.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_responses/stream.rs delete mode 100644 lib/components/fabro-llm/src/codec/openai_responses/wire.rs delete mode 100644 lib/components/fabro-llm/src/cost.rs create mode 100644 lib/components/fabro-llm/src/gateway.rs delete mode 100644 lib/components/fabro-llm/src/generate.rs delete mode 100644 lib/components/fabro-llm/src/middleware.rs delete mode 100644 lib/components/fabro-llm/src/model_test.rs create mode 100644 lib/components/fabro-llm/src/probe.rs delete mode 100644 lib/components/fabro-llm/src/provider.rs delete mode 100644 lib/components/fabro-llm/src/providers/anthropic.rs delete mode 100644 lib/components/fabro-llm/src/providers/bedrock/eventstream.rs delete mode 100644 lib/components/fabro-llm/src/providers/bedrock/mod.rs delete mode 100644 lib/components/fabro-llm/src/providers/bedrock/sigv4.rs delete mode 100644 lib/components/fabro-llm/src/providers/common.rs delete mode 100644 lib/components/fabro-llm/src/providers/fabro_server.rs delete mode 100644 lib/components/fabro-llm/src/providers/gemini.rs delete mode 100644 lib/components/fabro-llm/src/providers/mod.rs delete mode 100644 lib/components/fabro-llm/src/providers/openai.rs delete mode 100644 lib/components/fabro-llm/src/providers/openai_compatible.rs create mode 100644 lib/components/fabro-llm/src/resolver.rs delete mode 100644 lib/components/fabro-llm/src/retry.rs create mode 100644 lib/components/fabro-llm/src/selection.rs create mode 100644 lib/components/fabro-llm/src/structured.rs create mode 100644 lib/components/fabro-llm/src/test_support.rs delete mode 100644 lib/components/fabro-llm/src/token_count.rs delete mode 100644 lib/components/fabro-llm/src/tools.rs delete mode 100644 lib/components/fabro-llm/src/transport.rs delete mode 100644 lib/components/fabro-llm/src/types.rs delete mode 100644 lib/components/fabro-llm/tests/integration.rs delete mode 100644 lib/components/fabro-llm/tests/it/main.rs delete mode 100644 lib/components/fabro-llm/tests/it/support.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/anthropic.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/gemini.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/mod.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/openai_compatible.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/openai_responses.rs delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap delete mode 100644 lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap diff --git a/lib/components/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml index 91f2e0e98..6ecc8ca81 100644 --- a/lib/components/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -28,7 +28,6 @@ fabro-auth = { path = "../../foundation/fabro-auth" } fabro-config = { path = "../../foundation/fabro-config", features = ["clap"] } fabro-types = { path = "../../foundation/fabro-types", features = ["clap"] } fabro-llm = { path = "../fabro-llm" } -fabro-model = { path = "../../foundation/fabro-model" } fabro-mcp = { path = "../fabro-mcp" } fabro-sandbox = { path = "../fabro-sandbox" } fabro-static.workspace = true @@ -60,6 +59,7 @@ libc = "0.2" [dev-dependencies] fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] } +fabro-llm = { path = "../fabro-llm", features = ["test-support"] } insta.workspace = true tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index 15140a3e2..06b74031c 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -1,5 +1,8 @@ -use fabro_llm::types::ToolDefinition; -use fabro_model::{AgentProfileKind, Catalog, Model, ProviderId}; +use std::sync::Arc; + +use fabro_llm::catalog::{self, ModelEntry}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition}; use crate::profiles::EnvContext; use crate::sandbox::Sandbox; @@ -10,11 +13,14 @@ use crate::subagent::{ }; use crate::tool_registry::ToolRegistry; +/// Context window assumed for a model the catalog does not describe. +pub const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 200_000; + pub trait AgentProfile: Send + Sync { fn profile_kind(&self) -> AgentProfileKind; fn provider_id(&self) -> ProviderId; fn model(&self) -> &str; - fn catalog(&self) -> Option<&Catalog> { + fn catalog(&self) -> Option<&Arc> { None } fn tool_registry(&self) -> &ToolRegistry; @@ -34,31 +40,32 @@ pub trait AgentProfile: Send + Sync { fn knowledge_cutoff(&self) -> Option { self.catalog_model() - .and_then(|m| m.knowledge_cutoff().map(str::to_string)) + .and_then(|entry| entry.policy.knowledge_cutoff) } - fn catalog_model(&self) -> Option<&Model> { + /// The catalog row for this profile's route, when the catalog knows it. + fn catalog_model(&self) -> Option> { let catalog = self.catalog()?; - catalog.get_on_provider(&self.provider_id(), self.model()) + catalog::model_on_provider(catalog, self.provider_id().as_str(), self.model()) } fn context_window_size(&self) -> usize { - self.catalog_model().map_or(200_000, |m| { - usize::try_from(m.context_window()).unwrap_or(usize::MAX) - }) + self.catalog_model() + .and_then(|entry| entry.model.limits()) + .map_or(DEFAULT_CONTEXT_WINDOW_TOKENS, |limits| { + usize::try_from(limits.context_tokens).unwrap_or(usize::MAX) + }) } - fn max_output_tokens(&self) -> Option { - self.catalog_model().and_then(Model::max_output) + fn max_output_tokens(&self) -> Option { + self.catalog_model() + .and_then(|entry| entry.model.limits()) + .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)) } fn reasons_by_default(&self) -> bool { - let Some(catalog) = self.catalog() else { - return false; - }; - catalog - .model_settings_on_provider(&self.provider_id(), self.model()) - .is_some_and(|settings| settings.reasoning_by_default) + self.catalog_model() + .is_some_and(|entry| entry.reasons_by_default()) } fn register_subagent_tools( @@ -83,7 +90,7 @@ pub trait AgentProfile: Send + Sync { #[cfg(test)] mod tests { - use fabro_model::{AgentProfileKind, ProviderId}; + use fabro_types::{AgentProfileKind, provider_ids}; use super::*; use crate::test_support::{MockSandbox, TestProfile}; @@ -92,7 +99,7 @@ mod tests { fn profile_provider_and_model() { let profile = TestProfile::new(); assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); - assert_eq!(profile.provider_id(), ProviderId::anthropic()); + assert_eq!(profile.provider_id(), provider_ids::anthropic()); assert_eq!(profile.model(), "mock-model"); } diff --git a/lib/components/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs index f3d264c66..b237e4a06 100644 --- a/lib/components/fabro-agent/src/apply_patch.rs +++ b/lib/components/fabro-agent/src/apply_patch.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use std::sync::Arc; -use fabro_llm::types::ToolDefinition; +use fabro_types::ToolDefinition; use crate::sandbox::Sandbox; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -502,16 +502,14 @@ pub fn make_apply_patch_tool() -> RegisteredTool { mod tests { use std::collections::HashMap; - use fabro_llm::types::{ - ContentPart, FinishReason, Message as LlmMessage, Response, Role, TokenCounts, ToolCall, - }; + use fabro_types::{ContentPart, ToolCall, tool_result_to_json}; use tokio::fs; use tokio_util::sync::CancellationToken; use super::*; use crate::LocalSandbox; use crate::test_support::MutableMockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; #[test] fn parse_apply_patch_add_file() { @@ -1689,7 +1687,9 @@ def gamma(): async fn e2e_through_tool_executor() { use crate::config::SessionOptions; use crate::session::Session; - use crate::test_support::{MockLlmProvider, TestProfile, make_client, text_response}; + use crate::test_support::{ + MockLlmProvider, TestProfile, make_client, response_with_parts, text_response, + }; use crate::tool_registry::ToolRegistry; // Set up sandbox with a file @@ -1731,28 +1731,9 @@ def farewell(name): *** Delete File: src/obsolete.py *** End Patch"; - let mut tool_call = ToolCall::new("call_1", "apply_patch", serde_json::json!(patch_text)); - tool_call.tool_type = "custom".to_string(); - tool_call.raw_arguments = Some(patch_text.to_string()); + let tool_call = ToolCall::custom("call_1", "apply_patch", patch_text); let responses = vec![ - Response { - id: "resp_call_1".to_string(), - model: "mock-model".to_string(), - provider: "mock".to_string(), - message: LlmMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call)], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, + response_with_parts("resp_call_1", vec![ContentPart::ToolCall(tool_call)]), text_response("Done! Updated greet and farewell functions."), ]; @@ -1787,7 +1768,9 @@ def farewell(name): async fn failed_custom_tool_call_returns_codex_style_error_to_session_history() { use crate::config::SessionOptions; use crate::session::Session; - use crate::test_support::{MockLlmProvider, TestProfile, make_client, text_response}; + use crate::test_support::{ + MockLlmProvider, TestProfile, make_client, response_with_parts, text_response, + }; use crate::tool_registry::ToolRegistry; use crate::types::Message as AgentMessage; @@ -1808,29 +1791,10 @@ def farewell(name): - return 1 + return 2 *** End Patch"; - let mut tool_call = ToolCall::new("call_1", "apply_patch", serde_json::json!(patch_text)); - tool_call.tool_type = "custom".to_string(); - tool_call.raw_arguments = Some(patch_text.to_string()); + let tool_call = ToolCall::custom("call_1", "apply_patch", patch_text); let responses = vec![ - Response { - id: "resp_call_1".to_string(), - model: "mock-model".to_string(), - provider: "mock".to_string(), - message: LlmMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call)], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, + response_with_parts("resp_call_1", vec![ContentPart::ToolCall(tool_call)]), text_response("I will correct the patch."), ]; @@ -1850,7 +1814,7 @@ def farewell(name): assert_eq!(results.len(), 1); assert!(results[0].is_error); assert_eq!( - results[0].content.as_str(), + tool_result_to_json(&results[0]).as_str(), Some("Failed to find context 'def missing():' in src/app.py") ); } diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index f7cfdd7f9..2d362f8f7 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -12,16 +12,12 @@ use clap::{Args, Parser}; use fabro_auth::{CredentialSource, SqlVaultCredentialSource}; use fabro_config::Storage; use fabro_config::user::default_storage_dir; -use fabro_llm::Error as LlmError; -use fabro_llm::client::Client; -use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{Request, Response}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::middleware::{Call, Middleware, Next, Output}; +use fabro_llm::{Client, ClientOptions, Error as LlmError, catalog}; use fabro_mcp::config::McpServerSettings; -#[cfg(test)] -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ModelSelectionError, ProviderId}; use fabro_static::EnvVars; +use fabro_types::{AgentProfileKind, ModelHandle, ModelId, ProviderId}; use fabro_util::terminal::Styles; use fabro_vault::SecretStore; use tokio::io::{AsyncWriteExt, stdout}; @@ -196,20 +192,15 @@ fn summarizer_model_id( catalog: &Catalog, selected_model: &str, ) -> ModelHandle { - ModelHandle::ByName { - provider: provider_id.clone(), - model: catalog - .default_for_provider(provider_id) + let model = + catalog::small_default_for_ready(catalog, &std::iter::once(provider_id.clone()).collect()) + .filter(|entry| entry.provider.id() == provider_id) + .or_else(|| catalog::default_model(catalog, provider_id.as_str())) .map_or_else( - || match provider_id.as_str() { - ProviderId::ANTHROPIC => "claude-haiku-4-5", - ProviderId::GEMINI => "gemini-2.0-flash", - _ => selected_model, - }, - |model| model.id.as_str(), - ) - .to_string(), - } + || selected_model.to_string(), + |entry| entry.model.id().to_string(), + ); + ModelHandle::new(provider_id.clone(), ModelId::new(model)) } fn build_summarizer( @@ -224,33 +215,33 @@ fn build_summarizer( } } -fn parse_provider(args: &AgentArgs) -> anyhow::Result { - let provider_str = args.provider.as_deref().unwrap_or("anthropic"); - Ok(provider_str.parse()?) +fn parse_provider(args: &AgentArgs) -> ProviderId { + ProviderId::new(args.provider.as_deref().unwrap_or("anthropic")) } fn resolve_provider_id( catalog: &Catalog, args: &AgentArgs, eligible_providers: &std::collections::HashSet, -) -> anyhow::Result { +) -> ProviderId { if args.provider.is_some() { - let requested = parse_provider(args)?; - return Ok(catalog - .provider(&requested) - .map_or(requested, |provider| provider.id.clone())); + let requested = parse_provider(args); + return catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested); } if let Some(model_id) = args.model.as_deref() { - match catalog.select(model_id, None, eligible_providers) { - Ok(model) => return Ok(model.provider.clone()), - Err(ModelSelectionError::UnknownSelector { .. }) => {} - Err(error) => return Err(error.into()), + // A bare model selector picks the highest-priority eligible provider + // offering it, matching how the client resolves the request. + let matches = catalog::models_matching(catalog, model_id); + if let Some(entry) = matches + .iter() + .find(|entry| eligible_providers.contains(entry.provider.id())) + .or_else(|| matches.first()) + { + return entry.provider.id().clone(); } } - let requested = parse_provider(args)?; - Ok(catalog - .provider(&requested) - .map_or(requested, |provider| provider.id.clone())) + let requested = parse_provider(args); + catalog::canonical_provider_id(catalog, requested.as_str()).unwrap_or(requested) } async fn standalone_llm_source() -> anyhow::Result> { @@ -266,17 +257,12 @@ fn profile_kind_for_provider( provider_id: &ProviderId, model: Option<&str>, ) -> anyhow::Result { - catalog - .effective_agent_profile(provider_id, model) + catalog::agent_profile(catalog, provider_id.as_str(), model) .ok_or_else(|| anyhow::anyhow!("provider '{provider_id}' is not configured")) } fn ensure_provider_registered(client: &Client, provider_id: &ProviderId) -> anyhow::Result<()> { - if client - .provider_names() - .iter() - .any(|name| *name == provider_id.as_str()) - { + if client.available_providers().contains(provider_id) { return Ok(()); } @@ -328,7 +314,7 @@ fn print_output(session: &Session, styles: &Styles) { reason = "Session summaries are diagnostic metadata, not assistant output." )] fn print_summary(session: &Session, styles: &Styles) { - let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0i64); + let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0u64); for turn in session.history().turns() { if let Message::Assistant { tool_calls, usage, .. @@ -336,7 +322,7 @@ fn print_summary(session: &Session, styles: &Styles) { { turn_count += 1; tool_call_count += tool_calls.len(); - total_tokens += usage.total_tokens(); + total_tokens = total_tokens.saturating_add(usage.total()); } } let token_str = if total_tokens >= 1_000_000 { @@ -365,38 +351,32 @@ impl Middleware for DebugMiddleware { clippy::print_stderr, reason = "Debug middleware logs request and response summaries to stderr." )] - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { + async fn handle(&self, call: Call, next: Next) -> Result { let s = self.styles; eprintln!( "{}", s.dim.apply_to(format!( "[debug] request: model={} messages={} tools={}", - request.model, - request.messages.len(), - request.tools.as_ref().map_or(0, Vec::len), + call.route().handle(), + call.request().messages().len(), + call.request().tools().len(), )), ); - let response = next(request).await?; - eprintln!( - "{}", - s.dim.apply_to(format!( - "[debug] response: model={} finish={:?} usage=({}/{}/{})", - response.model, - response.finish_reason, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.total_tokens(), - )), - ); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await + let output = next.run(call).await?; + if let Output::Complete(response) = &output { + eprintln!( + "{}", + s.dim.apply_to(format!( + "[debug] response: model={} finish={:?} usage=({}/{}/{})", + response.model, + response.finish_reason, + response.usage.input, + response.usage.output, + response.usage.total(), + )), + ); + } + Ok(output) } } @@ -411,55 +391,60 @@ impl Middleware for VerboseMiddleware { clippy::print_stderr, reason = "Verbose middleware dumps full request and response JSON to stderr." )] - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { + async fn handle(&self, call: Call, next: Next) -> Result { let s = self.styles; eprintln!( "{}\n{}", s.dim.apply_to("[verbose] request:"), - serde_json::to_string_pretty(&request) + serde_json::to_string_pretty(call.request()) .unwrap_or_else(|e| format!("")) ); - let response = next(request).await?; - eprintln!( - "{}\n{}", - s.dim.apply_to("[verbose] response:"), - serde_json::to_string_pretty(&response) - .unwrap_or_else(|e| format!("")) - ); - Ok(response) + let output = next.run(call).await?; + if let Output::Complete(response) = &output { + eprintln!( + "{}\n{}", + s.dim.apply_to("[verbose] response:"), + serde_json::to_string_pretty(response) + .unwrap_or_else(|e| format!("")) + ); + } + Ok(output) } +} - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await +/// Client options for the standalone agent: standard retries plus the +/// requested diagnostic middleware. +fn cli_client_options(args: &AgentArgs, styles: &'static Styles) -> ClientOptions { + let options = ClientOptions::standard(); + if args.verbose { + options.with_middleware(Arc::new(VerboseMiddleware { styles })) + } else if args.debug { + options.with_middleware(Arc::new(DebugMiddleware { styles })) + } else { + options } } +/// The catalog the standalone agent runs against: lithos built-ins, Fabro +/// policy, and the operator's `[llm]` overlay from the active settings file. +#[expect( + clippy::disallowed_methods, + reason = "Standalone agent honors OPENAI_BASE_URL from the process environment." +)] +fn standalone_catalog() -> anyhow::Result> { + let overlay = + fabro_config::load_llm_overlay(None).context("failed to load the LLM settings overlay")?; + let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok()) + .context("failed to build standalone agent LLM catalog")?; + Ok(Arc::new(catalog)) +} + pub async fn run_with_args( args: AgentArgs, mcp_servers: Vec, ) -> anyhow::Result<()> { let llm_source = standalone_llm_source().await?; - let catalog = - Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); - run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await -} - -#[allow( - clippy::print_stdout, - clippy::print_stderr, - reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." -)] -pub async fn run_with_args_and_source( - args: AgentArgs, - llm_source: Arc, - mcp_servers: Vec, -) -> anyhow::Result<()> { - let catalog = - Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); + let catalog = standalone_catalog()?; run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await } @@ -474,27 +459,31 @@ pub async fn run_with_args_and_source_and_catalog( mcp_servers: Vec, catalog: Arc, ) -> anyhow::Result<()> { - let client = Client::from_source(llm_source.as_ref(), Arc::clone(&catalog)) + // Resolve color support once, leak to get 'static lifetime for use across + // threads + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + let built = fabro_llm::build_client( + Catalog::clone(&catalog), + llm_source, + cli_client_options(&args, styles), + ) + .await + .context("Failed to create LLM client")?; + for issue in &built.build_issues { + eprintln!( + "{}", + styles.dim.apply_to(format!( + "[llm] provider '{}' is unavailable: {}", + issue.provider, issue.cause + )) + ); + } + run_with_args_and_client_and_catalog_styled(args, built.client, mcp_servers, catalog, styles) .await - .context("Failed to create LLM client")?; - run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await -} - -#[allow( - clippy::print_stdout, - clippy::print_stderr, - reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." -)] -pub async fn run_with_args_and_client( - args: AgentArgs, - client: Client, - mcp_servers: Vec, -) -> anyhow::Result<()> { - let catalog = - Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); - run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await } +/// Run against an already-built client, such as the `fabro exec` gateway +/// client. Diagnostic middleware is the caller's responsibility. #[allow( clippy::print_stdout, clippy::print_stderr, @@ -502,35 +491,49 @@ pub async fn run_with_args_and_client( )] pub async fn run_with_args_and_client_and_catalog( args: AgentArgs, - mut client: Client, + client: Client, mcp_servers: Vec, catalog: Arc, ) -> anyhow::Result<()> { - // Resolve color support once, leak to get 'static lifetime for use across - // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + run_with_args_and_client_and_catalog_styled(args, client, mcp_servers, catalog, styles).await +} - let provider_id = resolve_provider_id(&catalog, &args, &client.provider_ids())?; +/// Client options a caller building its own client can use so `--debug` and +/// `--verbose` behave the same as with the standalone client. +#[must_use] +pub fn diagnostic_client_options(args: &AgentArgs) -> ClientOptions { + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + cli_client_options(args, styles) +} + +#[allow( + clippy::print_stdout, + clippy::print_stderr, + reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." +)] +async fn run_with_args_and_client_and_catalog_styled( + args: AgentArgs, + client: Client, + mcp_servers: Vec, + catalog: Arc, + styles: &'static Styles, +) -> anyhow::Result<()> { + let available: std::collections::HashSet = + client.available_providers().iter().cloned().collect(); + let provider_id = resolve_provider_id(&catalog, &args, &available); ensure_provider_registered(&client, &provider_id)?; - if args.verbose { - client.add_middleware(Arc::new(VerboseMiddleware { styles })); - } else if args.debug { - client.add_middleware(Arc::new(DebugMiddleware { styles })); - } - let model = if let Some(model) = args.model.clone() { model } else { - catalog - .default_for_provider(&provider_id) - .map(|model| model.id.clone()) + catalog::default_model(&catalog, provider_id.as_str()) + .map(|entry| entry.model.id().to_string()) .ok_or_else(|| { anyhow::anyhow!( "provider '{provider_id}' has no default model in the catalog; pass --model explicitly" ) })? - .to_string() }; let profile_kind = profile_kind_for_provider(&catalog, &provider_id, Some(&model))?; eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); @@ -801,11 +804,10 @@ pub async fn run() -> anyhow::Result<()> { #[cfg(test)] mod tests { - use std::collections::HashMap; - - use fabro_model::catalog::{ - ModelCatalogSettings, ProviderCatalogSettings, SettingsModelFeatures, SettingsModelLimits, + use fabro_llm::test_support::{ + client_with_adapters, test_catalog as fabro_test_catalog, test_catalog_with_overlay, }; + use fabro_types::provider_ids; use serde_json::json; use super::*; @@ -941,13 +943,74 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) + } + + /// An operator-defined OpenAI-compatible provider with one Claude model, + /// the shape an `[llm]` overlay produces. + const ACME_OVERLAY: &str = r#" +[providers.acme-aws] +display_name = "Acme AWS" +aliases = ["br"] +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "https://example.invalid/v1" +auth = { type = "bearer" } +default_model = "acme-aws-claude" + +[providers.acme-aws.metadata.fabro] +agent_profile = "openai" +credentials = ["env:ACME_API_KEY"] + +[providers.acme-aws.models.acme-aws-claude] +display_name = "Acme AWS Claude" +api_model = "acme-aws-claude" +limits = { context_tokens = 1000, max_output_tokens = 500 } +capabilities = { text = true, tools = true } + +[providers.acme-aws.models.acme-aws-claude.metadata.fabro] +family = "claude" +agent_profile = "anthropic" +"#; + + /// The same provider with no models, so its default comes from the + /// operator's `--model` alone. + const ACME_OVERLAY_WITHOUT_MODELS: &str = r#" +[providers.acme-aws] +display_name = "Acme AWS" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = "https://example.invalid/v1" +auth = { type = "bearer" } +allow_passthrough = true + +[providers.acme-aws.metadata.fabro] +agent_profile = "openai" +credentials = ["env:ACME_API_KEY"] +"#; + + fn acme_catalog() -> Catalog { + test_catalog_with_overlay(ACME_OVERLAY) + } + + fn args_with(provider: Option<&str>, model: Option<&str>) -> AgentArgs { + AgentArgs { + prompt: "test".to_string(), + provider: provider.map(str::to_string), + model: model.map(str::to_string), + permissions: None, + auto_approve: false, + debug: false, + verbose: false, + skills_dir: None, + output_format: None, + } } #[test] fn ensure_provider_registered_reports_missing_credentials() { - let client = Client::new(HashMap::new(), None, vec![]); - let error = ensure_provider_registered(&client, &ProviderId::anthropic()).unwrap_err(); + let client = client_with_adapters(Vec::new(), ClientOptions::default()); + let error = ensure_provider_registered(&client, &provider_ids::anthropic()).unwrap_err(); assert_eq!( error.to_string(), "LLM credentials not configured for provider 'anthropic'" @@ -956,30 +1019,10 @@ mod tests { #[test] fn profile_kind_accepts_custom_catalog_provider() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::OpenAi), - ..ProviderCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let args = AgentArgs { - prompt: "test".to_string(), - provider: Some("acme-aws".to_string()), - model: None, - permissions: None, - auto_approve: false, - debug: false, - verbose: false, - skills_dir: None, - output_format: None, - }; + let catalog = acme_catalog(); + let args = args_with(Some("acme-aws"), None); - let provider_id = parse_provider(&args).unwrap(); + let provider_id = parse_provider(&args); assert_eq!(provider_id, ProviderId::new("acme-aws")); assert_eq!( profile_kind_for_provider(&catalog, &provider_id, None).unwrap(), @@ -989,127 +1032,29 @@ mod tests { #[test] fn standalone_provider_resolution_uses_catalog_model_provider_when_provider_omitted() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::OpenAi), - ..ProviderCatalogSettings::default() - }); - settings - .models - .insert("acme-aws-claude".to_string(), ModelCatalogSettings { - provider: Some("acme-aws".to_string()), - display_name: Some("Acme AWS Claude".to_string()), - family: Some("claude".to_string()), - default: Some(true), - limits: Some(SettingsModelLimits { - context_window: Some(1000), - max_output: None, - }), - features: Some(SettingsModelFeatures { - tools: Some(true), - vision: Some(false), - reasoning: Some(false), - reasoning_by_default: None, - reasoning_effort: None, - prompt_cache: None, - cache_control_breakpoints: None, - sampling_params: None, - }), - ..ModelCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let args = AgentArgs { - prompt: "test".to_string(), - provider: None, - model: Some("acme-aws-claude".to_string()), - permissions: None, - auto_approve: false, - debug: false, - verbose: false, - skills_dir: None, - output_format: None, - }; + let catalog = acme_catalog(); + let args = args_with(None, Some("acme-aws-claude")); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(), + resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), ProviderId::new("acme-aws") ); } #[test] fn standalone_provider_resolution_canonicalizes_explicit_provider_alias() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::OpenAi), - aliases: Some(vec!["br".to_string()]), - ..ProviderCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let args = AgentArgs { - prompt: "test".to_string(), - provider: Some("br".to_string()), - model: None, - permissions: None, - auto_approve: false, - debug: false, - verbose: false, - skills_dir: None, - output_format: None, - }; + let catalog = acme_catalog(); + let args = args_with(Some("br"), None); assert_eq!( - resolve_provider_id(&catalog, &args, &catalog.all_provider_ids()).unwrap(), + resolve_provider_id(&catalog, &args, &catalog::enabled_provider_ids(&catalog)), ProviderId::new("acme-aws") ); } #[test] fn standalone_profile_kind_uses_model_agent_profile_override() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::OpenAi), - ..ProviderCatalogSettings::default() - }); - settings - .models - .insert("acme-aws-claude".to_string(), ModelCatalogSettings { - provider: Some("acme-aws".to_string()), - display_name: Some("Acme AWS Claude".to_string()), - family: Some("claude".to_string()), - default: Some(true), - agent_profile: Some(AgentProfileKind::Anthropic), - limits: Some(SettingsModelLimits { - context_window: Some(1000), - max_output: None, - }), - features: Some(SettingsModelFeatures { - tools: Some(true), - vision: Some(false), - reasoning: Some(false), - reasoning_by_default: None, - reasoning_effort: None, - prompt_cache: None, - cache_control_breakpoints: None, - sampling_params: None, - }), - ..ModelCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); + let catalog = acme_catalog(); assert_eq!( profile_kind_for_provider( @@ -1124,44 +1069,22 @@ mod tests { #[test] fn summarizer_model_id_uses_selected_model_for_custom_provider_without_default() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::OpenAi), - ..ProviderCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); + let catalog = test_catalog_with_overlay(ACME_OVERLAY_WITHOUT_MODELS); let provider_id = ProviderId::new("acme-aws"); let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6"); assert_eq!(model_id.provider(), &provider_id); - assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6"); + assert_eq!(model_id.model().as_str(), "acme-aws-claude-sonnet-4-6"); } #[test] - fn summarizer_model_id_ignores_profile_for_custom_provider_without_default() { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert("acme-aws".to_string(), ProviderCatalogSettings { - display_name: Some("Acme AWS".to_string()), - adapter: Some("openai_compatible".to_string()), - base_url: Some("https://example.invalid/v1".to_string()), - agent_profile: Some(AgentProfileKind::Anthropic), - ..ProviderCatalogSettings::default() - }); - let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let provider_id = ProviderId::new("acme-aws"); + fn summarizer_model_id_prefers_the_provider_small_default() { + let catalog = test_catalog(); + let model_id = summarizer_model_id(&provider_ids::openai(), &catalog, "gpt-5.4"); - let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6"); - - assert_eq!(model_id.provider(), &provider_id); - assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6"); + assert_eq!(model_id.provider(), &provider_ids::openai()); + assert_eq!(model_id.model().as_str(), "gpt-5.4-mini"); } // subagent tool registration tests @@ -1170,7 +1093,7 @@ mod tests { fn build_profile_can_register_subagent_tools() { let mut profile = AgentProfileBuilder::new( AgentProfileKind::Anthropic, - ProviderId::anthropic(), + provider_ids::anthropic(), "model", test_catalog(), ) diff --git a/lib/components/fabro-agent/src/compaction.rs b/lib/components/fabro-agent/src/compaction.rs index ad634955d..168c908fe 100644 --- a/lib/components/fabro-agent/src/compaction.rs +++ b/lib/components/fabro-agent/src/compaction.rs @@ -1,7 +1,7 @@ use std::fmt::Write; -use fabro_llm::client::Client; -use fabro_llm::types::{Message as LlmMessage, Request}; +use fabro_llm::{Client, Request}; +use fabro_types::{tool_call_arguments, tool_result_to_json}; use tracing::debug; use crate::agent_profile::AgentProfile; @@ -14,13 +14,13 @@ use crate::types::{AgentEvent, Message}; const APPROX_CHARS_PER_TOKEN: usize = 4; /// Maximum output budget for the visible summary text itself. -const SUMMARY_MAX_TOKENS: i64 = 4096; +const SUMMARY_MAX_TOKENS: u32 = 4096; /// Extra output budget for models that reason on every request. `max_tokens` /// bounds reasoning *plus* visible output, so a reasoning model handed only /// `SUMMARY_MAX_TOKENS` can spend the whole budget thinking and return a /// successful response with empty content — a silently empty summary. -const REASONING_HEADROOM_TOKENS: i64 = 16_384; +const REASONING_HEADROOM_TOKENS: u32 = 16_384; #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] @@ -138,32 +138,29 @@ function names, error messages, and exact values. Omit pleasantries and conversa {file_ops_section}" ); - let summary_request = Request { - model: provider_profile.model().to_string(), - messages: vec![ - LlmMessage::system(summarization_prompt), - LlmMessage::user(format!( - "Here is the conversation to summarize:\n\n{rendered}" - )), - ], - provider: Some(provider_profile.provider_id().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(max_tokens), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; + let summary_request = Request::builder() + .model(format!( + "{}/{}", + provider_profile.provider_id(), + provider_profile.model() + )) + .system(summarization_prompt) + .user(format!( + "Here is the conversation to summarize:\n\n{rendered}" + )) + .max_output_tokens(max_tokens) + .build() + .map_err(|err| { + CompactionError::from(fabro_llm::Error::new( + fabro_llm::ErrorKind::InvalidRequest, + format!("invalid summarization request: {err}"), + )) + })?; let response = llm_client - .complete(&summary_request) + .complete(summary_request) .await - .map_err(CompactionError::Llm)?; + .map_err(CompactionError::from)?; let response_text = response.text(); let summary_text = response_text.trim(); @@ -208,7 +205,7 @@ Build on their progress — do not repeat completed steps.\n\n{summary_text}" /// as well as the summary. Provider routes that reason by default get headroom /// on top of the summary allowance. Every known model budget is capped at its /// declared `max_output`. -fn summary_max_tokens(reasoning_by_default: bool, max_output: Option) -> i64 { +fn summary_max_tokens(reasoning_by_default: bool, max_output: Option) -> u32 { let budget = if reasoning_by_default { SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS } else { @@ -264,7 +261,7 @@ pub(crate) fn estimate_active_context_usage( fn latest_assistant_usage_baseline(turns: &[Message]) -> Option<(usize, usize)> { turns.iter().enumerate().rev().find_map(|(index, turn)| { if let Message::Assistant { usage, .. } = turn { - let total_tokens = usage.total_tokens(); + let total_tokens = usage.total(); if total_tokens > 0 { return Some((index, usize::try_from(total_tokens).unwrap_or(usize::MAX))); } @@ -300,13 +297,14 @@ fn estimate_turn_chars(turn: &Message) -> usize { let reasoning_chars = turn.reasoning_text().map_or(0, str::len); let tool_call_chars: usize = tool_calls .iter() - .map(|tc| tc.name.len() + tc.arguments.to_string().len()) + .map(|tc| tc.name.len() + tc.input.raw().len()) .sum(); content.len() + reasoning_chars + tool_call_chars } - Message::ToolResults { results, .. } => { - results.iter().map(|r| r.content.to_string().len()).sum() - } + Message::ToolResults { results, .. } => results + .iter() + .map(|r| tool_result_to_json(r).to_string().len()) + .sum(), } } @@ -328,7 +326,7 @@ pub fn render_turns_for_summary(turns: &[Message]) -> String { let _ = writeln!(out, "Assistant: {content}"); } for tc in tool_calls { - let args_str = tc.arguments.to_string(); + let args_str = tool_call_arguments(tc).to_string(); let truncated = if args_str.len() > 500 { format!("{}...", &args_str[..args_str.floor_char_boundary(500)]) } else { @@ -339,7 +337,7 @@ pub fn render_turns_for_summary(turns: &[Message]) -> String { } Message::ToolResults { results, .. } => { for r in results { - let content_str = r.content.to_string(); + let content_str = tool_result_to_json(r).to_string(); let truncated = if content_str.len() > 500 { format!( "{}...", @@ -367,8 +365,10 @@ mod tests { use std::sync::Arc; use std::time::SystemTime; - use fabro_llm::types::{TokenCounts, ToolCall, ToolResult}; - use fabro_model::{Catalog, Model, ProviderId}; + use fabro_llm::catalog::model_on_provider; + use fabro_llm::lithos_catalog::Catalog; + use fabro_llm::test_support::test_catalog; + use fabro_types::{TokenCounts, ToolCall, tool_result_from_json}; use super::*; use crate::event::Emitter; @@ -377,19 +377,18 @@ mod tests { use crate::tool_registry::ToolRegistry; use crate::types::Message; - fn catalog_model(provider: &ProviderId, id: &str) -> &'static Model { - Catalog::builtin() - .get_on_provider(provider, id) - .unwrap_or_else(|| panic!("{provider}/{id} missing from builtin catalog")) + fn catalog() -> Catalog { + test_catalog() } - fn builtin_summary_max_tokens(provider: &ProviderId, id: &str) -> i64 { - let catalog = Catalog::builtin(); - let model = catalog_model(provider, id); - let settings = catalog - .settings_for(model) - .unwrap_or_else(|| panic!("{provider}/{id} missing catalog settings")); - summary_max_tokens(settings.reasoning_by_default, model.max_output()) + fn builtin_summary_max_tokens(catalog: &Catalog, provider: &str, id: &str) -> u32 { + let entry = model_on_provider(catalog, provider, id) + .unwrap_or_else(|| panic!("{provider}/{id} missing from the catalog")); + let max_output = entry + .model + .limits() + .map(|limits| u32::try_from(limits.max_output_tokens).unwrap_or(u32::MAX)); + summary_max_tokens(entry.reasons_by_default(), max_output) } #[test] @@ -405,22 +404,23 @@ mod tests { #[test] fn summary_budget_for_non_reasoning_model_is_summary_allowance() { - // claude-haiku-4-5: reasoning = false. + // claude-haiku-4.5: reasoning = false. assert_eq!( - builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-haiku-4-5"), + builtin_summary_max_tokens(&catalog(), "anthropic", "claude-haiku-4.5"), SUMMARY_MAX_TOKENS ); } #[test] fn summary_budget_for_model_without_effort_feature_is_summary_allowance() { - // claude-sonnet-4-5 reasons only when a request asks for it, and - // compaction never sends a reasoning effort. - let model = catalog_model(&ProviderId::anthropic(), "claude-sonnet-4-5"); - assert!(model.supports_reasoning()); - assert!(!model.supports_reasoning_effort()); + // claude-sonnet-4.5 reasons only when a request asks for a thinking + // budget, and compaction never sends one. + let catalog = catalog(); + let entry = model_on_provider(&catalog, "anthropic", "claude-sonnet-4.5").unwrap(); + assert!(entry.model.capabilities().reasoning().is_supported()); + assert!(!entry.model.protocol_options().reasoning_effort_levels); assert_eq!( - builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-sonnet-4-5"), + builtin_summary_max_tokens(&catalog, "anthropic", "claude-sonnet-4.5"), SUMMARY_MAX_TOKENS ); } @@ -428,7 +428,7 @@ mod tests { #[test] fn summary_budget_for_always_adaptive_model_adds_reasoning_headroom() { assert_eq!( - builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-fable-5"), + builtin_summary_max_tokens(&catalog(), "anthropic", "claude-fable-5"), SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); } @@ -436,19 +436,20 @@ mod tests { #[test] fn summary_budget_for_effort_levels_model_adds_reasoning_headroom() { assert_eq!( - builtin_summary_max_tokens(&ProviderId::anthropic(), "claude-opus-5"), + builtin_summary_max_tokens(&catalog(), "anthropic", "claude-opus-5"), SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); } #[test] fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() { - let moonshot = ProviderId::new("moonshot"); - let model = catalog_model(&moonshot, "kimi-k2.5"); - assert!(model.supports_reasoning()); - assert!(!model.supports_reasoning_effort()); + // Kimi K2.5 takes no effort levels but always reasons, which Fabro + // policy states outright. + let catalog = catalog(); + let entry = model_on_provider(&catalog, "moonshot", "kimi-k2.5").unwrap(); + assert!(!entry.model.protocol_options().reasoning_effort_levels); assert_eq!( - builtin_summary_max_tokens(&moonshot, "kimi-k2.5"), + builtin_summary_max_tokens(&catalog, "moonshot", "kimi-k2.5"), SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS ); } @@ -468,24 +469,22 @@ mod tests { }, Message::Assistant { content: "Let me check".into(), - tool_calls: vec![ToolCall::new( + tool_calls: vec![ToolCall::function( "c1", "read_file", serde_json::json!({"path": "foo.rs"}), )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }, Message::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!("file contents here"), - is_error: false, - image_data: None, - image_media_type: None, - }], + results: vec![tool_result_from_json( + "c1", + serde_json::json!("file contents here"), + false, + )], timestamp: SystemTime::now(), }, ]; @@ -502,13 +501,11 @@ mod tests { fn render_turns_truncates_long_tool_output() { let long_output = "x".repeat(1000); let turns = vec![Message::ToolResults { - results: vec![ToolResult { - tool_call_id: "c1".into(), - content: serde_json::json!(long_output), - is_error: false, - image_data: None, - image_media_type: None, - }], + results: vec![tool_result_from_json( + "c1", + serde_json::json!(long_output), + false, + )], timestamp: SystemTime::now(), }]; let rendered = render_turns_for_summary(&turns); @@ -540,19 +537,23 @@ mod tests { history.push(Message::Assistant { // 18 chars content + tool call name (9) + args (16) = 43 chars => 10 tokens content: "No usage available".into(), - tool_calls: vec![ToolCall::new( + tool_calls: vec![ToolCall::function( "call_1", "read_file", serde_json::json!({"path": "foo.rs"}), )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); history.push(Message::ToolResults { // 4 chars => 1 token - results: vec![ToolResult::success("call_1", serde_json::json!(1234))], + results: vec![tool_result_from_json( + "call_1", + serde_json::json!(1234), + false, + )], timestamp: SystemTime::now(), }); @@ -588,16 +589,20 @@ mod tests { content: "baseline response".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts { - input_tokens: 50, + usage: TokenCounts { + input: 50, ..TokenCounts::default() - }), + }, response_id: "resp_1".into(), timestamp: SystemTime::now(), }); history.push(Message::ToolResults { // JSON number renders as 4 chars => 1 local token. - results: vec![ToolResult::success("call_1", serde_json::json!(1234))], + results: vec![tool_result_from_json( + "call_1", + serde_json::json!(1234), + false, + )], timestamp: SystemTime::now(), }); history.push(Message::User { @@ -627,13 +632,13 @@ mod tests { content: "short".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts { - input_tokens: 10, - output_tokens: 20, - reasoning_tokens: 30, - cache_read_tokens: 40, - cache_write_tokens: 50, - }), + usage: TokenCounts { + input: 10, + output: 20, + reasoning: 30, + cache_read: 40, + cache_write: 50, + }, response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -654,10 +659,10 @@ mod tests { content: "older response".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts { - input_tokens: 1_000, + usage: TokenCounts { + input: 1_000, ..TokenCounts::default() - }), + }, response_id: "resp_old".into(), timestamp: SystemTime::now(), }); @@ -669,10 +674,10 @@ mod tests { content: "latest response".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts { - input_tokens: 20, + usage: TokenCounts { + input: 20, ..TokenCounts::default() - }), + }, response_id: "resp_new".into(), timestamp: SystemTime::now(), }); diff --git a/lib/components/fabro-agent/src/config.rs b/lib/components/fabro-agent/src/config.rs index cc207c3f4..f660334d8 100644 --- a/lib/components/fabro-agent/src/config.rs +++ b/lib/components/fabro-agent/src/config.rs @@ -2,10 +2,10 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use fabro_llm::types::{ReasoningEffort, Speed}; +use fabro_llm::RetryPolicy; +use fabro_llm::client::default_retry_policy; use fabro_mcp::config::McpServerSettings; -use fabro_model::AgentProfileKind; -use fabro_types::PermissionLevel; +use fabro_types::{AgentProfileKind, PermissionLevel, ReasoningEffort, Speed}; /// Callback invoked before each tool execution. Return `Ok(())` to allow, /// `Err(message)` to deny with the given message. @@ -168,7 +168,11 @@ pub struct SessionOptions { pub tool_line_limits: HashMap, /// Override the provider's default max_tokens when set. /// Node-level attribute takes priority over the model catalog default. - pub max_tokens: Option, + pub max_tokens: Option, + /// Same-route retry policy for replaying a turn whose stream failed after + /// visible output was already shown. Retries before visible output are + /// the client's; this bounds the agent's own replays. + pub replay_retry_policy: RetryPolicy, pub enable_loop_detection: bool, pub loop_detection_window: usize, pub max_subagent_depth: usize, @@ -200,6 +204,7 @@ impl std::fmt::Debug for SessionOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SessionOptions") .field("max_tokens", &self.max_tokens) + .field("replay_retry_policy", &self.replay_retry_policy) .field("reasoning_effort", &self.reasoning_effort) .field("speed", &self.speed) .field("tool_output_limits", &self.tool_output_limits) @@ -236,6 +241,7 @@ impl Default for SessionOptions { fn default() -> Self { Self { max_tokens: None, + replay_retry_policy: default_retry_policy(), reasoning_effort: None, speed: None, tool_output_limits: HashMap::new(), diff --git a/lib/components/fabro-agent/src/context_window.rs b/lib/components/fabro-agent/src/context_window.rs index 851a51ea6..1d7964ee5 100644 --- a/lib/components/fabro-agent/src/context_window.rs +++ b/lib/components/fabro-agent/src/context_window.rs @@ -1,14 +1,12 @@ use std::collections::{BTreeMap, HashSet}; use chrono::Utc; -use fabro_llm::token_count::{ - estimate_message_tokens, estimate_request_control_tokens, estimate_text_tokens, - estimate_tool_definition_tokens, is_local_estimator_warning, -}; -use fabro_llm::types::{Request, Role, TokenCounts, Warning as LlmWarning}; +use fabro_llm::Request; +use fabro_llm::estimate::{self, EstimateWarning, TokenEstimate}; use fabro_types::{ - StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, - StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, + Role, StageContextWindowBreakdownItem, StageContextWindowCategory, + StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, + StageContextWindowWarning, TokenCounts, text_of, }; use crate::memory::MemoryDocument; @@ -126,34 +124,64 @@ pub(crate) fn context_window_from_response_usage( usage: &TokenCounts, ) -> StageContextWindowProjection { let input_tokens = usage - .input_tokens - .saturating_add(usage.cache_read_tokens) - .saturating_add(usage.cache_write_tokens); - if input_tokens <= 0 { + .input + .saturating_add(usage.cache_read) + .saturating_add(usage.cache_write); + if input_tokens == 0 { return local_snapshot.clone(); } scaled_snapshot( local_snapshot, - u64::try_from(input_tokens).unwrap_or(u64::MAX), + input_tokens, StageContextWindowCountMethod::ResponseUsageScaledBreakdown, local_snapshot.warnings.clone(), ) } +/// Warning code for media parts sized by bytes rather than tokenized. +pub(crate) const MEDIA_ESTIMATE_WARNING: &str = "media_token_estimate"; +/// Warning code for provider-native opaque parts measured as JSON text. +pub(crate) const OPAQUE_CONTEXT_ESTIMATE_WARNING: &str = "opaque_context_estimate"; +/// Warning code for provider options measured as JSON text. +const PROVIDER_OPTIONS_ESTIMATE_WARNING: &str = "provider_options_estimate"; + +/// Fabro's stable code for a lithos estimator warning. +fn warning_code(warning: EstimateWarning) -> &'static str { + match warning { + EstimateWarning::Media => MEDIA_ESTIMATE_WARNING, + EstimateWarning::OpaqueContent => OPAQUE_CONTEXT_ESTIMATE_WARNING, + EstimateWarning::ProviderOptions => PROVIDER_OPTIONS_ESTIMATE_WARNING, + _ => "token_count_warning", + } +} + +/// Whether a warning code describes local-estimator imprecision rather than +/// a fact about the conversation. +fn is_local_estimator_warning(code: &str) -> bool { + matches!( + code, + MEDIA_ESTIMATE_WARNING + | OPAQUE_CONTEXT_ESTIMATE_WARNING + | PROVIDER_OPTIONS_ESTIMATE_WARNING + | "token_count_warning" + ) +} + #[must_use] -fn warnings_from_llm(warnings: &[LlmWarning]) -> Vec { - warnings - .iter() +fn warnings_from_estimate(estimate: &TokenEstimate) -> Vec { + estimate + .warnings() .map(|warning| StageContextWindowWarning { - code: warning - .code - .clone() - .unwrap_or_else(|| "token_count_warning".to_string()), - message: warning.message.clone(), + code: warning_code(warning).to_string(), + message: warning.to_string(), }) .collect() } +fn to_usize(tokens: u64) -> usize { + usize::try_from(tokens).unwrap_or(usize::MAX) +} + fn add_message_breakdown( builder: &mut BreakdownBuilder, warnings: &mut Vec, @@ -161,34 +189,35 @@ fn add_message_breakdown( ) { let memory_text = memory_prompt_suffix(input.memory); let skills_text = skills_prompt_suffix(input.skills, input.tool_vocabulary); - let memory_tokens = estimate_text_tokens(&memory_text); - let skills_tokens = estimate_text_tokens(&skills_text); + let memory_tokens = to_usize(estimate::text_tokens(&memory_text)); + let skills_tokens = to_usize(estimate::text_tokens(&skills_text)); let mut system_parts_seen = false; - for message in &input.request.messages { - let estimate = estimate_message_tokens(message); - warnings.extend(warnings_from_llm(&estimate.warnings)); - if message.role == Role::System + for message in input.request.messages() { + let estimate = estimate::message_tokens(message); + warnings.extend(warnings_from_estimate(&estimate)); + let tokens = to_usize(estimate.tokens()); + if message.role() == Role::System && !system_parts_seen - && message.text() == input.system_prompt + && text_of(message.content()) == input.system_prompt { system_parts_seen = true; let attributed_suffix = memory_tokens.saturating_add(skills_tokens); builder.add( StageContextWindowCategory::SystemPrompt, - estimate.tokens.saturating_sub(attributed_suffix), + tokens.saturating_sub(attributed_suffix), ); builder.add(StageContextWindowCategory::Memory, memory_tokens); builder.add(StageContextWindowCategory::Skills, skills_tokens); } else { - builder.add(StageContextWindowCategory::Conversation, estimate.tokens); + builder.add(StageContextWindowCategory::Conversation, tokens); } } } fn add_tool_breakdown(builder: &mut BreakdownBuilder, tools: &[ToolDefinitionWithSource]) { for tool in tools { - let tokens = estimate_tool_definition_tokens(&tool.definition); + let tokens = to_usize(estimate::tool_definition_tokens(&tool.definition)); match &tool.source { ToolSource::Native => builder.add(StageContextWindowCategory::Tools, tokens), ToolSource::Mcp { .. } => builder.add(StageContextWindowCategory::McpTools, tokens), @@ -202,9 +231,12 @@ fn add_request_control_breakdown( warnings: &mut Vec, request: &Request, ) { - let estimate = estimate_request_control_tokens(request); - warnings.extend(warnings_from_llm(&estimate.warnings)); - builder.add(StageContextWindowCategory::Other, estimate.tokens); + let estimate = estimate::request_control_tokens(request); + warnings.extend(warnings_from_estimate(&estimate)); + builder.add( + StageContextWindowCategory::Other, + to_usize(estimate.tokens()), + ); } fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String { @@ -340,28 +372,24 @@ fn usage_percent(tokens: u64, denominator: u64) -> f64 { #[cfg(test)] mod tests { - use fabro_llm::types::{Message as LlmMessage, Request, ToolChoice, ToolDefinition}; + use fabro_types::{Message as LlmMessage, ToolChoice, ToolDefinition}; use super::*; use crate::tool_registry::ToolDefinitionWithSource; fn request(messages: Vec, tools: Vec) -> Request { - Request { - model: "model-a".to_string(), - messages, - provider: Some("test".to_string()), - tools: (!tools.is_empty()).then_some(tools), - tool_choice: Some(ToolChoice::Auto), - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, + let mut builder = Request::builder().model("test/model-a"); + for message in messages { + builder = builder.message(message); } + let has_tools = !tools.is_empty(); + for tool in tools { + builder = builder.tool(tool); + } + if has_tools { + builder = builder.tool_choice(ToolChoice::Auto); + } + builder.build().expect("test request should build") } fn tool(name: &str, source: ToolSource) -> ToolDefinitionWithSource { @@ -404,8 +432,8 @@ mod tests { ]; let req = request( vec![ - LlmMessage::system(system_prompt.clone()), - LlmMessage::user("hello"), + LlmMessage::text(Role::System, system_prompt.clone()), + LlmMessage::text(Role::User, "hello"), ], tools.iter().map(|tool| tool.definition.clone()).collect(), ); @@ -525,7 +553,6 @@ mod tests { } fn warnings_in() -> Vec { - use fabro_llm::token_count::{MEDIA_ESTIMATE_WARNING, OPAQUE_CONTEXT_ESTIMATE_WARNING}; vec![ StageContextWindowWarning { code: OPAQUE_CONTEXT_ESTIMATE_WARNING.to_string(), @@ -593,7 +620,6 @@ mod tests { #[test] fn scaled_snapshot_dedupes_repeated_warning_codes() { - use fabro_llm::token_count::OPAQUE_CONTEXT_ESTIMATE_WARNING; let local = snapshot_for_warning_test(); // Simulate the real bug: build_local_snapshot walks N messages and // adds the same `opaque_context_estimate` warning once per turn that diff --git a/lib/components/fabro-agent/src/error.rs b/lib/components/fabro-agent/src/error.rs index b274bd4b7..b3fb48592 100644 --- a/lib/components/fabro-agent/src/error.rs +++ b/lib/components/fabro-agent/src/error.rs @@ -1,4 +1,4 @@ -use fabro_llm::Error as LlmError; +use fabro_llm::LlmError; /// Why a session was interrupted. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -33,6 +33,8 @@ pub enum CompactionError { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum Error { + /// A provider call failed. Carries lithos's stored error projection so + /// the failure stays cloneable and serializable. #[error("LLM error: {0}")] Llm(#[from] LlmError), @@ -52,21 +54,40 @@ pub enum Error { Interrupted(InterruptReason), } +impl From for Error { + fn from(error: fabro_llm::Error) -> Self { + Self::Llm(LlmError::from(error)) + } +} + +impl From for CompactionError { + fn from(error: fabro_llm::Error) -> Self { + Self::Llm(LlmError::from(error)) + } +} + pub type Result = std::result::Result; #[cfg(test)] mod tests { - use fabro_llm::{ProviderErrorDetail, ProviderErrorKind}; + use std::time::Duration; + + use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification}; + use fabro_types::provider_ids; use fabro_util::error; use super::*; + fn network_error(message: &str) -> LlmError { + LlmError::from( + fabro_llm::Error::new(ErrorKind::Network, message) + .with_retry(RetryClassification::Safe), + ) + } + #[test] fn agent_error_from_sdk_error() { - let sdk_err = LlmError::Network { - message: "connection refused".into(), - source: None, - }; + let sdk_err = network_error("connection refused"); let agent_err = Error::from(sdk_err); assert!(matches!(agent_err, Error::Llm(_))); assert!(agent_err.to_string().contains("connection refused")); @@ -74,10 +95,7 @@ mod tests { #[test] fn compaction_error_preserves_llm_source_chain() { - let err = Error::Compaction(CompactionError::Llm(LlmError::Network { - message: "connection refused".into(), - source: None, - })); + let err = Error::Compaction(CompactionError::Llm(network_error("connection refused"))); let chain = error::collect_chain(&err); @@ -139,10 +157,7 @@ mod tests { #[test] fn serde_roundtrip_llm_network() { - let err = Error::Llm(LlmError::Network { - message: "connection refused".into(), - source: None, - }); + let err = Error::Llm(network_error("connection refused")); let json = serde_json::to_string(&err).unwrap(); let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); @@ -150,20 +165,21 @@ mod tests { #[test] fn serde_roundtrip_llm_provider() { - let err = Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), - status_code: Some(429), - error_code: None, - retry_after: Some(2.0), - raw: None, - }), - }); + let err = Error::Llm(LlmError::from( + fabro_llm::Error::new(ErrorKind::RateLimit, "too fast") + .with_provider(provider_ids::openai()) + .with_status(429) + .with_retry(RetryClassification::after(Duration::from_secs(2))), + )); let json = serde_json::to_string(&err).unwrap(); let deserialized: Error = serde_json::from_str(&json).unwrap(); assert_eq!(err.to_string(), deserialized.to_string()); + let Error::Llm(decoded) = deserialized else { + panic!("expected an LLM error"); + }; + assert_eq!(decoded.kind(), ErrorKind::RateLimit); + assert_eq!(decoded.status(), Some(429)); + assert_eq!(decoded.retry_after(), Some(Duration::from_secs(2))); } #[test] @@ -213,10 +229,7 @@ mod tests { #[test] fn clone_all_variants() { let errors: Vec = vec![ - Error::Llm(LlmError::Network { - message: "refused".into(), - source: None, - }), + Error::Llm(network_error("refused")), Error::Compaction(CompactionError::EmptySummary { summarized_turn_count: 3, }), @@ -234,10 +247,7 @@ mod tests { #[test] fn serde_tag_format_llm() { - let err = Error::Llm(LlmError::Network { - message: "refused".into(), - source: None, - }); + let err = Error::Llm(network_error("refused")); let json = serde_json::to_string(&err).unwrap(); let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["type"], "llm"); diff --git a/lib/components/fabro-agent/src/file_tracker.rs b/lib/components/fabro-agent/src/file_tracker.rs index 1420822eb..f9982a513 100644 --- a/lib/components/fabro-agent/src/file_tracker.rs +++ b/lib/components/fabro-agent/src/file_tracker.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::fmt::Write; -use fabro_llm::types::{ToolCall, ToolResult}; +use fabro_types::{ToolCall, ToolResult, tool_call_arguments, tool_result_to_json}; use crate::native_tool::NativeTool; use crate::tool_permissions::canonical_tool_name; @@ -71,24 +71,25 @@ impl FileTracker { } match canonical_tool_name(&tc.name) { name if name == NativeTool::ReadFile.canonical_name() => { - if let Some(path) = file_path(&tc.arguments) { + if let Some(path) = file_path(&tool_call_arguments(tc)) { self.record_read(path); } } name if name == NativeTool::WriteFile.canonical_name() => { - if let Some(path) = file_path(&tc.arguments) { + if let Some(path) = file_path(&tool_call_arguments(tc)) { self.record_write(path); } } name if name == NativeTool::EditFile.canonical_name() => { - if let Some(path) = file_path(&tc.arguments) { + if let Some(path) = file_path(&tool_call_arguments(tc)) { self.record_edit(path); } } name if name == NativeTool::ApplyPatch.canonical_name() => { - let content = match result.content.as_str() { + let output = tool_result_to_json(result); + let content = match output.as_str() { Some(s) => s.to_string(), - None => result.content.to_string(), + None => output.to_string(), }; for line in content.lines() { let line = line.trim(); @@ -107,6 +108,8 @@ impl FileTracker { #[cfg(test)] mod tests { + use fabro_types::tool_result_from_json; + use super::*; #[test] @@ -137,14 +140,15 @@ mod tests { #[test] fn record_from_tool_calls_read_file() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "read_file", serde_json::json!({"file_path": "/tmp/foo.rs"}), )]; - let results = vec![ToolResult::success( + let results = vec![tool_result_from_json( "tc1", serde_json::json!("file contents"), + false, )]; tracker.record_from_tool_calls(&tool_calls, &results); assert_eq!(tracker.render(), "- /tmp/foo.rs (read)\n"); @@ -153,12 +157,12 @@ mod tests { #[test] fn record_from_tool_calls_write_file() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "write_file", serde_json::json!({"file_path": "/tmp/bar.rs", "content": "hello"}), )]; - let results = vec![ToolResult::success("tc1", serde_json::json!("ok"))]; + let results = vec![tool_result_from_json("tc1", serde_json::json!("ok"), false)]; tracker.record_from_tool_calls(&tool_calls, &results); assert_eq!(tracker.render(), "- /tmp/bar.rs (written)\n"); } @@ -166,12 +170,12 @@ mod tests { #[test] fn record_from_tool_calls_edit_file() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "edit_file", serde_json::json!({"file_path": "/tmp/baz.rs"}), )]; - let results = vec![ToolResult::success("tc1", serde_json::json!("ok"))]; + let results = vec![tool_result_from_json("tc1", serde_json::json!("ok"), false)]; tracker.record_from_tool_calls(&tool_calls, &results); assert_eq!(tracker.render(), "- /tmp/baz.rs (edited)\n"); } @@ -180,17 +184,17 @@ mod tests { fn record_from_kimi_tool_calls_uses_path_argument() { let mut tracker = FileTracker::default(); let tool_calls = vec![ - ToolCall::new("tc1", "Read", serde_json::json!({"path": "/tmp/a.rs"})), - ToolCall::new( + ToolCall::function("tc1", "Read", serde_json::json!({"path": "/tmp/a.rs"})), + ToolCall::function( "tc2", "Write", serde_json::json!({"path": "/tmp/b.rs", "content": "x"}), ), - ToolCall::new("tc3", "Edit", serde_json::json!({"path": "/tmp/c.rs"})), + ToolCall::function("tc3", "Edit", serde_json::json!({"path": "/tmp/c.rs"})), ]; let results = ["tc1", "tc2", "tc3"] .into_iter() - .map(|id| ToolResult::success(id, serde_json::json!("ok"))) + .map(|id| tool_result_from_json(id, serde_json::json!("ok"), false)) .collect::>(); tracker.record_from_tool_calls(&tool_calls, &results); @@ -204,12 +208,16 @@ mod tests { #[test] fn record_from_tool_calls_skips_errors() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "read_file", serde_json::json!({"file_path": "/tmp/missing.rs"}), )]; - let results = vec![ToolResult::error("tc1", "File not found")]; + let results = vec![tool_result_from_json( + "tc1", + serde_json::Value::String("File not found".into()), + true, + )]; tracker.record_from_tool_calls(&tool_calls, &results); assert!(tracker.is_empty()); } @@ -217,16 +225,17 @@ mod tests { #[test] fn record_from_tool_calls_apply_patch_added() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "apply_patch", serde_json::json!({"patch": "..."}), )]; - let results = vec![ToolResult::success( + let results = vec![tool_result_from_json( "tc1", serde_json::json!( "Success. Updated the following files:\nA src/new.rs\nM src/old.rs\n" ), + false, )]; tracker.record_from_tool_calls(&tool_calls, &results); assert_eq!( @@ -250,14 +259,15 @@ mod tests { #[test] fn record_from_tool_calls_ignores_unknown_tools() { let mut tracker = FileTracker::default(); - let tool_calls = vec![ToolCall::new( + let tool_calls = vec![ToolCall::function( "tc1", "shell", serde_json::json!({"command": "ls"}), )]; - let results = vec![ToolResult::success( + let results = vec![tool_result_from_json( "tc1", serde_json::json!("file1\nfile2"), + false, )]; tracker.record_from_tool_calls(&tool_calls, &results); assert!(tracker.is_empty()); diff --git a/lib/components/fabro-agent/src/history.rs b/lib/components/fabro-agent/src/history.rs index 23f8ab7a7..c898d0dce 100644 --- a/lib/components/fabro-agent/src/history.rs +++ b/lib/components/fabro-agent/src/history.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; -use fabro_llm::types::{Message as LlmMessage, TokenCounts}; -use fabro_types::SessionMessage; +use fabro_llm::reasoning; +use fabro_types::{Message as LlmMessage, SessionMessage, TokenCounts}; use crate::types::Message; @@ -73,7 +73,7 @@ impl History { fn invalidate_preserved_usage(preserved: &mut [Message]) { for turn in preserved { if let Message::Assistant { usage, .. } = turn { - **usage = TokenCounts::default(); + *usage = TokenCounts::default(); } } } @@ -87,7 +87,7 @@ impl History { fn strip_opaque_provider_items(&mut self) { for turn in &mut self.turns { if let Message::Assistant { provider_parts, .. } = turn { - provider_parts.retain(|p| !p.is_opaque_openai()); + provider_parts.retain(|p| !reasoning::is_opaque_openai(p)); } } } @@ -164,10 +164,22 @@ fn add_tool_result_call_ids<'a>(turns: &'a [Message], call_ids: &mut HashSet<&'a mod tests { use std::time::SystemTime; - use fabro_llm::types::{ContentPart, Role, ThinkingData, TokenCounts, ToolCall, ToolResult}; + use fabro_llm::reasoning::OPENAI_REASONING_KIND; + use fabro_types::{ + ContentPart, ReasoningContent, Role, TokenCounts, ToolCall, text_of, tool_result_from_json, + }; use super::*; + fn thinking(text: &str, signature: Option<&str>) -> ContentPart { + ContentPart::Reasoning(ReasoningContent { + text: text.into(), + signature: signature.map(str::to_string), + signature_origin: signature.map(|_| "anthropic".to_string()), + redacted: false, + }) + } + #[test] fn compact_replaces_old_turns_with_summary() { let mut history = History::default(); @@ -229,30 +241,34 @@ mod tests { let call_id = format!("call_{index}"); history.push(Message::Assistant { content: String::new(), - tool_calls: vec![ToolCall::new( + tool_calls: vec![ToolCall::function( &call_id, "read_file", serde_json::json!({ "file_path": format!("{index}.txt") }), )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: format!("resp_{index}"), timestamp: SystemTime::now(), }); history.push(Message::ToolResults { - results: vec![ToolResult::success(&call_id, serde_json::json!("ok"))], + results: vec![tool_result_from_json( + &call_id, + serde_json::json!("ok"), + false, + )], timestamp: SystemTime::now(), }); } history.push(Message::Assistant { content: String::new(), - tool_calls: vec![ToolCall::new( + tool_calls: vec![ToolCall::function( "call_3", "read_file", serde_json::json!({ "file_path": "3.txt" }), )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_3".into(), timestamp: SystemTime::now(), }); @@ -261,7 +277,7 @@ mod tests { let messages = history.convert_to_messages(); let mut seen_tool_calls = Vec::new(); for message in messages { - for part in message.content { + for part in message.content().iter().cloned() { match part { ContentPart::ToolCall(tool_call) => seen_tool_calls.push(tool_call.id), ContentPart::ToolResult(result) => assert!( @@ -280,14 +296,22 @@ mod tests { let mut history = History::default(); history.push(Message::Assistant { content: String::new(), - tool_calls: vec![ToolCall::new("call_1", "read_file", serde_json::json!({}))], + tool_calls: vec![ToolCall::function( + "call_1", + "read_file", + serde_json::json!({}), + )], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); history.push(Message::ToolResults { - results: vec![ToolResult::success("call_1", serde_json::json!("ok"))], + results: vec![tool_result_from_json( + "call_1", + serde_json::json!("ok"), + false, + )], timestamp: SystemTime::now(), }); @@ -308,8 +332,8 @@ mod tests { } history.compact(2, "[Context Summary]\nThis is a summary".into()); let messages = history.convert_to_messages(); - assert_eq!(messages[0].role, Role::System); - assert!(messages[0].text().contains("[Context Summary]")); + assert_eq!(messages[0].role(), Role::System); + assert!(text_of(messages[0].content()).contains("[Context Summary]")); } #[test] @@ -328,8 +352,8 @@ mod tests { }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::User); - assert_eq!(messages[0].text(), "Hello"); + assert_eq!(messages[0].role(), Role::User); + assert_eq!(text_of(messages[0].content()), "Hello"); } #[test] @@ -339,32 +363,32 @@ mod tests { content: "Hi there".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::Assistant); - assert_eq!(messages[0].text(), "Hi there"); + assert_eq!(messages[0].role(), Role::Assistant); + assert_eq!(text_of(messages[0].content()), "Hi there"); } #[test] fn assistant_turn_with_tool_calls() { let mut history = History::default(); - let tc = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "foo.rs"})); + let tc = ToolCall::function("call_1", "read_file", serde_json::json!({"path": "foo.rs"})); history.push(Message::Assistant { content: "Let me read that".into(), tool_calls: vec![tc], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_2".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); - assert_eq!(messages[0].role, Role::Assistant); + assert_eq!(messages[0].role(), Role::Assistant); let tool_call_parts: Vec<_> = messages[0] - .content + .content() .iter() .filter(|p| matches!(p, ContentPart::ToolCall(_))) .collect(); @@ -374,24 +398,20 @@ mod tests { #[test] fn assistant_turn_with_reasoning_in_provider_parts() { let mut history = History::default(); - let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think about this...".into(), - signature: None, - redacted: false, - }); + let thinking = thinking("Let me think about this...", None); history.push(Message::Assistant { content: "The answer is 42".into(), tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_3".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] - .content + .content() .iter() - .filter(|p| matches!(p, ContentPart::Thinking(_))) + .filter(|p| matches!(p, ContentPart::Reasoning(_))) .collect(); assert_eq!(thinking_parts.len(), 1); } @@ -399,25 +419,21 @@ mod tests { #[test] fn thinking_with_signature_preserved_via_provider_parts() { let mut history = History::default(); - let thinking = ContentPart::Thinking(ThinkingData { - text: "Let me think...".into(), - signature: Some("sig_abc123".into()), - redacted: false, - }); + let thinking = thinking("Let me think...", Some("sig_abc123")); history.push(Message::Assistant { content: "The answer".into(), tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_4".into(), timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); let thinking_parts: Vec<_> = messages[0] - .content + .content() .iter() .filter_map(|p| match p { - ContentPart::Thinking(td) => Some(td), + ContentPart::Reasoning(td) => Some(td), _ => None, }) .collect(); @@ -430,16 +446,16 @@ mod tests { #[test] fn assistant_turn_preserves_provider_parts() { let mut history = History::default(); - let reasoning_item = ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), - }; - let tc = ToolCall::new("call_1", "search", serde_json::json!({})); + let reasoning_item = ContentPart::opaque( + OPENAI_REASONING_KIND, + serde_json::json!({"type": "reasoning", "id": "rs_abc"}), + ); + let tc = ToolCall::function("call_1", "search", serde_json::json!({})); history.push(Message::Assistant { content: String::new(), tool_calls: vec![tc], provider_parts: vec![reasoning_item], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -447,23 +463,27 @@ mod tests { assert_eq!(messages.len(), 1); // Provider parts come first, then tool calls assert!( - matches!(&messages[0].content[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING) + matches!(&messages[0].content()[0], ContentPart::Opaque { kind, .. } if kind == OPENAI_REASONING_KIND) ); - assert!(matches!(&messages[0].content[1], ContentPart::ToolCall(_))); + assert!(matches!( + &messages[0].content()[1], + ContentPart::ToolCall(_) + )); } #[test] fn tool_results_turn_maps_to_tool_message() { let mut history = History::default(); - let result = ToolResult::success("call_1", serde_json::json!("file contents here")); + let result = + tool_result_from_json("call_1", serde_json::json!("file contents here"), false); history.push(Message::ToolResults { results: vec![result], timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::Tool); - assert_eq!(messages[0].tool_call_id, Some("call_1".into())); + assert_eq!(messages[0].role(), Role::Tool); + assert_eq!(messages[0].tool_call_id(), Some("call_1")); } #[test] @@ -475,8 +495,8 @@ mod tests { }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::System); - assert_eq!(messages[0].text(), "You are a coding assistant"); + assert_eq!(messages[0].role(), Role::System); + assert_eq!(text_of(messages[0].content()), "You are a coding assistant"); } #[test] @@ -488,15 +508,16 @@ mod tests { }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::User); - assert_eq!(messages[0].text(), "Focus on the main task"); + assert_eq!(messages[0].role(), Role::User); + assert_eq!(text_of(messages[0].content()), "Focus on the main task"); } #[test] fn session_message_roundtrip_preserves_runtime_history() { let mut history = History::default(); - let tool_call = ToolCall::new("call_1", "read_file", serde_json::json!({"path": "a.rs"})); - let tool_result = ToolResult::success("call_1", serde_json::json!("ok")); + let tool_call = + ToolCall::function("call_1", "read_file", serde_json::json!({"path": "a.rs"})); + let tool_result = tool_result_from_json("call_1", serde_json::json!("ok"), false); history.push(Message::User { content: "Read a file".into(), timestamp: SystemTime::now(), @@ -505,11 +526,11 @@ mod tests { content: "Reading".into(), tool_calls: vec![tool_call], provider_parts: vec![], - usage: Box::new(TokenCounts { - input_tokens: 10, - output_tokens: 3, + usage: TokenCounts { + input: 10, + output: 3, ..TokenCounts::default() - }), + }, response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -528,7 +549,7 @@ mod tests { ); assert!( matches!(&restored.turns()[1], Message::Assistant { content, tool_calls, usage, .. } - if content == "Reading" && tool_calls.len() == 1 && usage.input_tokens == 10) + if content == "Reading" && tool_calls.len() == 1 && usage.input == 10) ); assert!( matches!(&restored.turns()[2], Message::ToolResults { results, .. } if results.len() == 1) @@ -548,7 +569,7 @@ mod tests { content: "Second".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -564,37 +585,34 @@ mod tests { }); history.push(Message::Assistant { content: "Hi".into(), - tool_calls: vec![ToolCall::new( + tool_calls: vec![ToolCall::function( "c1", "shell", serde_json::json!({"cmd": "ls"}), )], - provider_parts: vec![ContentPart::Thinking(ThinkingData { - text: "thinking...".into(), - signature: None, - redacted: false, - })], - usage: Box::new(TokenCounts { - input_tokens: 10, - output_tokens: 5, + provider_parts: vec![thinking("thinking...", None)], + usage: TokenCounts { + input: 10, + output: 5, ..Default::default() - }), + }, response_id: "resp_1".into(), timestamp: SystemTime::now(), }); history.push(Message::ToolResults { - results: vec![ToolResult::success( + results: vec![tool_result_from_json( "c1", serde_json::json!("file1.rs\nfile2.rs"), + false, )], timestamp: SystemTime::now(), }); let messages = history.convert_to_messages(); assert_eq!(messages.len(), 3); - assert_eq!(messages[0].role, Role::User); - assert_eq!(messages[1].role, Role::Assistant); - assert_eq!(messages[2].role, Role::Tool); + assert_eq!(messages[0].role(), Role::User); + assert_eq!(messages[1].role(), Role::Assistant); + assert_eq!(messages[2].role(), Role::Tool); } #[test] @@ -608,16 +626,16 @@ mod tests { content: "recent msg".into(), timestamp: SystemTime::now(), }); - let reasoning = ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.into(), - data: serde_json::json!({"type": "reasoning", "id": "rs_abc"}), - }; - let tc = ToolCall::new("call_1", "search", serde_json::json!({})); + let reasoning = ContentPart::opaque( + OPENAI_REASONING_KIND, + serde_json::json!({"type": "reasoning", "id": "rs_abc"}), + ); + let tc = ToolCall::function("call_1", "search", serde_json::json!({})); history.push(Message::Assistant { content: "response".into(), tool_calls: vec![tc], provider_parts: vec![reasoning], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -656,16 +674,12 @@ mod tests { content: "recent msg".into(), timestamp: SystemTime::now(), }); - let thinking = ContentPart::Thinking(ThinkingData { - text: "deep thought".into(), - signature: Some("sig_xyz".into()), - redacted: false, - }); + let thinking = thinking("deep thought", Some("sig_xyz")); history.push(Message::Assistant { content: "answer".into(), tool_calls: vec![], provider_parts: vec![thinking], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -681,7 +695,7 @@ mod tests { 1, "thinking block should be preserved" ); - assert!(matches!(&provider_parts[0], ContentPart::Thinking(_))); + assert!(matches!(&provider_parts[0], ContentPart::Reasoning(_))); } else { panic!("expected Assistant turn"); } @@ -694,23 +708,20 @@ mod tests { content: "old msg".into(), timestamp: SystemTime::now(), }); - let tool_call = ToolCall::new("call_1", "search", serde_json::json!({"query": "fabro"})); - let thinking = ContentPart::Thinking(ThinkingData { - text: "deep thought".into(), - signature: Some("sig_xyz".into()), - redacted: false, - }); + let tool_call = + ToolCall::function("call_1", "search", serde_json::json!({"query": "fabro"})); + let thinking = thinking("deep thought", Some("sig_xyz")); history.push(Message::Assistant { content: "answer".into(), tool_calls: vec![tool_call.clone()], provider_parts: vec![thinking.clone()], - usage: Box::new(TokenCounts { - input_tokens: 10, - output_tokens: 20, - reasoning_tokens: 30, - cache_read_tokens: 40, - cache_write_tokens: 50, - }), + usage: TokenCounts { + input: 10, + output: 20, + reasoning: 30, + cache_read: 40, + cache_write: 50, + }, response_id: "resp_1".into(), timestamp: SystemTime::now(), }); @@ -735,7 +746,7 @@ mod tests { assert_eq!(tool_calls, &[tool_call]); assert_eq!(provider_parts, &[thinking]); assert_eq!(response_id, "resp_1"); - assert_eq!(**usage, TokenCounts::default()); + assert_eq!(*usage, TokenCounts::default()); } else { panic!("expected Assistant turn"); } @@ -753,11 +764,11 @@ mod tests { history.push(Message::Assistant { content: format!("response {i}"), tool_calls: vec![], - provider_parts: vec![ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.into(), - data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), - }], - usage: Box::new(TokenCounts::default()), + provider_parts: vec![ContentPart::opaque( + OPENAI_REASONING_KIND, + serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}), + )], + usage: TokenCounts::default(), response_id: format!("resp_{i}"), timestamp: SystemTime::now(), }); @@ -786,7 +797,7 @@ mod tests { content: "reply".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "r1".into(), timestamp: SystemTime::now(), }, @@ -831,7 +842,7 @@ mod tests { content: "assistant msg".into(), tool_calls: vec![], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "r1".into(), timestamp: SystemTime::now(), }); diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index f1c9c5f17..cf714c9b1 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -76,7 +76,7 @@ pub use todo_tools::{ make_todo_list_tool, make_update_plan_tool, }; pub use tool_permissions::canonical_tool_name; -pub use tool_registry::{AgentEventEmitter, ToolRegistry}; +pub use tool_registry::{AgentEventEmitter, ToolDefinitionExt, ToolRegistry}; pub use tools::{ WebFetchSummarizer, make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, make_shell_tool, make_shell_tool_with_options, make_write_file_tool, register_core_tools, diff --git a/lib/components/fabro-agent/src/loop_detection.rs b/lib/components/fabro-agent/src/loop_detection.rs index c81996c78..0b313e731 100644 --- a/lib/components/fabro-agent/src/loop_detection.rs +++ b/lib/components/fabro-agent/src/loop_detection.rs @@ -1,6 +1,8 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +use fabro_types::tool_call_arguments; + use crate::history::History; use crate::types::Message; @@ -18,7 +20,7 @@ fn extract_signatures_from_assistant(turn: &Message) -> Vec { }; tool_calls .iter() - .map(|tc| tool_call_signature(&tc.name, &tc.arguments)) + .map(|tc| tool_call_signature(&tc.name, &tool_call_arguments(tc))) .collect() } @@ -97,16 +99,16 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool { mod tests { use std::time::SystemTime; - use fabro_llm::types::{TokenCounts, ToolCall}; + use fabro_types::{TokenCounts, ToolCall}; use super::*; fn assistant_with_tool(name: &str, args: serde_json::Value) -> Message { Message::Assistant { content: String::new(), - tool_calls: vec![ToolCall::new("call_1", name, args)], + tool_calls: vec![ToolCall::function("call_1", name, args)], provider_parts: vec![], - usage: Box::new(TokenCounts::default()), + usage: TokenCounts::default(), response_id: "resp".into(), timestamp: SystemTime::now(), } diff --git a/lib/components/fabro-agent/src/mcp_integration.rs b/lib/components/fabro-agent/src/mcp_integration.rs index fe766562e..afd62ec2b 100644 --- a/lib/components/fabro-agent/src/mcp_integration.rs +++ b/lib/components/fabro-agent/src/mcp_integration.rs @@ -1,7 +1,7 @@ use std::sync::Arc; -use fabro_llm::types::ToolDefinition; use fabro_mcp::connection_manager::{McpConnectionManager, call_result_to_string}; +use fabro_types::ToolDefinition; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -18,11 +18,11 @@ pub fn make_mcp_tools(manager: &Arc) -> Vec Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } #[test] fn anthropic_profile_identity() { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); - assert_eq!(profile.provider_id(), ProviderId::anthropic()); + assert_eq!(profile.provider_id(), provider_ids::anthropic()); assert_eq!(profile.model(), "claude-sonnet-4-20250514"); } @@ -122,7 +125,7 @@ mod tests { let profile = AnthropicProfile::new("claude-opus-4-6").with_catalog(test_catalog()); assert_eq!(profile.context_window_size(), 1_000_000); - let profile = AnthropicProfile::new("claude-sonnet-4-6").with_catalog(test_catalog()); + let profile = AnthropicProfile::new("claude-sonnet-4.5").with_catalog(test_catalog()); assert_eq!(profile.context_window_size(), 200_000); } diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index 97cdeff1d..66ad7a841 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -2,7 +2,8 @@ use std::sync::Arc; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -64,7 +65,7 @@ impl Claude5Profile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Claude5, - provider_id: ProviderId::anthropic(), + provider_id: provider_ids::anthropic(), model: model.into(), catalog: None, registry, @@ -165,7 +166,7 @@ mod tests { fn profile_identity() { let profile = Claude5Profile::new("claude-fable-5"); assert_eq!(profile.profile_kind(), AgentProfileKind::Claude5); - assert_eq!(profile.provider_id(), ProviderId::anthropic()); + assert_eq!(profile.provider_id(), provider_ids::anthropic()); assert_eq!(profile.model(), "claude-fable-5"); } diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index 6f53622b0..069dad511 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::time::Duration; -use fabro_llm::types::ToolDefinition; +use fabro_types::{ToolDefinition, ToolDefinitionKind}; use fabro_util::error as util_error; use serde_json::Value; use tokio::time; @@ -26,19 +26,16 @@ fn definition( description: impl Into, parameters: Value, ) -> ToolDefinition { - ToolDefinition { - name: tool.canonical_name().to_string(), - description: description.into(), - parameters, - } + ToolDefinition::function(tool.canonical_name(), description, parameters) } /// Reject unknown top-level fields while retaining a shared executor. #[must_use] pub(crate) fn strict_object_tool(mut tool: RegisteredTool) -> RegisteredTool { - let object = tool - .definition - .parameters + let ToolDefinitionKind::Function { input_schema } = &mut tool.definition.kind else { + panic!("native JSON-schema tools should use a function definition"); + }; + let object = input_schema .as_object_mut() .expect("native JSON-schema tools should use an object schema"); object.insert("additionalProperties".to_string(), Value::Bool(false)); @@ -469,9 +466,10 @@ mod tests { use crate::todo_tools::{ make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool, }; + use crate::tool_registry::ToolDefinitionExt; fn property_names(tool: &RegisteredTool) -> BTreeSet<&str> { - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .as_object() .unwrap() .keys() @@ -480,7 +478,7 @@ mod tests { } fn required_names(tool: &RegisteredTool) -> BTreeSet<&str> { - tool.definition.parameters["required"] + tool.definition.parameters()["required"] .as_array() .map(|required| { required @@ -492,9 +490,9 @@ mod tests { } fn assert_schema(tool: &RegisteredTool, properties: &[&str], required: &[&str]) { - assert_eq!(tool.definition.parameters["type"], "object"); + assert_eq!(tool.definition.parameters()["type"], "object"); assert_eq!( - tool.definition.parameters["additionalProperties"], + tool.definition.parameters()["additionalProperties"], Value::Bool(false) ); assert_eq!(property_names(tool), properties.iter().copied().collect()); @@ -515,7 +513,7 @@ mod tests { #[test] fn core_adapter_schemas_match_the_claude5_contract() { - let options = NativeToolOptions::for_profile(fabro_model::AgentProfileKind::Claude5); + let options = NativeToolOptions::for_profile(fabro_types::AgentProfileKind::Claude5); assert_schema(&make_read_tool(), &["file_path", "limit", "offset"], &[ "file_path", ]); @@ -531,7 +529,7 @@ mod tests { let bash = make_bash_tool(&options); assert_schema(&bash, &["command", "description", "timeout"], &["command"]); assert_eq!( - bash.definition.parameters["properties"]["timeout"]["maximum"], + bash.definition.parameters()["properties"]["timeout"]["maximum"], 600_000 ); assert_schema(&make_web_fetch_tool(None), &["prompt", "url"], &[ diff --git a/lib/components/fabro-agent/src/profiles/gemini.rs b/lib/components/fabro-agent/src/profiles/gemini.rs index f6b3d494d..d4e812b78 100644 --- a/lib/components/fabro-agent/src/profiles/gemini.rs +++ b/lib/components/fabro-agent/src/profiles/gemini.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -41,7 +42,7 @@ impl GeminiProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Gemini, - provider_id: ProviderId::gemini(), + provider_id: provider_ids::gemini(), model: model.into(), catalog: None, registry, @@ -93,19 +94,21 @@ impl AgentProfile for GeminiProfile { mod tests { use std::sync::Arc; + use fabro_llm::test_support::test_catalog as fabro_test_catalog; + use super::*; use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } #[test] fn gemini_profile_identity() { let profile = GeminiProfile::new("gemini-2.0-flash"); assert_eq!(profile.profile_kind(), AgentProfileKind::Gemini); - assert_eq!(profile.provider_id(), ProviderId::gemini()); + assert_eq!(profile.provider_id(), provider_ids::gemini()); assert_eq!(profile.model(), "gemini-2.0-flash"); } diff --git a/lib/components/fabro-agent/src/profiles/gpt56.rs b/lib/components/fabro-agent/src/profiles/gpt56.rs index d0fb93ed1..75d299c56 100644 --- a/lib/components/fabro-agent/src/profiles/gpt56.rs +++ b/lib/components/fabro-agent/src/profiles/gpt56.rs @@ -16,8 +16,8 @@ use std::sync::Arc; -use fabro_llm::types::ToolDefinition; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId, ToolDefinition, provider_ids}; use serde_json::Value; use super::EnvContext; @@ -69,7 +69,7 @@ impl Gpt56Profile { Self { base: BaseProfile { profile_kind: AgentProfileKind::Gpt56, - provider_id: ProviderId::openai(), + provider_id: provider_ids::openai(), model: model.into(), catalog: None, registry, @@ -137,12 +137,12 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool { ); RegisteredTool { - definition: ToolDefinition { + definition: ToolDefinition::function( // Supply the canonical identity; registry insertion rewrites the // stored and wire name to `shell_command`. - name: NativeTool::Shell.canonical_name().to_string(), + NativeTool::Shell.canonical_name(), description, - parameters: serde_json::json!({ + serde_json::json!({ "type": "object", "properties": { "command": { @@ -162,7 +162,7 @@ fn make_shell_command_tool(options: &NativeToolOptions) -> RegisteredTool { }, "required": ["command"] }), - }, + ), executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = tools::required_str(&args, "command")?; @@ -222,21 +222,25 @@ impl AgentProfile for Gpt56Profile { mod tests { use std::sync::Arc; - use fabro_model::catalog::LlmCatalogSettings; + use fabro_llm::catalog; + use fabro_llm::test_support::{test_catalog as fabro_test_catalog, test_catalog_with_overlay}; use super::*; use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; + use crate::tool_registry::ToolDefinitionExt; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } /// OpenRouter ships disabled in the built-in catalog. fn catalog_with_openrouter() -> Arc { - let overrides: LlmCatalogSettings = - toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap(); - Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()) + Arc::new(test_catalog_with_overlay( + "[providers.openrouter.metadata.fabro] +enabled = true +", + )) } fn prompt(profile: &Gpt56Profile) -> String { @@ -248,7 +252,7 @@ mod tests { fn gpt56_profile_identity() { let profile = Gpt56Profile::new("gpt-5.6-sol"); assert_eq!(profile.profile_kind(), AgentProfileKind::Gpt56); - assert_eq!(profile.provider_id(), ProviderId::openai()); + assert_eq!(profile.provider_id(), provider_ids::openai()); assert_eq!(profile.model(), "gpt-5.6-sol"); } @@ -285,14 +289,14 @@ mod tests { fn shell_command_accepts_a_workdir() { let profile = Gpt56Profile::new("gpt-5.6-sol"); let shell = profile.tool_registry().get("shell_command").unwrap(); - assert_eq!(shell.definition.parameters["type"], "object"); - assert!(shell.definition.parameters["properties"]["workdir"].is_object()); + assert_eq!(shell.definition.parameters()["type"], "object"); + assert!(shell.definition.parameters()["properties"]["workdir"].is_object()); assert_eq!( - shell.definition.parameters["required"], + shell.definition.parameters()["required"], serde_json::json!(["command"]) ); assert_eq!( - shell.definition.parameters["properties"]["command"]["description"], + shell.definition.parameters()["properties"]["command"]["description"], "Bash source to evaluate, run by a non-login Bash shell." ); } @@ -315,7 +319,7 @@ mod tests { "tool '{}' must not be a custom definition on an openai_compatible route", definition.name ); - assert_eq!(definition.parameters["type"], "object"); + assert_eq!(definition.parameters()["type"], "object"); } } @@ -324,7 +328,7 @@ mod tests { #[test] fn shell_description_names_the_editor_actually_registered() { let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog()); + Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); let shell = direct.tool_registry().get("shell_command").unwrap(); assert!(shell.definition.description.contains("`apply_patch`")); assert!(!shell.definition.description.contains("`edit_file`")); @@ -346,7 +350,7 @@ mod tests { assert!(!rendered.contains("*** Begin Patch")); let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog()); + Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); let rendered = prompt(&direct); assert!(rendered.contains("Use `apply_patch` for local file edits")); assert!(rendered.contains("*** Begin Patch")); @@ -424,7 +428,7 @@ mod tests { #[test] fn provider_prompt_uses_catalog_display_name() { let direct = - Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog()); + Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); assert!(prompt(&direct).contains("powered by OpenAI")); let gateway = Gpt56Profile::new("gpt-5.6-sol") @@ -443,14 +447,14 @@ mod tests { let provider_id = ProviderId::new(provider); for model in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] { assert_eq!( - catalog.effective_agent_profile(&provider_id, Some(model)), + catalog::agent_profile(&catalog, provider_id.as_str(), Some(model)), Some(AgentProfileKind::Gpt56), "{provider}/{model} should use the gpt56 profile" ); } for model in ["gpt-5.5", "gpt-5.4"] { assert_eq!( - catalog.effective_agent_profile(&provider_id, Some(model)), + catalog::agent_profile(&catalog, provider_id.as_str(), Some(model)), Some(AgentProfileKind::OpenAi), "{provider}/{model} should keep the openai profile" ); @@ -461,7 +465,7 @@ mod tests { #[test] fn catalog_reports_the_5_6_context_window() { let profile = - Gpt56Profile::new("gpt-5.6-sol").with_route(ProviderId::openai(), test_catalog()); - assert_eq!(profile.context_window_size(), 272_000); + Gpt56Profile::new("gpt-5.6-sol").with_route(provider_ids::openai(), test_catalog()); + assert_eq!(profile.context_window_size(), 1_050_000); } } diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index e04a7d19c..e24dc5c11 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -152,7 +153,8 @@ impl AgentProfile for KimiProfile { #[cfg(test)] mod tests { - use fabro_model::catalog::LlmCatalogSettings; + use fabro_llm::catalog; + use fabro_llm::test_support::{test_catalog as fabro_test_catalog, test_catalog_with_overlay}; use fabro_types::AgentToolCategory; use super::*; @@ -160,17 +162,20 @@ mod tests { use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; use crate::tool_permissions::{known_tool_category, tool_category}; + use crate::tool_registry::ToolDefinitionExt; fn catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } /// OpenRouter ships disabled, so an operator opts in before its models are /// selectable. Enable it the way they would, to observe gateway routing. fn catalog_with_openrouter() -> Arc { - let overrides: LlmCatalogSettings = - toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap(); - Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()) + Arc::new(test_catalog_with_overlay( + "[providers.openrouter.metadata.fabro] +enabled = true +", + )) } /// Kimi models must resolve to the Kimi profile whether they are reached @@ -184,7 +189,7 @@ mod tests { (catalog_with_openrouter(), "openrouter", "kimi-k2.6"), ] { assert_eq!( - catalog.effective_agent_profile(&ProviderId::new(provider), Some(model)), + catalog::agent_profile(&catalog, provider, Some(model)), Some(AgentProfileKind::Kimi), "{provider}/{model} should use the Kimi profile" ); @@ -199,8 +204,7 @@ mod tests { // Deliberately not a GPT-5.6 model: those carry their own per-model // profile override, so they would not show that the provider default // is what applies here. - let profile = - catalog.effective_agent_profile(&ProviderId::new("openrouter"), Some("gpt-5.4")); + let profile = catalog::agent_profile(&catalog, "openrouter", Some("gpt-5.4")); assert_eq!(profile, Some(AgentProfileKind::OpenAi)); } @@ -273,7 +277,7 @@ mod tests { .get("Skill") .unwrap() .definition - .parameters; + .parameters(); assert!(skill_parameters["properties"].get("skill").is_some()); assert!(skill_parameters["properties"].get("args").is_some()); assert!(skill_parameters["properties"].get("skill_name").is_none()); @@ -352,7 +356,7 @@ mod tests { .get("Edit") .unwrap() .definition - .parameters; + .parameters(); assert!(parameters["properties"].get("path").is_some()); assert!(parameters["properties"].get("file_path").is_none()); diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 07b6ec05e..885943ef9 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -22,7 +22,7 @@ use std::fmt::Write as _; use std::str::FromStr; use std::sync::Arc; -use fabro_llm::types::ToolDefinition; +use fabro_types::ToolDefinition; use serde_json::Value; use strum::EnumString; @@ -39,13 +39,9 @@ const MAX_GREP_RESULTS: usize = 2000; const MAX_GREP_MATCHES_SCANNED: usize = 20_000; fn definition(tool: NativeTool, description: &str, parameters: Value) -> ToolDefinition { - ToolDefinition { - // Supply the canonical identity; registry insertion rewrites the - // stored and wire name for the active vocabulary. - name: tool.canonical_name().to_string(), - description: description.to_string(), - parameters, - } + // Supply the canonical identity; registry insertion rewrites the + // stored and wire name for the active vocabulary. + ToolDefinition::function(tool.canonical_name(), description, parameters) } /// `Bash`, taking `timeout` in seconds and an optional `cwd`. @@ -377,7 +373,7 @@ mod tests { use super::*; use crate::sandbox::{ExecResult, Sandbox}; use crate::test_support::{MockSandbox, MutableMockSandbox}; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; fn ctx(env: Arc) -> ToolContext { ToolContext { @@ -523,12 +519,12 @@ mod tests { assert_eq!(env.read_file_text("/f.txt").await.unwrap(), "after"); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("path") .is_some() ); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("file_path") .is_none() ); @@ -645,7 +641,8 @@ mod tests { #[test] fn grep_schema_uses_kimi_code_modes_and_flags() { - let parameters = make_kimi_grep_tool().definition.parameters; + let tool = make_kimi_grep_tool(); + let parameters = tool.definition.parameters(); assert_eq!( parameters["properties"]["output_mode"]["enum"], json!(["content", "files_with_matches", "count_matches"]) @@ -659,7 +656,7 @@ mod tests { #[test] fn bash_schema_states_seconds_and_quotes_real_limits() { let tool = make_kimi_bash_tool(60_000, 600_000); - let params = &tool.definition.parameters; + let params = &tool.definition.parameters(); let timeout = params["properties"]["timeout"]["description"] .as_str() .unwrap(); diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 2ecc237bb..9b7897394 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; -use fabro_model::{AgentProfileKind, Catalog, CodecKind, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +#[cfg(test)] +use fabro_types::provider_ids; +use fabro_types::{AgentProfileKind, ProviderId}; pub mod anthropic; pub mod claude5; @@ -169,9 +172,12 @@ pub(crate) enum FileEditToolKind { EditFile, } +/// The lithos codec that carries freeform (custom) tool definitions. +pub(crate) const OPENAI_RESPONSES_CODEC: &str = "openai-responses"; + impl FileEditToolKind { - pub(crate) fn for_codec(codec: CodecKind) -> Self { - if codec == CodecKind::OpenAiResponses { + pub(crate) fn for_codec(codec: &str) -> Self { + if codec == OPENAI_RESPONSES_CODEC { Self::ApplyPatch } else { Self::EditFile @@ -210,11 +216,11 @@ impl FileEditToolKind { /// trait defaults: there is no sensible default for a profile that has no base. macro_rules! impl_base_profile_accessors { () => { - fn profile_kind(&self) -> ::fabro_model::AgentProfileKind { + fn profile_kind(&self) -> ::fabro_types::AgentProfileKind { self.base.profile_kind } - fn provider_id(&self) -> ::fabro_model::ProviderId { + fn provider_id(&self) -> ::fabro_types::ProviderId { self.base.provider_id.clone() } @@ -222,8 +228,8 @@ macro_rules! impl_base_profile_accessors { &self.base.model } - fn catalog(&self) -> Option<&::fabro_model::Catalog> { - self.base.catalog.as_deref() + fn catalog(&self) -> Option<&::std::sync::Arc<::fabro_llm::lithos_catalog::Catalog>> { + self.base.catalog.as_ref() } fn tool_registry(&self) -> &$crate::tool_registry::ToolRegistry { @@ -259,10 +265,10 @@ impl BaseProfile { fn provider_display_name(&self) -> String { self.catalog .as_ref() - .and_then(|catalog| catalog.provider(&self.provider_id)) + .and_then(|catalog| catalog.provider(self.provider_id.as_str()).ok()) .map_or_else( - || self.provider_id.display_name(), - |provider| provider.display_name.clone(), + || self.provider_id.to_string(), + |provider| provider.display_name().to_string(), ) } @@ -274,11 +280,9 @@ impl BaseProfile { /// /// Returns the newly selected editor when the registry changed. fn configure_file_edit_tool(&mut self) -> Option { - let codec = self - .catalog - .as_ref()? - .effective_codec(&self.provider_id, Some(&self.model))?; - let desired = FileEditToolKind::for_codec(codec); + let catalog = self.catalog.as_ref()?; + let provider = catalog.provider(self.provider_id.as_str()).ok()?; + let desired = FileEditToolKind::for_codec(provider.codec().as_str()); if self.file_edit_tool() == Some(desired) { return None; } @@ -451,8 +455,8 @@ pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> Stri #[cfg(test)] mod tests { - use fabro_llm::types::ToolDefinition; - use fabro_model::catalog::LlmCatalogSettings; + use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay}; + use fabro_types::ToolDefinition; use tokio_util::sync::CancellationToken; use super::*; @@ -461,6 +465,10 @@ mod tests { use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; + /// OpenRouter ships disabled, so an operator opts in before its models are + /// selectable. + const OPENROUTER_ENABLED: &str = "[providers.openrouter.metadata.fabro]\nenabled = true\n"; + fn native_tool_options( profile_kind: AgentProfileKind, has_web_search: bool, @@ -536,21 +544,17 @@ mod tests { fn gpt56_edit_file_profile(has_web_search: bool) -> Gpt56Profile { let options = native_tool_options(AgentProfileKind::Gpt56, has_web_search); let deps = ProfileDeps::standalone(options); - let overrides: LlmCatalogSettings = - toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap(); Gpt56Profile::with_native_tools("gpt-5.6-sol", &deps).with_route( ProviderId::new("openrouter"), - Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()), + Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED)), ) } fn openai_edit_file_profile(has_web_search: bool) -> OpenAiProfile { let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search); let deps = ProfileDeps::standalone(options); - OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route( - ProviderId::new("moonshot"), - Arc::new(Catalog::from_builtin().unwrap()), - ) + OpenAiProfile::with_native_tools("kimi-k2.5", &deps) + .with_route(ProviderId::new("moonshot"), Arc::new(test_catalog())) } /// Profiles using fabro's native tool vocabulary get the same `shell` @@ -575,7 +579,7 @@ mod tests { .collect(); for definition in &definitions { - assert_eq!(definition.parameters, definitions[0].parameters); + assert_eq!(definition.kind, definitions[0].kind); assert_eq!(definition.description, definitions[0].description); assert!( definition.description.contains("Bash"), @@ -694,30 +698,34 @@ mod tests { #[test] fn profile_builder_keeps_tool_availability_and_prompt_guidance_in_sync() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); + let catalog = Arc::new(test_catalog()); let env = MockSandbox::linux(); let cases = [ ( AgentProfileKind::OpenAi, - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", ), ( AgentProfileKind::Anthropic, - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", ), ( AgentProfileKind::Gemini, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", ), ( AgentProfileKind::Claude5, - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-sonnet-5", ), - (AgentProfileKind::Gpt56, ProviderId::openai(), "gpt-5.6-sol"), + ( + AgentProfileKind::Gpt56, + provider_ids::openai(), + "gpt-5.6-sol", + ), ]; for (profile_kind, provider_id, model) in cases { @@ -774,9 +782,9 @@ mod tests { ) { let builder = AgentProfileBuilder::new( profile_kind, - ProviderId::anthropic(), + provider_ids::anthropic(), model, - Arc::new(Catalog::from_builtin().unwrap()), + Arc::new(test_catalog()), ); let root = builder.build(); let child = builder.build(); @@ -844,9 +852,7 @@ mod tests { #[test] fn profile_builder_selects_a_codec_compatible_gpt56_editor() { - let overrides: LlmCatalogSettings = - toml::from_str("[providers.openrouter]\nenabled = true\n").unwrap(); - let catalog = Arc::new(Catalog::from_builtin_with_overrides(&overrides).unwrap()); + let catalog = Arc::new(test_catalog_with_overlay(OPENROUTER_ENABLED)); let profile = AgentProfileBuilder::new( AgentProfileKind::Gpt56, ProviderId::new("openrouter"), diff --git a/lib/components/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs index d3010c859..29018cfb5 100644 --- a/lib/components/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; +use fabro_llm::lithos_catalog::Catalog; +use fabro_types::{AgentProfileKind, ProviderId, provider_ids}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -42,7 +43,7 @@ impl OpenAiProfile { Self { base: BaseProfile { profile_kind: AgentProfileKind::OpenAi, - provider_id: ProviderId::openai(), + provider_id: provider_ids::openai(), model: model.into(), catalog: None, registry, @@ -101,19 +102,22 @@ impl AgentProfile for OpenAiProfile { mod tests { use std::sync::Arc; + use fabro_llm::test_support::test_catalog as fabro_test_catalog; + use super::*; use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::test_support::MockSandbox; + use crate::tool_registry::ToolDefinitionExt; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin().unwrap()) + Arc::new(fabro_test_catalog()) } #[test] fn openai_profile_identity() { let profile = OpenAiProfile::new("o3-mini"); assert_eq!(profile.profile_kind(), AgentProfileKind::OpenAi); - assert_eq!(profile.provider_id(), ProviderId::openai()); + assert_eq!(profile.provider_id(), provider_ids::openai()); assert_eq!(profile.model(), "o3-mini"); } @@ -249,10 +253,11 @@ mod tests { let edit_file = profile.tool_registry().get("edit_file").unwrap(); assert!(!edit_file.definition.is_custom()); - assert_eq!(edit_file.definition.parameters["type"], "object"); + assert_eq!(edit_file.definition.parameters()["type"], "object"); for definition in profile.tool_registry().definitions() { assert_eq!( - definition.parameters["type"], "object", + definition.parameters()["type"], + "object", "tool '{}' must use an object parameter schema", definition.name ); diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 63eb58522..94c8bea44 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -6,9 +6,7 @@ use std::ops::RangeInclusive; use std::sync::Arc; use async_trait::async_trait; -use fabro_llm::types::ToolDefinition; -use fabro_model::AgentProfileKind; -use fabro_types::{InterviewOption, QuestionType}; +use fabro_types::{AgentProfileKind, InterviewOption, QuestionType, ToolDefinition}; use serde::Deserialize; use serde_json::json; use tokio_util::sync::CancellationToken; @@ -214,10 +212,10 @@ pub fn register_question_tools(profile_kind: AgentProfileKind, registry: &mut To fn make_openai_question_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: OPENAI_REQUEST_USER_INPUT_TOOL.to_string(), - description: "Ask the human one or more questions and wait for their answers before continuing this stage.".to_string(), - parameters: json!({ + definition: ToolDefinition::function( + OPENAI_REQUEST_USER_INPUT_TOOL.to_string(), + "Ask the human one or more questions and wait for their answers before continuing this stage.", + json!({ "type": "object", "required": ["questions"], "properties": { @@ -247,7 +245,7 @@ fn make_openai_question_tool() -> RegisteredTool { } } }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let parsed: OpenAiQuestionToolArgs = parse_tool_args(args)?; @@ -262,10 +260,10 @@ fn make_openai_question_tool() -> RegisteredTool { fn make_anthropic_question_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(), - description: "Ask the human one or more questions and wait for their answers before continuing this stage.".to_string(), - parameters: json!({ + definition: ToolDefinition::function( + ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(), + "Ask the human one or more questions and wait for their answers before continuing this stage.", + json!({ "type": "object", "required": ["questions"], "properties": { @@ -296,12 +294,11 @@ fn make_anthropic_question_tool() -> RegisteredTool { } } }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?; - let questions = - normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?; + let questions = normalize_anthropic_questions(parsed, &ANTHROPIC_QUESTION_LIMITS)?; let answers = execute_question_tool(ctx, questions).await?; format_anthropic_answers(&answers) }) @@ -312,10 +309,10 @@ fn make_anthropic_question_tool() -> RegisteredTool { fn make_claude5_question_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(), - description: "Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.".to_string(), - parameters: json!({ + definition: ToolDefinition::function( + ANTHROPIC_ASK_USER_QUESTION_TOOL.to_string(), + "Ask the human up to four questions when a decision is genuinely theirs to make. The UI automatically provides an Other option for custom text.", + json!({ "type": "object", "properties": { "questions": { @@ -373,12 +370,11 @@ fn make_claude5_question_tool() -> RegisteredTool { "required": ["questions"], "additionalProperties": false }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let parsed: AnthropicQuestionToolArgs = parse_tool_args(args)?; - let questions = - normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?; + let questions = normalize_anthropic_questions(parsed, &CLAUDE5_QUESTION_LIMITS)?; let answers = execute_question_tool(ctx, questions).await?; format_anthropic_answers(&answers) }) @@ -652,6 +648,7 @@ mod tests { use super::*; use crate::native_tool::ToolVocabulary; use crate::test_support::MockSandbox; + use crate::tool_registry::ToolDefinitionExt; fn answered( original_id: Option<&str>, @@ -784,9 +781,9 @@ mod tests { let mut claude5 = ToolRegistry::with_vocabulary(ToolVocabulary::Claude5); register_question_tools(AgentProfileKind::Claude5, &mut claude5); let tool = claude5.get(ANTHROPIC_ASK_USER_QUESTION_TOOL).unwrap(); - assert_eq!(tool.definition.parameters["additionalProperties"], false); + assert_eq!(tool.definition.parameters()["additionalProperties"], false); assert_eq!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .as_object() .unwrap() .keys() @@ -795,7 +792,7 @@ mod tests { vec!["questions"] ); assert_eq!( - tool.definition.parameters["properties"]["questions"]["maxItems"], + tool.definition.parameters()["properties"]["questions"]["maxItems"], 4 ); assert!(claude5.get(OPENAI_REQUEST_USER_INPUT_TOOL).is_none()); diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index 3d0cf6ef0..cc562da08 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -2,23 +2,19 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; -use fabro_auth::CredentialSource; -use fabro_llm::client::Client; -use fabro_llm::error::ProviderErrorKind; -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{ - ContentPart, Message as LlmMessage, ReasoningEffort, Request, RetryPolicy, StreamEvent, - TokenCounts, ToolChoice, +use fabro_llm::types::ContentBlockKind; +use fabro_llm::{ + CallContext, Client, FinishReason, LlmError, Request, Response, RetryClassification, + RetryListener, RetryStage, StreamEvent, reasoning, }; -use fabro_llm::{Error as LlmError, retry}; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; use fabro_mcp::http_transport; -use fabro_model::{AgentProfileKind, Catalog, ModelId, ModelRef, Speed, UsdMicros}; use fabro_types::{ - AgentToolSummary, LlmOutputKind, LlmRetryPhase, PermissionLevel, Principal, SessionMessage, - SessionRecord, StageContextWindowProjection, SteeringMessage, + AgentProfileKind, AgentToolSummary, LlmOutputKind, LlmRetryPhase, Message as LlmMessage, + ModelId, ModelRef, PermissionLevel, Principal, ReasoningEffort, Role, SessionMessage, + SessionRecord, Speed, StageContextWindowProjection, SteeringMessage, TokenCounts, ToolCall, + ToolChoice, UsdMicros, billing, }; use fabro_util::shell; use futures::StreamExt; @@ -104,17 +100,69 @@ fn record_elapsed(start: &mut Option, total: &mut Duration) { /// events below identify the first observed content kind. fn first_output_kind(event: &StreamEvent) -> Option { match event { - StreamEvent::ReasoningStart | StreamEvent::ReasoningDelta { .. } => { - Some(LlmOutputKind::Reasoning) - } - StreamEvent::TextStart { .. } | StreamEvent::TextDelta { .. } => Some(LlmOutputKind::Text), - StreamEvent::ToolCallStart { .. } - | StreamEvent::ToolCallDelta { .. } - | StreamEvent::ToolCallEnd { .. } => Some(LlmOutputKind::ToolCall), + StreamEvent::ContentBlockStart { kind, .. } => match kind { + ContentBlockKind::Text => Some(LlmOutputKind::Text), + ContentBlockKind::Reasoning => Some(LlmOutputKind::Reasoning), + ContentBlockKind::ToolCall { .. } => Some(LlmOutputKind::ToolCall), + _ => None, + }, + StreamEvent::ReasoningDelta { .. } => Some(LlmOutputKind::Reasoning), + StreamEvent::TextDelta { .. } => Some(LlmOutputKind::Text), + StreamEvent::ToolCallDelta { .. } => Some(LlmOutputKind::ToolCall), + StreamEvent::ContentBlockEnd { part, .. } => match part { + fabro_types::ContentPart::Text { .. } => Some(LlmOutputKind::Text), + fabro_types::ContentPart::Reasoning(_) => Some(LlmOutputKind::Reasoning), + fabro_types::ContentPart::ToolCall(_) => Some(LlmOutputKind::ToolCall), + _ => None, + }, _ => None, } } +/// A stream ended with a response the agent cannot act on: the provider +/// stopped at its output limit or before the response was complete. Replayed +/// like a transient failure so provisional tool calls never run. +fn incomplete_response_error(response: &Response) -> fabro_llm::Error { + let (code, message) = match response.finish_reason { + FinishReason::Length => ( + "length", + "the provider stopped at its output limit before completing the response", + ), + _ => ( + "incomplete_response", + "the provider ended without a complete response", + ), + }; + fabro_llm::Error::new(fabro_llm::ErrorKind::StreamDecode, message) + .with_provider(response.model.provider().clone()) + .with_provider_code(code) + .with_retry(RetryClassification::Safe) +} + +/// How one inference turn ended. +enum TurnOutcome { + Completed(Box), + /// A steer interrupt cancelled the round; the caller re-iterates. + Interrupted, + /// The session was cancelled. + Cancelled, + Failed(fabro_llm::Error), +} + +/// How one stream attempt within a turn ended. +enum AttemptOutcome { + Completed(Box), + Interrupted, + Cancelled, + Failed(fabro_llm::Error), +} + +struct StreamAttempt { + /// Whether this attempt delivered text or reasoning to the user. + visible_output: bool, + outcome: AttemptOutcome, +} + impl SteeringItem { #[must_use] pub fn actor(&self) -> Option<&Principal> { @@ -457,34 +505,6 @@ impl Session { } } - /// Build a session from a credential source and catalog. Resolves the LLM - /// client once at construction and caches it for the session's lifetime. - /// Sessions are bounded (≤ 1 hour); cached client is fine within that - /// window. For longer-lived contexts (workflow runs) hold a source and - /// catalog, not a session. - /// - /// # Errors - /// - /// Returns an error if `Client::from_source` fails (e.g. vault unreachable, - /// OAuth refresh failed). - pub async fn from_source( - source: &dyn CredentialSource, - catalog: Arc, - provider_profile: Arc, - sandbox: Arc, - config: SessionOptions, - subagent_supervisor: Option, - ) -> Result { - let client = Client::from_source(source, catalog).await?; - Ok(Self::new( - client, - provider_profile, - sandbox, - config, - subagent_supervisor, - )) - } - pub fn from_record( record: &SessionRecord, runtime_context: &[SessionMessage], @@ -545,7 +565,7 @@ impl Session { } #[must_use] - pub fn provider_id(&self) -> fabro_model::ProviderId { + pub fn provider_id(&self) -> fabro_types::ProviderId { self.provider_profile.provider_id() } @@ -1117,35 +1137,17 @@ impl Session { Error::Interrupted(reason) } - fn emit_llm_error(&mut self, err: LlmError) -> Error { + fn emit_llm_error(&mut self, err: fabro_llm::Error) -> Error { + let err = LlmError::from(err); self.event_emitter.emit(self.id.clone(), AgentEvent::Error { error: Error::Llm(err.clone()), }); - if is_auth_error(&err) { + if err.is_auth_error() { self.transition(SessionState::Closed); } Error::Llm(err) } - async fn open_stream_with_retry( - &mut self, - client: &Client, - request: &Request, - retry_policy: &RetryPolicy, - ) -> Result { - let stream_result = retry::retry(retry_policy, || { - let client = client.clone(); - let request = request.clone(); - async move { client.stream(&request).await } - }) - .await; - - match stream_result { - Ok(stream) => Ok(stream), - Err(err) => Err(self.emit_llm_error(err)), - } - } - #[must_use] pub fn followup_queue_handle(&self) -> Arc>> { self.followup_queue.clone() @@ -1275,8 +1277,8 @@ impl Session { } #[must_use] - pub fn last_input_usage(&self) -> TokenCounts { - self.last_input_usage.clone() + pub const fn last_input_usage(&self) -> TokenCounts { + self.last_input_usage } #[must_use] @@ -1423,8 +1425,6 @@ impl Session { usage_accumulator: &mut TokenCounts, cost_accumulator: &mut Option, ) -> Result, Error> { - const STREAM_CONSUME_RETRIES: usize = 3; - if self.state == SessionState::Closed { return Err(Error::SessionClosed); } @@ -1538,15 +1538,15 @@ impl Session { let pending_task_reminder = self.task_reminder_if_needed(); // Build request - let built_request = self.build_request(pending_task_reminder.as_ref()); + let built_request = self.build_request(pending_task_reminder.as_ref())?; let local_context_window = built_request.context_window.clone(); let request = built_request.request; - let requested_model = ModelRef { - provider: self.provider_profile.provider_id(), - model_id: ModelId::new(self.provider_profile.model()), - speed: self.config.speed, - }; + let requested_model = ModelRef::new( + self.provider_profile.provider_id(), + ModelId::new(self.provider_profile.model()), + ) + .with_speed(self.config.speed); // Open the inference bracket for this round. The request is built // and compaction has run, so this is the last point before the @@ -1557,338 +1557,51 @@ impl Session { requested_model: requested_model.clone(), }); - // Call LLM (streaming) with retry for transient errors - let retry_emitter = self.event_emitter.clone(); - let retry_session_id = self.id.clone(); - let retry_provider = requested_model.provider.to_string(); - let retry_model = requested_model.model_id.to_string(); - let retry_policy = RetryPolicy { - max_retries: 3, - on_retry: Some(std::sync::Arc::new(move |err, attempt, delay| { - retry_emitter.emit(retry_session_id.clone(), AgentEvent::LlmRetry { - provider: retry_provider.clone(), - model: retry_model.clone(), - attempt: attempt as usize, - delay_secs: delay.as_secs_f64(), - error: err.clone(), - phase: LlmRetryPhase::Open, - }); - })), - ..Default::default() - }; - let client = self.llm_client.clone(); - let cancel_token_for_select = self.cancel_token.clone(); let mut inference_start = Some(Instant::now()); - let stream_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - let mut event_stream = if let Some(stream) = stream_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - record_elapsed(&mut inference_start, &mut timing.inference); - if self.cancel_token.is_cancelled() { - self.shutdown(SessionShutdownReason::Cancelled).await; - return Err(self.interrupted_error()); - } - // Round-only cancel before stream opened — re-iterate to - // pick up the steer. - continue; - }; - - // Consume the stream, retrying up to 3 times if the provider - // closes the stream without sending a Finish event. If visible - // output was already emitted, clear it before replaying the turn. - let mut response = None; - // Set true if a steer-interrupt cancelled the round mid-stream so - // we can clear partial output and `continue` after the loop. - let mut steer_interrupted = false; - let mut visible_output_present = false; - - 'streamattempts: for stream_attempt in 0..=STREAM_CONSUME_RETRIES { - let mut accumulator = StreamAccumulator::new(); - let mut attempt_emitted_output = false; - let mut stream_error = None; - // Re-armed per attempt: a replayed turn discards everything - // the previous attempt produced, so its first output is a new - // observation rather than a continuation. - let mut first_output_emitted = false; - - loop { - let chunk = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = self.cancel_token.cancelled() => None, - next = event_stream.next() => Some(next), - }; - let Some(event_opt) = chunk else { - // One of the cancellation tokens fired. - break; - }; - let Some(event_result) = event_opt else { - // Stream ended normally. - break; - }; - match event_result { - Ok(event) => { - if !first_output_emitted { - if let Some(kind) = first_output_kind(&event) { - first_output_emitted = true; - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmFirstOutput { kind }); - } - } - match &event { - StreamEvent::TextDelta { ref delta, .. } => { - attempt_emitted_output = true; - visible_output_present = true; - self.event_emitter.emit( - self.id.clone(), - AgentEvent::TextDelta { - delta: delta.clone(), - }, - ); - } - StreamEvent::ReasoningDelta { ref delta } => { - attempt_emitted_output = true; - visible_output_present = true; - self.event_emitter.emit( - self.id.clone(), - AgentEvent::ReasoningDelta { - delta: delta.clone(), - }, - ); - } - _ => {} - } - accumulator.process(&event); - } - Err(err) => { - stream_error = Some(err); - break; - } - } - } - - // If terminal cancel fired, drop the stream and bail out. - if self.cancel_token.is_cancelled() { - drop(event_stream); - record_elapsed(&mut inference_start, &mut timing.inference); - self.shutdown(SessionShutdownReason::Cancelled).await; - return Err(self.interrupted_error()); - } - - // If only the round token fired (steer interrupt), drop the - // stream now; we'll clear partial output and continue below. - if round_token.is_cancelled() { - drop(event_stream); - steer_interrupted = true; - break 'streamattempts; - } - - if let Some(resp) = accumulator.response().cloned() { - response = Some(resp); - break; - } - - if let Some(err) = stream_error { - let can_retry = err.retryable() && stream_attempt < STREAM_CONSUME_RETRIES; - let retry_attempt = u32::try_from(stream_attempt).unwrap_or(u32::MAX); - let retry_delay = can_retry - .then(|| retry::retry_delay(&retry_policy, &err, retry_attempt)) - .flatten(); - - if let Some(delay) = retry_delay { - tracing::warn!( - attempt = stream_attempt + 1, - max = STREAM_CONSUME_RETRIES, - error = %err, - delay_secs = delay.as_secs_f64(), - "LLM stream failed mid-turn, retrying turn" - ); - if attempt_emitted_output { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - visible_output_present = false; - } - // Emitted directly rather than through - // `retry_policy.on_retry` so the event can name the - // consume loop as the source of `attempt`; the policy - // callback only ever runs for stream-open failures. - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmRetry { - provider: requested_model.provider.to_string(), - model: requested_model.model_id.to_string(), - attempt: stream_attempt, - delay_secs: delay.as_secs_f64(), - error: err, - phase: LlmRetryPhase::Consume, - }); - - let delay_outcome = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = self.cancel_token.cancelled() => None, - () = time::sleep(delay) => Some(()), - }; - if delay_outcome.is_none() { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - } - - let cancel_token_for_select = self.cancel_token.clone(); - let retry_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - event_stream = if let Some(stream) = retry_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - }; - continue 'streamattempts; - } - - if visible_output_present { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - } - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(self.emit_llm_error(err)); - } - - // No Finish event — retry if we have attempts left - if stream_attempt < STREAM_CONSUME_RETRIES { - tracing::warn!( - attempt = stream_attempt + 1, - max = STREAM_CONSUME_RETRIES, - "Stream ended without Finish event, retrying turn" - ); - if attempt_emitted_output { - self.event_emitter.emit( - self.id.clone(), - AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }, - ); - visible_output_present = false; - } - // The only mid-turn restart that reaches no error handler: - // without this the round replays and discards its output - // with nothing on the durable stream to show for it. - self.event_emitter - .emit(self.id.clone(), AgentEvent::LlmRetry { - provider: requested_model.provider.to_string(), - model: requested_model.model_id.to_string(), - attempt: stream_attempt, - delay_secs: 0.0, - error: LlmError::Stream { - message: "Stream ended without a finish event".to_string(), - source: None, - }, - phase: LlmRetryPhase::Consume, - }); - let cancel_token_for_select = self.cancel_token.clone(); - let retry_outcome: Option> = tokio::select! { - biased; - () = round_token.cancelled() => None, - () = cancel_token_for_select.cancelled() => None, - stream = self.open_stream_with_retry(&client, &request, &retry_policy) => Some(stream), - }; - event_stream = if let Some(stream) = retry_outcome { - match stream { - Ok(stream) => stream, - Err(err) => { - record_elapsed(&mut inference_start, &mut timing.inference); - return Err(err); - } - } - } else { - steer_interrupted = - round_token.is_cancelled() && !self.cancel_token.is_cancelled(); - break 'streamattempts; - }; - } - } + let turn = self + .run_inference_turn(&request, &requested_model, &round_token) + .await; record_elapsed(&mut inference_start, &mut timing.inference); - // Mid-LLM steer interrupt: drop the unrecorded turn, clear any - // partial visible output, and re-iterate. The next turn's - // top-of-loop drain delivers the steer as the next user message. - if steer_interrupted { - if visible_output_present { - self.event_emitter - .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }); + let response = match turn { + TurnOutcome::Completed(response) => *response, + TurnOutcome::Interrupted => { + // Mid-LLM steer interrupt: the unrecorded turn is dropped + // and any partial visible output has been cleared. The + // next turn's top-of-loop drain delivers the steer as the + // next user message. + continue; } - continue; - } - - let Some(response) = response else { - if visible_output_present { - self.event_emitter - .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { - text: String::new(), - reasoning: None, - }); + TurnOutcome::Cancelled => { + self.shutdown(SessionShutdownReason::Cancelled).await; + return Err(self.interrupted_error()); + } + TurnOutcome::Failed(error) => { + return Err(self.emit_llm_error(error)); } - return Err(self.emit_llm_error(LlmError::Stream { - message: "Stream ended without a Finish event (after retries)".into(), - source: None, - })); }; // Record assistant turn let text = response.text(); - let tool_calls = response.tool_calls(); + let tool_calls: Vec = response.tool_calls().cloned().collect(); // Normalize before the response's content moves into history. - let reasoning = response.reasoning_output(); + let reasoning = reasoning::normalize(&response.content); let provider_parts: Vec<_> = response - .message .content .iter() - .filter(|p| matches!(p, ContentPart::Other { .. } | ContentPart::Thinking(_))) + .filter(|part| reasoning::is_provider_part(part)) .cloned() .collect(); - let usage = response.usage.clone(); + let usage = response.usage; let context_window = Some(context_window_from_response_usage( &local_context_window, &usage, )); - *usage_accumulator += usage.clone(); - UsdMicros::accumulate(cost_accumulator, response.cost_usd.map(UsdMicros::from_usd)); + billing::add_usage(usage_accumulator, usage); + UsdMicros::accumulate( + cost_accumulator, + response.cost.as_ref().map(UsdMicros::from_cost), + ); if let Some(reminder) = pending_task_reminder { self.history.push(reminder); @@ -1897,28 +1610,21 @@ impl Session { content: text.clone(), tool_calls: tool_calls.clone(), provider_parts, - usage: Box::new(usage), - response_id: response.id.clone(), + usage, + response_id: response.id.clone().unwrap_or_default(), timestamp: SystemTime::now(), }); - // Emit AssistantMessage with enriched data from the response - let model = ModelRef { - provider: self.provider_profile.provider_id(), - model_id: if response.model.is_empty() { - self.provider_profile.model().into() - } else { - response.model.clone().into() - }, - speed: self.config.speed, - }; + // Emit AssistantMessage with enriched data from the response. The + // response names the route that actually answered, which failover + // or a stand-in provider can make differ from the request. + let model = ModelRef::from_handle(&response.model, self.config.speed); self.event_emitter .emit(self.id.clone(), AgentEvent::AssistantMessage { text: text.clone(), model, - usage: response.usage.clone(), - cost_usd: response.cost_usd, - cost_source: response.cost_source, + usage, + cost: response.cost, tool_call_count: tool_calls.len(), context_window, reasoning, @@ -2029,6 +1735,284 @@ impl Session { } } + /// Run one inference turn to a final response, replaying the turn when a + /// stream fails after it already produced visible output. + /// + /// Failures before visible output are the client's to retry: the lithos + /// retry middleware reconnects them and reports each attempt through the + /// call's [`RetryListener`], which this method turns into `LlmRetry` + /// events. Once text or reasoning has reached the user no middleware can + /// replay the turn without duplicating output, so the agent does it here: + /// it clears the shown output with `AssistantOutputReplace`, waits the + /// delay the same policy computes, and streams the turn again. A stream + /// whose final response ends `Length` or `Incomplete` is not a completed + /// turn: it is replayed like a failure, and its provisional tool calls + /// are never executed. + async fn run_inference_turn( + &mut self, + request: &Request, + requested_model: &ModelRef, + round_token: &CancellationToken, + ) -> TurnOutcome { + let policy = self.config.replay_retry_policy; + let mut replay_attempt: u32 = 1; + // Whether text or reasoning from an earlier attempt is still shown. + let mut visible_output_present = false; + + loop { + let attempt = self + .stream_attempt(request, requested_model, round_token) + .await; + let visible_this_attempt = attempt.visible_output; + visible_output_present |= visible_this_attempt; + + let error = match attempt.outcome { + AttemptOutcome::Completed(response) => return TurnOutcome::Completed(response), + AttemptOutcome::Cancelled => { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Cancelled; + } + AttemptOutcome::Interrupted => { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Interrupted; + } + AttemptOutcome::Failed(error) => error, + }; + + // A failure before any visible output already went through the + // client's retry middleware; replaying it here would multiply the + // attempts. Only a turn the user has seen part of is replayed. + let delay = if visible_this_attempt { + policy.next_delay(replay_attempt, &error) + } else { + None + }; + let Some(delay) = delay else { + if visible_output_present { + self.clear_visible_output(); + } + return TurnOutcome::Failed(error); + }; + + tracing::warn!( + attempt = replay_attempt, + error = %error, + delay_secs = delay.as_secs_f64(), + "LLM stream failed after visible output, replaying turn" + ); + if visible_output_present { + self.clear_visible_output(); + visible_output_present = false; + } + self.event_emitter + .emit(self.id.clone(), AgentEvent::LlmRetry { + provider: requested_model.provider.to_string(), + model: requested_model.model_id.to_string(), + attempt: usize::try_from(replay_attempt).unwrap_or(usize::MAX), + delay_secs: delay.as_secs_f64(), + error: LlmError::from(&error), + phase: LlmRetryPhase::Consume, + }); + + let delay_outcome = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + () = time::sleep(delay) => Some(()), + }; + if delay_outcome.is_none() { + return if self.cancel_token.is_cancelled() { + TurnOutcome::Cancelled + } else { + TurnOutcome::Interrupted + }; + } + replay_attempt = replay_attempt.saturating_add(1); + } + } + + /// Open one stream and consume it to its final response. + async fn stream_attempt( + &mut self, + request: &Request, + requested_model: &ModelRef, + round_token: &CancellationToken, + ) -> StreamAttempt { + let mut attempt = StreamAttempt { + visible_output: false, + outcome: AttemptOutcome::Cancelled, + }; + + // Bind the lithos call to the agent's cancellation so the client + // releases the provider connection when the round or session ends. + let mut context = CallContext::new(); + let call_cancellation = context.cancellation().clone(); + context + .extensions_mut() + .insert(self.retry_listener(requested_model)); + let cancel_watcher = { + let round_token = round_token.clone(); + let cancel_token = self.cancel_token.clone(); + tokio::spawn(async move { + tokio::select! { + () = round_token.cancelled() => {} + () = cancel_token.cancelled() => {} + } + call_cancellation.cancel(); + }) + }; + + let client = self.llm_client.clone(); + let stream_outcome = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + stream = client.stream_with_context(request.clone(), context) => Some(stream), + }; + let mut event_stream = match stream_outcome { + Some(Ok(stream)) => stream, + Some(Err(error)) => { + cancel_watcher.abort(); + attempt.outcome = self.classify_stream_end(Err(error), round_token); + return attempt; + } + None => { + cancel_watcher.abort(); + attempt.outcome = self.cancellation_outcome(round_token); + return attempt; + } + }; + + // Re-armed per attempt: a replayed turn discards everything the + // previous attempt produced, so its first output is a new observation + // rather than a continuation. + let mut first_output_emitted = false; + let outcome = loop { + let chunk = tokio::select! { + biased; + () = round_token.cancelled() => None, + () = self.cancel_token.cancelled() => None, + next = event_stream.next() => Some(next), + }; + let Some(item) = chunk else { + break self.cancellation_outcome(round_token); + }; + let Some(item) = item else { + // `ResponseStream` turns a stream that ends without `Ended` + // into an error item, so a bare end follows a terminal item + // that was already handled. + break self.cancellation_outcome(round_token); + }; + let event = match item { + Ok(event) => event, + Err(error) => break self.classify_stream_end(Err(error), round_token), + }; + if !first_output_emitted { + if let Some(kind) = first_output_kind(&event) { + first_output_emitted = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::LlmFirstOutput { kind }); + } + } + match event { + StreamEvent::TextDelta { text, .. } => { + attempt.visible_output = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::TextDelta { delta: text }); + } + StreamEvent::ReasoningDelta { text, .. } => { + attempt.visible_output = true; + self.event_emitter + .emit(self.id.clone(), AgentEvent::ReasoningDelta { delta: text }); + } + StreamEvent::Ended { response } => { + break self.classify_stream_end(Ok(*response), round_token); + } + _ => {} + } + }; + drop(event_stream); + cancel_watcher.abort(); + attempt.outcome = outcome; + attempt + } + + /// Classify how a stream ended, preferring the agent's own cancellation + /// signals over whatever error the cancelled call reported. + fn classify_stream_end( + &self, + end: Result, + round_token: &CancellationToken, + ) -> AttemptOutcome { + if self.cancel_token.is_cancelled() || round_token.is_cancelled() { + return self.cancellation_outcome(round_token); + } + match end { + Ok(response) => match response.finish_reason { + FinishReason::Length | FinishReason::Incomplete => { + AttemptOutcome::Failed(incomplete_response_error(&response)) + } + _ => AttemptOutcome::Completed(Box::new(response)), + }, + Err(error) => AttemptOutcome::Failed(error), + } + } + + fn cancellation_outcome(&self, round_token: &CancellationToken) -> AttemptOutcome { + if self.cancel_token.is_cancelled() { + AttemptOutcome::Cancelled + } else if round_token.is_cancelled() { + AttemptOutcome::Interrupted + } else { + // Neither token fired, so the stream itself ended. `ResponseStream` + // reports a completion-less end as an error item, so reaching + // here means the terminal item was consumed already. + AttemptOutcome::Failed( + fabro_llm::Error::new( + fabro_llm::ErrorKind::StreamDecode, + "the response stream ended without completion", + ) + .with_retry(RetryClassification::Safe), + ) + } + } + + /// Emit the event that clears partial assistant output shown to the user. + fn clear_visible_output(&self) { + self.event_emitter + .emit(self.id.clone(), AgentEvent::AssistantOutputReplace { + text: String::new(), + reasoning: None, + }); + } + + /// The listener that records the client's own retries, which happen + /// before any visible output, as `LlmRetry` events. + fn retry_listener(&self, requested_model: &ModelRef) -> RetryListener { + let emitter = self.event_emitter.clone(); + let session_id = self.id.clone(); + let provider = requested_model.provider.to_string(); + let model = requested_model.model_id.to_string(); + RetryListener::new(move |notice| { + let phase = match notice.stage { + RetryStage::Stream => LlmRetryPhase::Consume, + _ => LlmRetryPhase::Open, + }; + emitter.emit(session_id.clone(), AgentEvent::LlmRetry { + provider: provider.clone(), + model: model.clone(), + attempt: usize::try_from(notice.attempt).unwrap_or(usize::MAX), + delay_secs: notice.delay.as_secs_f64(), + error: notice.error, + phase, + }); + }) + } + /// Attempt context compaction when the configured threshold is exceeded. /// /// Returns `true` when an attempted compaction failed so the current input @@ -2129,10 +2113,13 @@ impl Session { } } - fn build_request(&self, pending_task_reminder: Option<&Message>) -> BuiltRequest { + fn build_request( + &self, + pending_task_reminder: Option<&Message>, + ) -> Result { let mut messages = Vec::new(); if !self.system_prompt.trim().is_empty() { - messages.push(LlmMessage::system(self.system_prompt.clone())); + messages.push(LlmMessage::text(Role::System, self.system_prompt.clone())); } messages.extend(self.history.convert_to_messages()); if let Some(reminder) = pending_task_reminder { @@ -2140,37 +2127,36 @@ impl Session { } let tools_with_source = self.effective_tools(); - let tools: Vec<_> = tools_with_source - .iter() - .map(|tool| tool.definition.clone()) - .collect(); - let has_tools = !tools.is_empty(); + let has_tools = !tools_with_source.is_empty(); - let request = Request { - model: self.provider_profile.model().to_string(), - messages, - provider: Some(self.provider_profile.provider_id().to_string()), - tools: if has_tools { Some(tools) } else { None }, - tool_choice: if has_tools { - Some(ToolChoice::Auto) - } else { - None - }, - response_format: None, - temperature: None, - top_p: None, - max_tokens: self - .config - .max_tokens - .or_else(|| self.provider_profile.max_output_tokens()), - stop_sequences: None, - reasoning_effort: self.config.reasoning_effort, - speed: self.config.speed, - metadata: None, - provider_options: None, - }; let provider = self.provider_profile.provider_id().to_string(); let model = self.provider_profile.model().to_string(); + let mut builder = Request::builder().model(format!("{provider}/{model}")); + for message in messages { + builder = builder.message(message); + } + for tool in &tools_with_source { + builder = builder.tool(tool.definition.clone()); + } + if has_tools { + builder = builder.tool_choice(ToolChoice::Auto); + } + if let Some(max_tokens) = self + .config + .max_tokens + .or_else(|| self.provider_profile.max_output_tokens()) + { + builder = builder.max_output_tokens(max_tokens); + } + if let Some(effort) = self.config.reasoning_effort { + builder = builder.reasoning_effort(effort); + } + if let Some(speed) = self.config.speed { + builder = builder.speed(speed); + } + let request = builder + .build() + .map_err(|err| Error::InvalidState(format!("invalid LLM request: {err}")))?; let context_window = build_local_snapshot(ContextWindowInput { request: &request, tools: &tools_with_source, @@ -2183,10 +2169,10 @@ impl Session { model: &model, context_window_tokens: self.provider_profile.context_window_size(), }); - BuiltRequest { + Ok(BuiltRequest { request, context_window, - } + }) } fn task_reminder_if_needed(&self) -> Option { @@ -2202,13 +2188,6 @@ impl Session { } } -const fn is_auth_error(err: &LlmError) -> bool { - matches!( - err.provider_kind(), - Some(ProviderErrorKind::Authentication | ProviderErrorKind::AccessDenied) - ) -} - /// Build the script that launches a sandbox MCP server detached and echoes its /// PID. /// @@ -2262,13 +2241,16 @@ mod tests { use std::time::Duration; use anyhow::Context as _; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; - use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; - use fabro_llm::types::{ - ContentPart, ReasoningEffort, Request, Response, Role, StreamEvent, TokenCounts, ToolCall, - ToolDefinition, + use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; + use fabro_llm::lithos_catalog::AdapterId; + use fabro_llm::reasoning::OPENAI_COMPAT_REASONING_DETAILS_KIND; + use fabro_llm::test_support::response_to_stream; + use fabro_llm::types::{ContentBlockId, ContentBlockKind, ToolCallKind}; + use fabro_llm::{ErrorFacts, ErrorKind, ResponseStream, RetryPolicy}; + use fabro_types::{ + ContentPart, Cost, CostSource, ReasoningOutput, StageContextWindowCountMethod, + ToolDefinition, provider_ids, text_of, tool_result_to_json, }; - use fabro_types::{ReasoningOutput, StageContextWindowCountMethod}; use futures::stream; use tokio::time::{sleep, timeout}; @@ -2381,29 +2363,114 @@ mod tests { fn make_named_noop_tool(name: &str) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: name.to_string(), - description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + name.to_string(), + format!("Tool {name}"), + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".to_string()) })), source: ToolSource::Native, } } + /// A cloneable recipe for a lithos error, since the live error itself + /// carries a source chain and cannot be cloned. + #[derive(Clone)] + struct ScriptedError { + kind: ErrorKind, + message: String, + retry: RetryClassification, + } + + impl ScriptedError { + fn build(&self) -> fabro_llm::Error { + fabro_llm::Error::new(self.kind.clone(), self.message.clone()) + .with_provider(provider_ids::anthropic()) + .with_retry(self.retry) + } + } + + /// A transient stream failure the provider may be asked to repeat. + fn stream_error(message: &str) -> ScriptedError { + ScriptedError { + kind: ErrorKind::StreamDecode, + message: message.to_string(), + retry: RetryClassification::Safe, + } + } + + /// A deterministic provider failure that repeating cannot fix. + fn provider_error(kind: ErrorKind, message: &str) -> ScriptedError { + ScriptedError { + kind, + message: message.to_string(), + retry: RetryClassification::Never, + } + } + + fn block(index: usize) -> ContentBlockId { + ContentBlockId::new(format!("block_{index}")) + } + + fn text_delta(text: &str) -> StreamEvent { + StreamEvent::TextDelta { + id: block(0), + text: text.to_string(), + } + } + + fn reasoning_delta(text: &str) -> StreamEvent { + StreamEvent::ReasoningDelta { + id: block(0), + text: text.to_string(), + } + } + + fn tool_call_start(tool_call: &ToolCall) -> StreamEvent { + StreamEvent::ContentBlockStart { + id: block(1), + kind: ContentBlockKind::ToolCall { + id: tool_call.id.clone(), + name: Some(tool_call.name.clone()), + kind: ToolCallKind::Function, + }, + } + } + + fn tool_call_delta(arguments: &str) -> StreamEvent { + StreamEvent::ToolCallDelta { + id: block(1), + arguments: arguments.to_string(), + } + } + + fn tool_call_end(tool_call: &ToolCall) -> StreamEvent { + StreamEvent::ContentBlockEnd { + id: block(1), + part: ContentPart::ToolCall(tool_call.clone()), + } + } + + fn finish(response: Response) -> StreamEvent { + StreamEvent::Ended { + response: Box::new(response), + } + } + #[derive(Clone)] enum ScriptedStreamCall { Response(Box), - Events(Vec>), + Events(Vec>), /// Emit the events, then hang until the round is cancelled. - EventsThenPending(Vec>), - Error(LlmError), + EventsThenPending(Vec>), + Error(ScriptedError), } struct ScriptedStreamProvider { calls: Vec, requests: Mutex>, call_index: AtomicUsize, + id: AdapterId, } impl ScriptedStreamProvider { @@ -2416,67 +2483,50 @@ mod tests { calls, requests: Mutex::new(Vec::new()), call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } - fn events_for_response(response: Response) -> Vec> { - let mut events = Vec::new(); - let text = response.text(); - if !text.is_empty() { - events.push(Ok(StreamEvent::text_delta(text, None))); - } - - for part in &response.message.content { - if let ContentPart::ToolCall(tool_call) = part { - events.push(Ok(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - })); - } - } - - events.push(Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - ))); - events + fn events( + scripted: Vec>, + ) -> Vec> { + scripted + .into_iter() + .map(|item| item.map_err(|error| error.build())) + .collect() } } #[async_trait::async_trait] impl ProviderAdapter for ScriptedStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "ScriptedStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "ScriptedStreamProvider does not implement complete()", + )) } - async fn stream(&self, request: &Request) -> Result { + async fn stream(&self, call: &ResolvedCall) -> Result { self.requests .lock() .expect("request capture lock poisoned") - .push(request.clone()); + .push(call.request().clone()); let idx = self.call_index.fetch_add(1, Ordering::SeqCst); - let scripted = if idx < self.calls.len() { - self.calls[idx].clone() - } else { - self.calls[self.calls.len() - 1].clone() - }; + let scripted = self.calls[idx.min(self.calls.len() - 1)].clone(); match scripted { - ScriptedStreamCall::Response(response) => { - Ok(Box::pin(stream::iter(Self::events_for_response(*response)))) + ScriptedStreamCall::Response(response) => Ok(response_to_stream(*response)), + ScriptedStreamCall::Events(events) => { + Ok(ResponseStream::new(stream::iter(Self::events(events)))) } - ScriptedStreamCall::Events(events) => Ok(Box::pin(stream::iter(events))), - ScriptedStreamCall::EventsThenPending(events) => { - Ok(Box::pin(stream::iter(events).chain(stream::pending()))) - } - ScriptedStreamCall::Error(err) => Err(err), + ScriptedStreamCall::EventsThenPending(events) => Ok(ResponseStream::new( + stream::iter(Self::events(events)).chain(stream::pending()), + )), + ScriptedStreamCall::Error(err) => Err(err.build()), } } } @@ -2485,6 +2535,7 @@ mod tests { responses: Vec, delay: Duration, call_index: AtomicUsize, + id: AdapterId, } impl DelayedStreamProvider { @@ -2493,31 +2544,28 @@ mod tests { responses, delay, call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } } #[async_trait::async_trait] impl ProviderAdapter for DelayedStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "DelayedStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "DelayedStreamProvider does not implement complete()", + )) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _call: &ResolvedCall) -> Result { sleep(self.delay).await; let idx = self.call_index.fetch_add(1, Ordering::SeqCst); - let response = if idx < self.responses.len() { - self.responses[idx].clone() - } else { - self.responses[self.responses.len() - 1].clone() - }; + let response = self.responses[idx.min(self.responses.len() - 1)].clone(); Ok(response_to_stream(response)) } } @@ -2526,6 +2574,7 @@ mod tests { first_started: Arc, response: Response, call_index: AtomicUsize, + id: AdapterId, } impl BlockingFirstStreamProvider { @@ -2534,24 +2583,25 @@ mod tests { first_started: Arc::new(Notify::new()), response, call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } } #[async_trait::async_trait] impl ProviderAdapter for BlockingFirstStreamProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(LlmError::Configuration { - message: "BlockingFirstStreamProvider does not implement complete()".into(), - source: None, - }) + async fn complete(&self, _call: &ResolvedCall) -> Result { + Err(fabro_llm::Error::new( + ErrorKind::Configuration, + "BlockingFirstStreamProvider does not implement complete()", + )) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream(&self, _call: &ResolvedCall) -> Result { if self.call_index.fetch_add(1, Ordering::SeqCst) == 0 { self.first_started.notify_one(); return std::future::pending().await; @@ -2658,11 +2708,11 @@ mod tests { async fn last_input_timing_reports_inference_and_tool_per_call() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "slow_tool".into(), - description: "Sleeps before returning".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "slow_tool", + "Sleeps before returning", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { sleep(Duration::from_millis(30)).await; @@ -2730,11 +2780,11 @@ mod tests { let seen_tokens = Arc::new(Mutex::new(Vec::new())); let seen_tokens_for_tool = Arc::clone(&seen_tokens); let record_env_tool = RegisteredTool { - definition: ToolDefinition { - name: "record_env".into(), - description: "Records resolved env".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "record_env", + "Records resolved env", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args, ctx| { let seen_tokens = Arc::clone(&seen_tokens_for_tool); Box::pin(async move { @@ -2941,14 +2991,19 @@ mod tests { #[tokio::test] async fn interrupted_round_does_not_commit_task_reminder() { + // The block start alone is protocol bookkeeping the client holds back + // until the stream shows something; the argument delta is what makes + // the tool call observable mid-flight. + let pending_call = ToolCall::function( + "call_1", + "TaskUpdate", + serde_json::json!({"taskId": "1", "status": "completed"}), + ); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::EventsThenPending(vec![Ok(StreamEvent::ToolCallStart { - tool_call: ToolCall::new( - "call_1", - "TaskUpdate", - serde_json::json!({"taskId": "1", "status": "completed"}), - ), - })]), + ScriptedStreamCall::EventsThenPending(vec![ + Ok(tool_call_start(&pending_call)), + Ok(tool_call_delta("{\"taskId\": \"1\"")), + ]), ScriptedStreamCall::Response(Box::new(text_response("resumed"))), ])); let mut registry = ToolRegistry::new(); @@ -2964,7 +3019,7 @@ mod tests { content: "done".into(), tool_calls: Vec::new(), provider_parts: Vec::new(), - usage: Box::::default(), + usage: TokenCounts::default(), response_id: format!("response_{index}"), timestamp: SystemTime::now(), }); @@ -3002,25 +3057,28 @@ mod tests { .first() .expect("the interrupted request should be captured"); let staged = interrupted - .messages + .messages() .last() .expect("the interrupted request should not be empty"); - assert_eq!(staged.role, Role::System); - assert_eq!(staged.text(), task_reminder::TASK_REMINDER_TEXT); + assert_eq!(staged.role(), Role::System); + assert_eq!(text_of(staged.content()), task_reminder::TASK_REMINDER_TEXT); let resumed = requests .get(1) .expect("steering should trigger a second provider request"); - let [.., steering, reminder] = resumed.messages.as_slice() else { + let [.., steering, reminder] = resumed.messages() else { panic!( "the resumed request should end with steering and a restaged reminder: {:?}", - resumed.messages + resumed.messages() ); }; - assert_eq!(steering.role, Role::User); - assert_eq!(steering.text(), "wrap up now"); - assert_eq!(reminder.role, Role::System); - assert_eq!(reminder.text(), task_reminder::TASK_REMINDER_TEXT); + assert_eq!(steering.role(), Role::User); + assert_eq!(text_of(steering.content()), "wrap up now"); + assert_eq!(reminder.role(), Role::System); + assert_eq!( + text_of(reminder.content()), + task_reminder::TASK_REMINDER_TEXT + ); let [ .., @@ -3042,11 +3100,11 @@ mod tests { #[tokio::test] async fn interrupt_during_tool_settles_once_after_balancing_tool_result() { let blocking_tool = RegisteredTool { - definition: ToolDefinition { - name: "block".into(), - description: "Blocks until interrupted".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "block", + "Blocks until interrupted", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; @@ -3430,7 +3488,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); assert_eq!( - results[0].content, + tool_result_to_json(&results[0]), serde_json::json!("Unknown tool: nonexistent_tool") ); } else { @@ -3455,7 +3513,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); assert_eq!( - results[0].content, + tool_result_to_json(&results[0]), serde_json::json!("tool execution failed") ); } else { @@ -3540,11 +3598,11 @@ mod tests { // Tool that cancels the token when executed let abort_tool = RegisteredTool { - definition: ToolDefinition { - name: "set_abort".into(), - description: "Sets interrupt flag".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "set_abort", + "Sets interrupt flag", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args, _ctx| { let token = cancel_token_for_tool.clone(); Box::pin(async move { @@ -3595,12 +3653,9 @@ mod tests { #[tokio::test] async fn auth_error_closes_session() { - let error_provider = Arc::new(MockErrorProvider { - error: LlmError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("invalid api key", "mock")), - }, - }); + let error_provider = Arc::new(MockErrorProvider::new(|| { + fabro_llm::Error::new(ErrorKind::Authentication, "invalid api key") + })); let client = make_client(error_provider).await; let profile = Arc::new(TestProfile::new()); let env = Arc::new(MockSandbox::default()); @@ -3784,7 +3839,7 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - assert_eq!(request.reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(request.reasoning_effort(), Some(ReasoningEffort::High)); } #[tokio::test] @@ -3815,17 +3870,17 @@ mod tests { async fn invalid_tool_args_returns_validation_error() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "strict_tool".into(), - description: "Tool with required params".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "strict_tool", + "Tool with required params", + serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} }, "required": ["text"] }), - }, + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("should not reach".to_string()) }) }), @@ -3843,7 +3898,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("text") && content_str.contains("required"), "Expected validation error mentioning 'text' and 'required', got: {content_str}" @@ -3857,17 +3912,17 @@ mod tests { async fn valid_tool_args_passes_validation() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "strict_tool".into(), - description: "Tool with required params".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "strict_tool", + "Tool with required params", + serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} }, "required": ["text"] }), - }, + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("tool executed".to_string()) }) }), @@ -3942,8 +3997,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let system_msg = &request.messages[0]; - let system_text = system_msg.text(); + let system_msg = &request.messages()[0]; + let system_text = text_of(system_msg.content()); assert!( system_text.contains("Always use TDD"), "System prompt should contain user instructions" @@ -3968,13 +4023,13 @@ mod tests { .expect("request should have been captured"); assert!( request - .messages + .messages() .iter() - .all(|message| message.role != Role::System), + .all(|message| message.role() != Role::System), "request should not contain an empty system message" ); assert!( - matches!(request.messages.first(), Some(message) if message.role == Role::User), + matches!(request.messages().first(), Some(message) if message.role() == Role::User), "first request message should be user input" ); } @@ -3997,7 +4052,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); let tool_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); assert_eq!(tool_names.len(), 2); assert!(tool_names.contains(&"read_file")); @@ -4026,8 +4082,9 @@ mod tests { .as_ref() .expect("request should have been captured"); assert!( - request.messages.iter().any(|message| { - message.role == Role::System && message.text() == task_reminder::TASK_REMINDER_TEXT + request.messages().iter().any(|message| { + message.role() == Role::System + && text_of(message.content()) == task_reminder::TASK_REMINDER_TEXT }), "request should include task reminder system message" ); @@ -4059,7 +4116,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "read_file"); } @@ -4122,7 +4180,8 @@ mod tests { let request = captured .as_ref() .expect("request should have been captured"); - let tools = request.tools.as_ref().expect("tools should be exposed"); + let tools = request.tools(); + assert!(!tools.is_empty(), "tools should be exposed"); let tool_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); assert_eq!(tool_names.len(), 2); assert!(tool_names.contains(&"read_file")); @@ -4156,7 +4215,7 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert!(results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("denied by policy"), "Expected denial message in content, got: {content_str}" @@ -4193,7 +4252,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(!results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("echo: hello"), "Expected echo output in content, got: {content_str}" @@ -4258,7 +4317,7 @@ mod tests { let turns = session.history().turns(); if let Message::ToolResults { results, .. } = &turns[2] { assert!(!results[0].is_error); - let content_str = results[0].content.to_string(); + let content_str = tool_result_to_json(&results[0]).to_string(); assert!( content_str.contains("echo: hello"), "Expected echo output in content, got: {content_str}" @@ -4331,11 +4390,8 @@ mod tests { async fn stream_retries_retryable_mid_stream_error_and_records_recovered_response() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(LlmError::Stream { - message: "connection reset".into(), - source: None, - }), + Ok(text_delta("partial")), + Err(stream_error("connection reset")), ]), ScriptedStreamCall::Response(Box::new(text_response("Recovered"))), ])); @@ -4361,7 +4417,7 @@ mod tests { } AgentEvent::LlmRetry { error, .. } => { retry_count += 1; - assert!(error.retryable()); + assert!(error.is_retryable()); } AgentEvent::AssistantMessage { text, .. } => { observed.push(format!("message:{text}")); @@ -4383,15 +4439,15 @@ mod tests { /// Builds a response whose provider parts carry both reasoning channels. fn reasoning_response(text: &str, summary: &str, trace: &str) -> Response { let mut response = text_response(text); - let mut content = vec![ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::json!([ + let mut content = vec![ContentPart::opaque( + OPENAI_COMPAT_REASONING_DETAILS_KIND, + serde_json::json!([ {"type": "reasoning.summary", "summary": summary}, {"type": "reasoning.text", "text": trace}, ]), - }]; - content.extend(response.message.content); - response.message.content = content; + )]; + content.extend(response.content); + response.content = content; response } @@ -4430,12 +4486,12 @@ mod tests { async fn tool_call_response_with_no_visible_text_still_carries_reasoning() { let mut tool_call = tool_call_response("nonexistent_tool", "call_1", serde_json::json!({})); // Drop the visible text so only the tool call and reasoning remain. - tool_call.message.content = vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::json!([{"type": "reasoning.summary", "summary": "call the tool"}]), - }, - ContentPart::ToolCall(ToolCall::new( + tool_call.content = vec![ + ContentPart::opaque( + OPENAI_COMPAT_REASONING_DETAILS_KIND, + serde_json::json!([{"type": "reasoning.summary", "summary": "call the tool"}]), + ), + ContentPart::ToolCall(ToolCall::function( "call_1", "nonexistent_tool", serde_json::json!({}), @@ -4458,13 +4514,8 @@ mod tests { async fn only_the_final_response_contributes_reasoning_after_a_retry() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ReasoningDelta { - delta: "discarded thinking".to_string(), - }), - Err(LlmError::Stream { - message: "connection reset".into(), - source: None, - }), + Ok(reasoning_delta("discarded thinking")), + Err(stream_error("connection reset")), ]), ScriptedStreamCall::Response(Box::new(reasoning_response( "Recovered", @@ -4487,48 +4538,29 @@ mod tests { #[tokio::test(start_paused = true)] async fn stream_quota_error_does_not_replay() { - let quota_error = LlmError::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: Box::new(ProviderErrorDetail { - error_code: Some("insufficient_quota".into()), - ..ProviderErrorDetail::new("You exceeded your current quota", "mock") - }), - }; + let quota_error = + provider_error(ErrorKind::QuotaExceeded, "You exceeded your current quota"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(quota_error.clone()), - ]), + ScriptedStreamCall::Events(vec![Ok(text_delta("partial")), Err(quota_error.clone())]), ])); let mut session = make_session_with_provider(provider.clone()).await; let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::QuotaExceeded, - .. - })) + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::QuotaExceeded )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); } - async fn assert_non_retryable_mid_stream_provider_error_does_not_replay( - kind: ProviderErrorKind, - ) { - let llm_error = LlmError::Provider { - kind, - detail: Box::new(ProviderErrorDetail::new( - format!("deterministic provider error: {kind:?}"), - "mock", - )), - }; + async fn assert_non_retryable_mid_stream_provider_error_does_not_replay(kind: ErrorKind) { + let llm_error = provider_error( + kind.clone(), + &format!("deterministic provider error: {kind:?}"), + ); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Err(llm_error.clone()), - ]), + ScriptedStreamCall::Events(vec![Ok(text_delta("partial")), Err(llm_error.clone())]), ScriptedStreamCall::Response(Box::new(text_response("should not replay"))), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4537,11 +4569,8 @@ mod tests { let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: actual_kind, - .. - })) if actual_kind == kind + &result, + Err(Error::Llm(error)) if error.kind() == kind )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); assert_eq!(session.history().turns().len(), 1); @@ -4557,11 +4586,8 @@ mod tests { AgentEvent::LlmRetry { .. } => retry_count += 1, AgentEvent::Error { error } => { assert!(matches!( - error, - Error::Llm(LlmError::Provider { - kind: actual_kind, - .. - }) if actual_kind == kind + &error, + Error::Llm(error) if error.kind() == kind )); observed.push("error".to_string()); } @@ -4580,36 +4606,25 @@ mod tests { #[tokio::test(start_paused = true)] async fn stream_non_retryable_mid_stream_errors_do_not_replay() { - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::Authentication, - ) - .await; - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::ContextLength, - ) - .await; - assert_non_retryable_mid_stream_provider_error_does_not_replay( - ProviderErrorKind::QuotaExceeded, - ) - .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::Authentication) + .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::ContextLength) + .await; + assert_non_retryable_mid_stream_provider_error_does_not_replay(ErrorKind::QuotaExceeded) + .await; } #[tokio::test(start_paused = true)] async fn stream_retry_exhaustion_emits_one_error_without_committing_assistant_or_tools() { - let retryable_error = LlmError::Stream { - message: "connection reset".into(), - source: None, - }; + let retryable_error = stream_error("connection reset"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::text_delta("partial", None)), - Ok(StreamEvent::ToolCallEnd { - tool_call: ToolCall::new( - "call_1", - "echo", - serde_json::json!({"text": "should not run"}), - ), - }), + Ok(text_delta("partial")), + Ok(tool_call_end(&ToolCall::function( + "call_1", + "echo", + serde_json::json!({"text": "should not run"}), + ))), Err(retryable_error.clone()), ]), ])); @@ -4618,8 +4633,13 @@ mod tests { let result = session.process_input("Hello").await; - assert!(matches!(result, Err(Error::Llm(LlmError::Stream { .. })))); - assert_eq!(provider.call_index.load(Ordering::SeqCst), 4); + assert!(matches!( + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::StreamDecode + )); + // Visible output was shown on every attempt, so only the agent's + // bounded replay loop runs: three attempts under the default policy. + assert_eq!(provider.call_index.load(Ordering::SeqCst), 3); assert_eq!(session.history().turns().len(), 1); let mut retry_count = 0; @@ -4632,7 +4652,7 @@ mod tests { match event.event { AgentEvent::LlmRetry { error, .. } => { retry_count += 1; - assert!(error.retryable()); + assert!(error.is_retryable()); } AgentEvent::AssistantOutputReplace { text, reasoning } => { assert_eq!(text, ""); @@ -4640,7 +4660,10 @@ mod tests { replace_count += 1; } AgentEvent::Error { error } => { - assert!(matches!(error, Error::Llm(LlmError::Stream { .. }))); + assert!(matches!( + &error, + Error::Llm(error) if error.kind() == ErrorKind::StreamDecode + )); error_count += 1; } AgentEvent::AssistantMessage { .. } => assistant_message_count += 1, @@ -4650,8 +4673,8 @@ mod tests { } } - assert_eq!(retry_count, 3); - assert_eq!(replace_count, 4); + assert_eq!(retry_count, 2); + assert_eq!(replace_count, 3); assert_eq!(error_count, 1); assert_eq!(assistant_message_count, 0); assert_eq!(tool_started_count, 0); @@ -4706,15 +4729,9 @@ mod tests { let response = text_response("Hello"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ReasoningDelta { - delta: "weighing options".to_string(), - }), - Ok(StreamEvent::text_delta("Hello", None)), - Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - )), + Ok(reasoning_delta("weighing options")), + Ok(text_delta("Hello")), + Ok(finish(response)), ]), ])); let mut session = make_session_with_provider(provider).await; @@ -4732,26 +4749,18 @@ mod tests { #[tokio::test] async fn first_output_reports_tool_call_for_a_turn_with_no_text_or_reasoning() { - let tool_call = ToolCall::new("call_1", "nonexistent_tool", serde_json::json!({})); + let tool_call = ToolCall::function("call_1", "nonexistent_tool", serde_json::json!({})); let mut response = tool_call_response("nonexistent_tool", "call_1", serde_json::json!({})); // Strip the visible text so the turn produces neither a text nor a // reasoning delta — the case a latch keyed on those two would miss // entirely, leaving tool-heavy rounds silent. - response.message.content = vec![ContentPart::ToolCall(tool_call.clone())]; + response.content = vec![ContentPart::ToolCall(tool_call.clone())]; let provider = Arc::new(ScriptedStreamProvider::new(vec![ ScriptedStreamCall::Events(vec![ - Ok(StreamEvent::ToolCallStart { - tool_call: tool_call.clone(), - }), - Ok(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }), - Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - )), + Ok(tool_call_start(&tool_call.clone())), + Ok(tool_call_end(&tool_call.clone())), + Ok(finish(response)), ]), ScriptedStreamCall::Response(Box::new(text_response("Done"))), ])); @@ -4819,15 +4828,16 @@ mod tests { assert_eq!(replace_count, 0); assert_eq!(deltas, vec!["Recovered".to_string()]); assert_eq!(assistant_messages, vec!["Recovered".to_string()]); - // The finish-less restart is the one mid-turn path with no error to - // report; without this event it would be invisible downstream. - assert_eq!(consume_retries, vec![(0, LlmRetryPhase::Consume)]); + // A stream that ends before any visible output is reconnected by the + // client's retry middleware; the agent records that retry too, so the + // restart is not invisible downstream. + assert_eq!(consume_retries, vec![(1, LlmRetryPhase::Consume)]); } #[tokio::test] async fn stream_retries_with_output_replace_after_partial_text() { let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![Ok(StreamEvent::text_delta("Hel", None))]), + ScriptedStreamCall::Events(vec![Ok(text_delta("Hel"))]), ScriptedStreamCall::Response(Box::new(text_response("Hello"))), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4877,15 +4887,9 @@ mod tests { #[tokio::test] async fn retry_open_auth_error_emits_error_and_closes_session() { - let auth_error = LlmError::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "mock") - }), - }; + let auth_error = provider_error(ErrorKind::Authentication, "bad key"); let provider = Arc::new(ScriptedStreamProvider::new(vec![ - ScriptedStreamCall::Events(vec![Ok(StreamEvent::text_delta("Hel", None))]), + ScriptedStreamCall::Events(vec![Ok(text_delta("Hel"))]), ScriptedStreamCall::Error(auth_error.clone()), ])); let mut session = make_session_with_provider(provider.clone()).await; @@ -4893,11 +4897,8 @@ mod tests { let result = session.process_input("Hello").await; assert!(matches!( - result, - Err(Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::Authentication, - .. - })) + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::Authentication )); assert_eq!(provider.call_index.load(Ordering::SeqCst), 2); @@ -4915,11 +4916,8 @@ mod tests { AgentEvent::Error { error } => { observed.push("error".to_string()); found_auth_error_event = matches!( - error, - Error::Llm(LlmError::Provider { - kind: ProviderErrorKind::Authentication, - .. - }) + &error, + Error::Llm(error) if error.kind() == ErrorKind::Authentication ); } AgentEvent::AssistantMessage { .. } => observed.push("message".to_string()), @@ -4936,20 +4934,207 @@ mod tests { assert!(found_auth_error_event, "expected auth error event"); } + /// A tool whose executions are counted, for tests that must prove a call + /// never ran. + fn counting_tool(name: &str, executions: Arc) -> RegisteredTool { + RegisteredTool { + definition: ToolDefinition::function( + name, + format!("Counts executions of {name}"), + serde_json::json!({"type": "object"}), + ), + executor: Arc::new(move |_args, _ctx| { + let executions = Arc::clone(&executions); + Box::pin(async move { + executions.fetch_add(1, Ordering::SeqCst); + Ok("ran".to_string()) + }) + }), + source: ToolSource::Native, + } + } + + /// A provisional tool call followed by an `Incomplete` end is not a + /// completed turn: the tool must never run and the input must not + /// complete successfully. + #[tokio::test] + async fn incomplete_stream_never_executes_provisional_tool_calls() { + let executions = Arc::new(AtomicUsize::new(0)); + let tool_call = ToolCall::function("call_1", "echo", serde_json::json!({})); + let mut ended = tool_call_response("echo", "call_1", serde_json::json!({})); + ended.content = vec![ContentPart::ToolCall(tool_call.clone())]; + ended.finish_reason = FinishReason::Incomplete; + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(tool_call_start(&tool_call)), + Ok(tool_call_delta("{}")), + Ok(tool_call_end(&tool_call)), + Ok(finish(ended)), + ]), + ])); + let mut registry = ToolRegistry::new(); + registry.register(counting_tool("echo", Arc::clone(&executions))); + let client = make_client_without_retries(provider.clone() as Arc); + let profile = Arc::new(TestProfile::with_tools(registry)); + let env = Arc::new(MockSandbox::default()); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); + let mut rx = session.subscribe(); + + let result = session.process_input("Use echo").await; + + assert!( + matches!( + &result, + Err(Error::Llm(error)) if error.kind() == ErrorKind::StreamDecode + ), + "an incomplete stream must not complete the input: {result:?}" + ); + assert_eq!(executions.load(Ordering::SeqCst), 0); + assert_eq!(session.history().turns().len(), 1); + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|event| event.event) + .collect(); + assert!(!events.iter().any(|event| matches!( + event, + AgentEvent::AssistantMessage { .. } | AgentEvent::ToolCallStarted { .. } + ))); + } + + /// A failure before any visible output is the client's to retry: the + /// provider is called once per policy attempt and the agent adds nothing. + #[tokio::test] + async fn open_failure_is_retried_by_the_client_exactly_per_policy() { + let provider = Arc::new(MockErrorProvider::new(|| { + stream_error("connection refused").build() + })); + // `make_client` installs a three-attempt policy with no delay. + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let mut session = Session::new(client, profile, env, SessionOptions::default(), None); + let mut rx = session.subscribe(); + + let result = session.process_input("Hello").await; + + assert!(matches!(&result, Err(Error::Llm(_)))); + assert_eq!(provider.calls(), 3); + let mut retries = Vec::new(); + let mut errors = 0; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::LlmRetry { attempt, phase, .. } => retries.push((attempt, phase)), + AgentEvent::Error { .. } => errors += 1, + _ => {} + } + } + assert_eq!(retries, vec![ + (1, LlmRetryPhase::Open), + (2, LlmRetryPhase::Open) + ]); + assert_eq!(errors, 1); + } + + /// A failure after visible output cannot be retried by any middleware, so + /// the agent replays the turn itself, bounded by its own policy. + #[tokio::test] + async fn failure_after_visible_output_is_replayed_by_the_agent_exactly_per_policy() { + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(text_delta("partial")), + Err(stream_error("connection reset")), + ]), + ])); + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + replay_retry_policy: test_retry_policy(), + ..SessionOptions::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut rx = session.subscribe(); + + let result = session.process_input("Hello").await; + + assert!(matches!(&result, Err(Error::Llm(_)))); + // Every attempt showed output before failing, so the client's retry + // layer never fires and only the agent's three replays run. + assert_eq!(provider.call_index.load(Ordering::SeqCst), 3); + let mut retries = Vec::new(); + let mut replaces = 0; + while let Ok(event) = rx.try_recv() { + match event.event { + AgentEvent::LlmRetry { attempt, phase, .. } => retries.push((attempt, phase)), + AgentEvent::AssistantOutputReplace { .. } => replaces += 1, + _ => {} + } + } + assert_eq!(retries, vec![ + (1, LlmRetryPhase::Consume), + (2, LlmRetryPhase::Consume) + ]); + assert_eq!(replaces, 3); + } + + /// Cancelling the session while a replay waits out its backoff stops the + /// turn without another provider call. + #[tokio::test(start_paused = true)] + async fn cancellation_during_replay_backoff_makes_no_further_provider_calls() { + let provider = Arc::new(ScriptedStreamProvider::new(vec![ + ScriptedStreamCall::Events(vec![ + Ok(text_delta("partial")), + Err(stream_error("connection reset")), + ]), + ])); + let client = make_client(provider.clone() as Arc).await; + let profile = Arc::new(TestProfile::new()); + let env = Arc::new(MockSandbox::default()); + let config = SessionOptions { + replay_retry_policy: RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::from_secs(30)) + .max_delay(Duration::from_secs(30)) + .jitter(false), + ..SessionOptions::default() + }; + let mut session = Session::new(client, profile, env, config, None); + let mut events = session.subscribe(); + let cancel = session.cancel_token(); + let controller = tokio::spawn(async move { + wait_for_agent_event(&mut events, |event| { + matches!(event, AgentEvent::LlmRetry { .. }) + }) + .await; + cancel.cancel(); + }); + + let result = session.process_input("Hello").await; + controller.await.unwrap(); + + assert!(matches!( + result, + Err(Error::Interrupted(InterruptReason::Cancelled)) + )); + assert_eq!(provider.call_index.load(Ordering::SeqCst), 1); + assert_eq!(session.state(), SessionState::Closed); + } + fn response_with_usage(mut response: Response, usage: TokenCounts) -> Response { response.usage = usage; response } fn response_with_cost(mut response: Response, cost_usd: f64) -> Response { - response.cost_usd = Some(cost_usd); - response.cost_source = Some(fabro_model::CostSource::Authoritative); + response.cost = Some(Cost { + usd_micros: u64::try_from(UsdMicros::from_usd(cost_usd).0).unwrap(), + source: CostSource::Provider, + }); response } - fn response_with_input_tokens(response: Response, input_tokens: i64) -> Response { + fn response_with_input_tokens(response: Response, input: u64) -> Response { response_with_usage(response, TokenCounts { - input_tokens, + input, ..TokenCounts::default() }) } @@ -5173,48 +5358,27 @@ mod tests { responses: Vec, stream_index: AtomicUsize, complete_calls: AtomicUsize, + id: AdapterId, } #[async_trait::async_trait] impl ProviderAdapter for StreamOnlyProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { + async fn complete(&self, _call: &ResolvedCall) -> Result { self.complete_calls.fetch_add(1, Ordering::SeqCst); - Err(LlmError::Stream { - message: "summarization failed".into(), - source: None, - }) + Err(stream_error("summarization failed").build()) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream( + &self, + _call: &ResolvedCall, + ) -> Result { let idx = self.stream_index.fetch_add(1, Ordering::SeqCst); - let response = if idx < self.responses.len() { - self.responses[idx].clone() - } else { - self.responses[self.responses.len() - 1].clone() - }; - // Reuse response_to_stream helper from test_support - let mut events: Vec> = Vec::new(); - let text = response.text(); - if !text.is_empty() { - events.push(Ok(StreamEvent::text_delta(text, None))); - } - for part in &response.message.content { - if let ContentPart::ToolCall(tc) = part { - events.push(Ok(StreamEvent::ToolCallEnd { - tool_call: tc.clone(), - })); - } - } - events.push(Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - ))); - Ok(Box::pin(stream::iter(events))) + let response = self.responses[idx.min(self.responses.len() - 1)].clone(); + Ok(response_to_stream(response)) } } @@ -5231,8 +5395,11 @@ mod tests { responses, stream_index: AtomicUsize::new(0), complete_calls: AtomicUsize::new(0), + id: AdapterId::new("mock"), }); - let client = make_client(provider.clone() as Arc).await; + // The client's own retries would repeat the failed summarization; the + // agent-level suppression is what this test observes. + let client = make_client_without_retries(provider.clone() as Arc); let registry = ToolRegistry::new(); let profile = Arc::new(TestProfile::with_context_window(registry, 100)); let env = Arc::new(MockSandbox::default()); @@ -5271,7 +5438,7 @@ mod tests { #[tokio::test] async fn compaction_includes_structured_prompt_and_file_tracking() { - use fabro_llm::types::ToolDefinition; + use fabro_types::ToolDefinition; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -5281,37 +5448,38 @@ mod tests { stream_responses: Vec, stream_index: AtomicUsize, captured_complete: Mutex>, + id: AdapterId, } #[async_trait::async_trait] impl ProviderAdapter for CompactionCapturingProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, request: &Request) -> Result { - *self.captured_complete.lock().unwrap() = Some(request.clone()); + async fn complete(&self, call: &ResolvedCall) -> Result { + *self.captured_complete.lock().unwrap() = Some(call.request().clone()); Ok(text_response("## Goal\nSummary goes here.")) } - async fn stream(&self, _request: &Request) -> Result { + async fn stream( + &self, + _call: &ResolvedCall, + ) -> Result { let idx = self.stream_index.fetch_add(1, Ordering::SeqCst); - let response = if idx < self.stream_responses.len() { - self.stream_responses[idx].clone() - } else { - self.stream_responses[self.stream_responses.len() - 1].clone() - }; + let response = + self.stream_responses[idx.min(self.stream_responses.len() - 1)].clone(); Ok(response_to_stream(response)) } } // read_file tool that always succeeds let read_tool = RegisteredTool { - definition: ToolDefinition { - name: "read_file".into(), - description: "Read a file".into(), - parameters: serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), - }, + definition: ToolDefinition::function( + "read_file", + "Read a file", + serde_json::json!({"type": "object", "properties": {"file_path": {"type": "string"}}}), + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Ok("file contents".to_string()) }) }), @@ -5341,6 +5509,7 @@ mod tests { stream_responses, stream_index: AtomicUsize::new(0), captured_complete: Mutex::new(None), + id: AdapterId::new("mock"), }); let client = make_client(provider.clone() as Arc).await; @@ -5375,7 +5544,7 @@ mod tests { let request = captured .as_ref() .expect("compaction request should have been captured"); - let system_text = request.messages[0].text(); + let system_text = text_of(request.messages()[0].content()); assert!( system_text.contains("## Goal"), "Compaction system prompt should contain structured '## Goal' section" @@ -5498,8 +5667,8 @@ mod tests { if let Message::ToolResults { results, .. } = &turns[2] { assert_eq!(results[0].tool_call_id, "mcp_call_1"); assert!(!results[0].is_error); - let output = results[0].content.as_str().unwrap_or(""); - assert_eq!(output, "hello from llm"); + let output = tool_result_to_json(&results[0]); + assert_eq!(output.as_str().unwrap_or(""), "hello from llm"); } else { panic!("expected ToolResults turn"); } @@ -5539,11 +5708,11 @@ mod tests { async fn wall_clock_timeout_aborts_session() { // Register a tool that loops until the cancel token fires let slow_tool = RegisteredTool { - definition: ToolDefinition { - name: "slow_tool".into(), - description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "slow_tool", + "Waits until cancelled", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; @@ -5604,11 +5773,11 @@ mod tests { async fn make_parent_waiting_on_blocked_subagent() -> (Session, SubAgentSupervisor, String, CancellationToken) { let block_until_cancelled = RegisteredTool { - definition: ToolDefinition { - name: "block_until_cancelled".into(), - description: "Waits until cancelled".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "block_until_cancelled", + "Waits until cancelled", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, ctx| { Box::pin(async move { ctx.cancel.cancelled().await; diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index f1aff7031..98e7ef296 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_llm::types::ToolDefinition; +use fabro_types::ToolDefinition; use tokio_util::sync::CancellationToken; use crate::error::{Error, InterruptReason}; @@ -226,13 +226,12 @@ pub fn make_use_skill_tool_for_vocabulary( ), }; RegisteredTool { - definition: ToolDefinition { - name: NativeTool::UseSkill.canonical_name().into(), - description: "Load a skill's instructions by name. Call this when the user's \ - request matches an available skill." - .into(), + definition: ToolDefinition::function( + NativeTool::UseSkill.canonical_name(), + "Load a skill's instructions by name. Call this when the user's \ + request matches an available skill.", parameters, - }, + ), executor: Arc::new(move |args, ctx| { let skills = skills.clone(); Box::pin(async move { @@ -351,7 +350,7 @@ mod tests { use super::*; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; // --- parse_skill tests --- @@ -733,17 +732,17 @@ name: trimmed assert!(result.contains("only staged files"), "{result}"); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("skill") .is_some() ); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("args") .is_some() ); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("skill_name") .is_none() ); @@ -770,22 +769,22 @@ name: trimmed assert!(result.contains("only staged files"), "{result}"); assert_eq!( - tool.definition.parameters["required"], + tool.definition.parameters()["required"], serde_json::json!(["skill"]) ); - assert_eq!(tool.definition.parameters["additionalProperties"], false); + assert_eq!(tool.definition.parameters()["additionalProperties"], false); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("skill") .is_some() ); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("args") .is_some() ); assert!( - tool.definition.parameters["properties"] + tool.definition.parameters()["properties"] .get("skill_name") .is_none() ); diff --git a/lib/components/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs index 697aa8893..8aa6de9b6 100644 --- a/lib/components/fabro-agent/src/subagent.rs +++ b/lib/components/fabro-agent/src/subagent.rs @@ -3,8 +3,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; -use fabro_llm::types::ToolDefinition; -use fabro_types::INITIAL_SUBAGENT_GENERATION; +use fabro_types::{INITIAL_SUBAGENT_GENERATION, ToolDefinition}; use fabro_util::error as util_error; use futures::future; use tokio::sync::{broadcast, mpsc, oneshot, watch}; @@ -1202,10 +1201,10 @@ pub fn make_spawn_agent_tool( current_depth: usize, ) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "spawn_agent".into(), - description: "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "spawn_agent", + "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + serde_json::json!({ "type": "object", "properties": { "task": { @@ -1215,7 +1214,7 @@ pub fn make_spawn_agent_tool( }, "required": ["task"] }), - }, + ), executor: Arc::new(move |args, ctx| { let supervisor = supervisor.clone(); let session_factory = session_factory.clone(); @@ -1240,10 +1239,10 @@ pub fn make_spawn_agent_tool( pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "send_input".into(), - description: "Send a follow-up message to a subagent. A running agent receives it at a safe turn boundary. A completed agent starts another turn in the same session with its existing history.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "send_input", + "Send a follow-up message to a subagent. A running agent receives it at a safe turn boundary. A completed agent starts another turn in the same session with its existing history.", + serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -1257,7 +1256,7 @@ pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { }, "required": ["agent_id", "message"] }), - }, + ), executor: Arc::new(move |args, _ctx| { let supervisor = supervisor.clone(); Box::pin(async move { @@ -1276,10 +1275,10 @@ pub fn make_send_input_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "wait".into(), - description: "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "wait", + "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -1289,7 +1288,7 @@ pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { }, "required": ["agent_id"] }), - }, + ), executor: Arc::new(move |args, ctx| { let supervisor = supervisor.clone(); Box::pin(async move { @@ -1313,10 +1312,10 @@ pub fn make_wait_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "close_agent".into(), - description: "Close a running or completed subagent that is no longer needed.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "close_agent", + "Close a running or completed subagent that is no longer needed.", + serde_json::json!({ "type": "object", "properties": { "agent_id": { @@ -1326,7 +1325,7 @@ pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { }, "required": ["agent_id"] }), - }, + ), executor: Arc::new(move |args, _ctx| { let supervisor = supervisor.clone(); Box::pin(async move { @@ -1344,15 +1343,15 @@ pub fn make_close_agent_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { #[cfg(test)] mod tests { - use fabro_llm::provider::ProviderAdapter; - use fabro_llm::types::Role; + use fabro_llm::adapter::ProviderAdapter; + use fabro_types::{Role, text_of}; use tokio::task::yield_now; use tokio::time; use super::*; use crate::config::SessionOptions; use crate::test_support::*; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; // --- Tests --- @@ -1657,13 +1656,13 @@ mod tests { .as_ref() .expect("request should have been captured"); let system_message = request - .messages + .messages() .iter() - .find(|message| message.role == Role::System) + .find(|message| message.role() == Role::System) .expect("subagent request should include system message"); assert!( - !system_message.text().trim().is_empty(), + !text_of(system_message.content()).trim().is_empty(), "subagent system prompt should not be empty" ); } @@ -1791,21 +1790,21 @@ mod tests { let spawn_tool = make_spawn_agent_tool(manager.clone(), factory, 0); assert_eq!(spawn_tool.definition.name, "spawn_agent"); - let spawn_properties = spawn_tool.definition.parameters["properties"] + let spawn_properties = spawn_tool.definition.parameters()["properties"] .as_object() .unwrap(); assert_eq!(spawn_properties.len(), 1); assert!(spawn_properties["task"].is_object()); - let spawn_required = spawn_tool.definition.parameters["required"] + let spawn_required = spawn_tool.definition.parameters()["required"] .as_array() .unwrap(); assert!(spawn_required.contains(&serde_json::json!("task"))); let send_tool = make_send_input_tool(manager.clone()); assert_eq!(send_tool.definition.name, "send_input"); - assert!(send_tool.definition.parameters["properties"]["agent_id"].is_object()); - assert!(send_tool.definition.parameters["properties"]["message"].is_object()); - let send_required = send_tool.definition.parameters["required"] + assert!(send_tool.definition.parameters()["properties"]["agent_id"].is_object()); + assert!(send_tool.definition.parameters()["properties"]["message"].is_object()); + let send_required = send_tool.definition.parameters()["required"] .as_array() .unwrap(); assert!(send_required.contains(&serde_json::json!("agent_id"))); @@ -1813,16 +1812,16 @@ mod tests { let wait_tool = make_wait_tool(manager.clone()); assert_eq!(wait_tool.definition.name, "wait"); - assert!(wait_tool.definition.parameters["properties"]["agent_id"].is_object()); - let wait_required = wait_tool.definition.parameters["required"] + assert!(wait_tool.definition.parameters()["properties"]["agent_id"].is_object()); + let wait_required = wait_tool.definition.parameters()["required"] .as_array() .unwrap(); assert!(wait_required.contains(&serde_json::json!("agent_id"))); let close_tool = make_close_agent_tool(manager); assert_eq!(close_tool.definition.name, "close_agent"); - assert!(close_tool.definition.parameters["properties"]["agent_id"].is_object()); - let close_required = close_tool.definition.parameters["required"] + assert!(close_tool.definition.parameters()["properties"]["agent_id"].is_object()); + let close_required = close_tool.definition.parameters()["required"] .as_array() .unwrap(); assert!(close_required.contains(&serde_json::json!("agent_id"))); @@ -2075,14 +2074,15 @@ mod tests { let request = captured .as_ref() .expect("second request should be captured"); - assert!(request.messages.iter().any(|message| { - message.role == Role::User && message.text().contains("Do something") + assert!(request.messages().iter().any(|message| { + message.role() == Role::User && text_of(message.content()).contains("Do something") })); - assert!(request.messages.iter().any(|message| { - message.role == Role::Assistant && message.text().contains("captured") + assert!(request.messages().iter().any(|message| { + message.role() == Role::Assistant && text_of(message.content()).contains("captured") })); - assert!(request.messages.iter().any(|message| { - message.role == Role::User && message.text().contains("Fix the review findings") + assert!(request.messages().iter().any(|message| { + message.role() == Role::User + && text_of(message.content()).contains("Fix the review findings") })); } diff --git a/lib/components/fabro-agent/src/task_reminder.rs b/lib/components/fabro-agent/src/task_reminder.rs index 0f1c59859..51d8145c4 100644 --- a/lib/components/fabro-agent/src/task_reminder.rs +++ b/lib/components/fabro-agent/src/task_reminder.rs @@ -74,18 +74,18 @@ fn is_task_reminder(content: &str) -> bool { mod tests { use std::time::SystemTime; - use fabro_llm::types::{TokenCounts, ToolCall}; + use fabro_types::{TokenCounts, ToolCall}; use super::*; fn assistant(tool_name: Option<&str>) -> Message { let tool_calls = tool_name - .map(|name| vec![ToolCall::new("call_1", name, serde_json::json!({}))]) + .map(|name| vec![ToolCall::function("call_1", name, serde_json::json!({}))]) .unwrap_or_default(); Message::Assistant { content: String::new(), tool_calls, provider_parts: Vec::new(), - usage: Box::::default(), + usage: TokenCounts::default(), response_id: "resp".into(), timestamp: SystemTime::now(), } diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index b68c9ed8a..bb3820aab 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -1,17 +1,18 @@ -use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use fabro_llm::Error as LlmError; -use fabro_llm::client::Client; -use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; -use fabro_llm::types::{ - ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, +use fabro_llm::adapter::{ProviderAdapter, ResolvedCall}; +use fabro_llm::lithos_catalog::AdapterId; +use fabro_llm::test_support::client_with_adapters; +pub use fabro_llm::test_support::{response_to_stream, test_retry_policy}; +use fabro_llm::{ + Client, ClientOptions, Error as LlmError, FinishReason, Request, Response, ResponseStream, }; -use fabro_model::{AgentProfileKind, ProviderId}; pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; -use futures::stream; +use fabro_types::{ + AgentProfileKind, ContentPart, ModelId, ProviderId, TokenCounts, ToolCall, provider_ids, +}; use crate::agent_profile::AgentProfile; use crate::config::SessionOptions; @@ -22,6 +23,12 @@ use crate::session::Session; use crate::skills::{Skill, format_skills_prompt_section}; use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource}; +/// The provider every test profile routes to. +pub const TEST_PROVIDER: &str = provider_ids::ANTHROPIC; +/// The model every test profile requests. It is not in the catalog, so the +/// provider's passthrough route serves it. +pub const TEST_MODEL: &str = "mock-model"; + // --- TestProfile --- pub struct TestProfile { @@ -58,11 +65,11 @@ impl AgentProfile for TestProfile { } fn provider_id(&self) -> ProviderId { - ProviderId::anthropic() + provider_ids::anthropic() } fn model(&self) -> &'static str { - "mock-model" + TEST_MODEL } fn tool_registry(&self) -> &ToolRegistry { @@ -102,9 +109,11 @@ impl AgentProfile for TestProfile { // --- MockLlmProvider --- +/// Answers from a script of responses, repeating the last one. pub struct MockLlmProvider { pub responses: Vec, pub call_index: AtomicUsize, + id: AdapterId, } impl MockLlmProvider { @@ -112,94 +121,110 @@ impl MockLlmProvider { Self { responses, call_index: AtomicUsize::new(0), + id: AdapterId::new("mock"), } } + + fn next_response(&self) -> Response { + let idx = self.call_index.fetch_add(1, Ordering::SeqCst); + self.responses[idx.min(self.responses.len() - 1)].clone() + } } #[async_trait] impl ProviderAdapter for MockLlmProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - let idx = self.call_index.fetch_add(1, Ordering::SeqCst); - if idx < self.responses.len() { - Ok(self.responses[idx].clone()) - } else { - Ok(self.responses[self.responses.len() - 1].clone()) - } + async fn complete(&self, _call: &ResolvedCall) -> Result { + Ok(self.next_response()) } - async fn stream(&self, _request: &Request) -> Result { - let idx = self.call_index.fetch_add(1, Ordering::SeqCst); - let response = if idx < self.responses.len() { - self.responses[idx].clone() - } else { - self.responses[self.responses.len() - 1].clone() - }; - Ok(response_to_stream(response)) + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(response_to_stream(self.next_response())) } } -/// Convert a canned `Response` into a `StreamEventStream` for mock streaming. -pub fn response_to_stream(response: Response) -> StreamEventStream { - let mut events: Vec> = Vec::new(); - - // Emit text deltas for text content - let text = response.text(); - if !text.is_empty() { - events.push(Ok(StreamEvent::text_delta(text, None))); - } - - // Emit tool call events - for part in &response.message.content { - if let ContentPart::ToolCall(tc) = part { - events.push(Ok(StreamEvent::ToolCallEnd { - tool_call: tc.clone(), - })); - } - } - - // Emit finish - events.push(Ok(StreamEvent::finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - ))); - - Box::pin(stream::iter(events)) -} - // --- Helper functions --- -pub fn text_response(text: &str) -> Response { - Response { - id: format!("resp_{text}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } +/// A response attributed to the test route with the given content parts. +pub fn response_with_parts(id: &str, parts: Vec) -> Response { + let has_tool_calls = parts + .iter() + .any(|part| matches!(part, ContentPart::ToolCall(_))); + let mut response = Response::new( + ProviderId::new(TEST_PROVIDER), + ModelId::new(TEST_MODEL), + parts, + ); + response.id = Some(id.to_string()); + response.finish_reason = if has_tool_calls { + FinishReason::ToolCall + } else { + FinishReason::Stop + }; + response.usage = TokenCounts { + input: 10, + output: 5, + ..TokenCounts::default() + }; + response } +pub fn text_response(text: &str) -> Response { + response_with_parts(&format!("resp_{text}"), vec![ContentPart::Text { + text: text.to_string(), + }]) +} + +pub fn tool_call_response( + tool_name: &str, + tool_call_id: &str, + args: serde_json::Value, +) -> Response { + response_with_parts(&format!("resp_{tool_call_id}"), vec![ + ContentPart::Text { + text: "Let me use a tool.".to_string(), + }, + ContentPart::ToolCall(ToolCall::function(tool_call_id, tool_name, args)), + ]) +} + +pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> Response { + let mut content = vec![ContentPart::Text { + text: "Let me use multiple tools.".to_string(), + }]; + for (tool_name, tool_call_id, args) in calls { + content.push(ContentPart::ToolCall(ToolCall::function( + tool_call_id, + tool_name, + args, + ))); + } + response_with_parts("resp_multi", content) +} + +/// A client over the Fabro test catalog that routes the test provider to +/// `provider`, with client-side retries but no delay between attempts. pub async fn make_client(provider: Arc) -> Client { - let mut providers = HashMap::new(); - providers.insert(provider.name().to_string(), provider.clone()); - // Also register under "anthropic" so TestProfile (ProviderId::anthropic()) - // routes correctly - providers.insert("anthropic".to_string(), provider); - Client::new(providers, Some("mock".into()), vec![]) + make_client_with_options( + provider, + ClientOptions::default().with_retry(Some(test_retry_policy())), + ) +} + +/// A client over the Fabro test catalog with no client-side retries. Tests +/// that count provider calls made by the agent's own replay loop use this. +pub fn make_client_without_retries(provider: Arc) -> Client { + make_client_with_options(provider, ClientOptions::default()) +} + +pub fn make_client_with_options( + provider: Arc, + options: ClientOptions, +) -> Client { + client_with_adapters(vec![(TEST_PROVIDER, provider)], options) } pub async fn make_session(responses: Vec) -> Session { @@ -245,47 +270,14 @@ pub async fn make_session_with_tools_and_config( Session::new(client, profile, env, config, None) } -pub fn tool_call_response( - tool_name: &str, - tool_call_id: &str, - args: serde_json::Value, -) -> Response { - use fabro_llm::types::{ContentPart, Role, ToolCall}; - Response { - id: format!("resp_{tool_call_id}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me use a tool."), - ContentPart::ToolCall(ToolCall::new(tool_call_id, tool_name, args)), - ], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } -} - pub fn make_echo_tool() -> RegisteredTool { - use fabro_llm::types::ToolDefinition; + use fabro_types::ToolDefinition; RegisteredTool { - definition: ToolDefinition { - name: "echo".into(), - description: "Echoes the input".into(), - parameters: serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), - }, + definition: ToolDefinition::function( + "echo", + "Echoes the input", + serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}}), + ), executor: Arc::new(|args, _ctx| { Box::pin(async move { let text = args @@ -300,13 +292,13 @@ pub fn make_echo_tool() -> RegisteredTool { } pub fn make_error_tool() -> RegisteredTool { - use fabro_llm::types::ToolDefinition; + use fabro_types::ToolDefinition; RegisteredTool { - definition: ToolDefinition { - name: "fail_tool".into(), - description: "Always fails".into(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "fail_tool", + "Always fails", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| { Box::pin(async move { Err("tool execution failed".to_string()) }) }), @@ -316,22 +308,41 @@ pub fn make_error_tool() -> RegisteredTool { // --- MockErrorProvider --- +/// Fails every call with a fresh error from `factory`. pub struct MockErrorProvider { - pub error: LlmError, + factory: Box LlmError + Send + Sync>, + calls: AtomicUsize, + id: AdapterId, +} + +impl MockErrorProvider { + pub fn new(factory: impl Fn() -> LlmError + Send + Sync + 'static) -> Self { + Self { + factory: Box::new(factory), + calls: AtomicUsize::new(0), + id: AdapterId::new("mock"), + } + } + + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } } #[async_trait] impl ProviderAdapter for MockErrorProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, _request: &Request) -> Result { - Err(self.error.clone()) + async fn complete(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) } - async fn stream(&self, _request: &Request) -> Result { - Err(self.error.clone()) + async fn stream(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) } } @@ -340,69 +351,37 @@ impl ProviderAdapter for MockErrorProvider { /// A mock LLM provider that captures the full Request for test assertions. pub struct CapturingLlmProvider { pub captured_request: Mutex>, + id: AdapterId, } impl CapturingLlmProvider { pub fn new() -> Self { Self { captured_request: Mutex::new(None), + id: AdapterId::new("mock"), } } } #[async_trait] impl ProviderAdapter for CapturingLlmProvider { - fn name(&self) -> &'static str { - "mock" + fn id(&self) -> &AdapterId { + &self.id } - async fn complete(&self, request: &Request) -> Result { + async fn complete(&self, call: &ResolvedCall) -> Result { *self .captured_request .lock() - .expect("captured_request lock poisoned") = Some(request.clone()); + .expect("captured_request lock poisoned") = Some(call.request().clone()); Ok(text_response("captured")) } - async fn stream(&self, request: &Request) -> Result { + async fn stream(&self, call: &ResolvedCall) -> Result { *self .captured_request .lock() - .expect("captured_request lock poisoned") = Some(request.clone()); + .expect("captured_request lock poisoned") = Some(call.request().clone()); Ok(response_to_stream(text_response("captured"))) } } - -pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) -> Response { - use fabro_llm::types::{ContentPart, Role, ToolCall}; - let mut content = vec![ContentPart::text("Let me use multiple tools.")]; - for (tool_name, tool_call_id, args) in calls { - content.push(ContentPart::ToolCall(ToolCall::new( - tool_call_id, - tool_name, - args, - ))); - } - Response { - id: "resp_multi".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content, - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } -} diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index 2eb136ed8..f30d94aa8 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -12,8 +12,7 @@ use std::fmt::Write; use std::str::FromStr; use std::sync::Arc; -use fabro_llm::types::ToolDefinition; -use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps}; +use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps, ToolDefinition}; use serde_json::Value; use strum::{EnumString, IntoStaticStr}; @@ -151,12 +150,11 @@ fn reconcile_replacement_list( #[must_use] pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "update_plan".into(), - description: "Update the multi-step plan for the current task. Submit the entire \ - plan; existing steps are reconciled by exact step text." - .into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "update_plan", + "Update the multi-step plan for the current task. Submit the entire \ + plan; existing steps are reconciled by exact step text.", + serde_json::json!({ "type": "object", "properties": { "explanation": { @@ -181,7 +179,7 @@ pub fn make_update_plan_tool(runtime: Arc) -> RegisteredTool { }, "required": ["plan"] }), - }, + ), executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { @@ -298,16 +296,15 @@ fn render_kimi_todos<'a>(items: impl IntoIterator) /// same [`TodoRuntime`] backs it, so projections and events are unchanged. pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "TodoList".into(), - description: "Maintain a structured TODO list for the current task. Use it \ + definition: ToolDefinition::function( + "TodoList", + "Maintain a structured TODO list for the current task. Use it \ proactively for multi-step work. Pass `todos` to replace the entire \ list, omit `todos` to read the current list without changing it, and \ pass an empty array to clear it. Keep exactly one item `in_progress` \ while work is underway, and mark an item `done` as soon as it is \ - finished rather than batching completions at the end." - .into(), - parameters: serde_json::json!({ + finished rather than batching completions at the end.", + serde_json::json!({ "type": "object", "properties": { "todos": { @@ -332,7 +329,7 @@ pub fn make_todo_list_tool(runtime: Arc) -> RegisteredTool { } } }), - }, + ), executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { @@ -449,10 +446,10 @@ fn format_task_details(todo: &TodoProjection) -> String { #[must_use] pub fn make_task_create_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "TaskCreate".into(), - description: TASK_CREATE_DESCRIPTION.into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "TaskCreate", + TASK_CREATE_DESCRIPTION, + serde_json::json!({ "type": "object", "properties": { "subject": {"type": "string"}, @@ -462,7 +459,7 @@ pub fn make_task_create_tool(runtime: Arc) -> RegisteredTool { }, "required": ["subject", "description"] }), - }, + ), executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { @@ -498,10 +495,10 @@ pub fn make_task_create_tool(runtime: Arc) -> RegisteredTool { #[must_use] pub fn make_task_update_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "TaskUpdate".into(), - description: TASK_UPDATE_DESCRIPTION.into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "TaskUpdate", + TASK_UPDATE_DESCRIPTION, + serde_json::json!({ "type": "object", "properties": { "taskId": {"type": "string"}, @@ -519,7 +516,7 @@ pub fn make_task_update_tool(runtime: Arc) -> RegisteredTool { }, "required": ["taskId"] }), - }, + ), executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { @@ -567,17 +564,17 @@ pub fn make_task_update_tool(runtime: Arc) -> RegisteredTool { #[must_use] pub fn make_task_get_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "TaskGet".into(), - description: TASK_GET_DESCRIPTION.into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "TaskGet", + TASK_GET_DESCRIPTION, + serde_json::json!({ "type": "object", "properties": { "taskId": {"type": "string"} }, "required": ["taskId"] }), - }, + ), executor: Arc::new(move |args, ctx| { let runtime = runtime.clone(); Box::pin(async move { @@ -604,15 +601,15 @@ pub fn make_task_get_tool(runtime: Arc) -> RegisteredTool { #[must_use] pub fn make_task_list_tool(runtime: Arc) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "TaskList".into(), - description: TASK_LIST_DESCRIPTION.into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "TaskList", + TASK_LIST_DESCRIPTION, + serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false }), - }, + ), executor: Arc::new(move |_args, ctx| { let runtime = runtime.clone(); Box::pin(async move { diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index cc2943aaf..b444bde38 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use std::sync::Arc; -use fabro_llm::types::{ToolCall, ToolResult}; +use fabro_types::{ToolCall, ToolInput, ToolResult, tool_call_arguments, tool_result_from_json}; use futures::future; use tokio_util::sync::CancellationToken; use tracing::debug; @@ -108,7 +108,7 @@ async fn execute_tool_calls_sequential( let mut results = Vec::new(); for tc in tool_calls { if cancel_token.is_cancelled() { - results.push(ToolResult::error(tc.id.clone(), "Cancelled")); + results.push(error_result(&tc.id, "Cancelled")); continue; } @@ -218,7 +218,7 @@ async fn execute_question_tool_round( for (index, tc) in tool_calls.iter().enumerate() { if cancel_token.is_cancelled() { - results.push(ToolResult::error(tc.id.clone(), "Cancelled")); + results.push(error_result(&tc.id, "Cancelled")); continue; } @@ -281,7 +281,7 @@ fn finish_error_result( config: &SessionOptions, message: &str, ) -> ToolResult { - let retained = retain_tool_result(ToolResult::error(&tc.id, message), None); + let retained = retain_tool_result(error_result(&tc.id, message), None); emit_tool_call_result( emitter, session_id, @@ -292,11 +292,30 @@ fn finish_error_result( truncate_tool_result(&retained.result, &tc.name, config) } +/// A tool result carrying one error message. +fn error_result(tool_call_id: &str, message: impl Into) -> ToolResult { + tool_result_from_json( + tool_call_id, + serde_json::Value::String(message.into()), + true, + ) +} + +/// A successful tool result carrying one output value. +fn success_result(tool_call_id: &str, output: serde_json::Value) -> ToolResult { + tool_result_from_json(tool_call_id, output, false) +} + +/// The single JSON value a tool result carries: a string for text output. +fn result_output(result: &ToolResult) -> serde_json::Value { + fabro_types::tool_result_to_json(result) +} + fn emit_tool_call_started(emitter: &Emitter, session_id: &str, tc: &ToolCall) { emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted { tool_name: tc.name.clone(), tool_call_id: tc.id.clone(), - arguments: tc.arguments.clone(), + arguments: tool_call_arguments(tc), }); } @@ -307,17 +326,18 @@ fn emit_tool_call_result( result: &ToolResult, output_stats: OutputCaptureStats, ) { + let output = result_output(result); emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta { - delta: result.content.to_string(), + delta: output.to_string(), }); emitter.emit(session_id.to_owned(), AgentEvent::ToolCallCompleted { - tool_name: tc.name.clone(), - tool_call_id: tc.id.clone(), - output: result.content.clone(), - is_error: result.is_error, + tool_name: tc.name.clone(), + tool_call_id: tc.id.clone(), + output, + is_error: result.is_error, output_bytes_observed: output_stats.observed_bytes, output_bytes_retained: output_stats.retained_bytes, - output_bytes_omitted: output_stats.omitted_bytes, + output_bytes_omitted: output_stats.omitted_bytes, }); } @@ -424,7 +444,7 @@ async fn execute_and_emit_one_tool_with_lookup( if let Some(hooks) = tool_hooks { debug!(tool = %tc.name, hook_event = "pre_tool_use", "Calling tool hook"); let start = std::time::Instant::now(); - let decision = hooks.pre_tool_use(&tc.name, &tc.arguments).await; + let decision = hooks.pre_tool_use(&tc.name, &tool_call_arguments(tc)).await; let elapsed = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); debug!(tool = %tc.name, hook_event = "pre_tool_use", ?decision, duration_ms = elapsed, "Tool hook complete"); @@ -452,11 +472,12 @@ async fn execute_and_emit_one_tool_with_lookup( // Post-tool-use hooks if let Some(hooks) = tool_hooks { + let output = result_output(&result); let fallback; - let content_str = if let Some(s) = result.content.as_str() { + let content_str = if let Some(s) = output.as_str() { s } else { - fallback = result.content.to_string(); + fallback = output.to_string(); &fallback }; if result.is_error { @@ -485,8 +506,8 @@ fn retain_tool_result( mut result: ToolResult, previous_stats: Option, ) -> RetainedToolResult { - let output_stats = match &mut result.content { - serde_json::Value::String(output) => { + let output_stats = match result.content.as_mut_slice() { + [fabro_types::ContentPart::Text { text: output }] => { let previously_omitted = previous_stats.map_or(0, |stats| stats.omitted_bytes); let previewed = preview_tool_output(output, MAX_RETAINED_TOOL_OUTPUT_BYTES, previously_omitted); @@ -496,7 +517,7 @@ fn retain_tool_result( } stats } - other => OutputCaptureStats::complete(serialized_json_bytes(other)), + _ => OutputCaptureStats::complete(serialized_json_bytes(&result_output(&result))), }; RetainedToolResult { @@ -528,14 +549,31 @@ async fn execute_one_tool( ) -> ExecutedToolResult { match registered_tool { Some(tool) => { - if tc.tool_type != "custom" { - if let Err(validation_error) = - validate_tool_args(&tool.definition.parameters, &tc.arguments) + let arguments = match &tc.input { + ToolInput::Function(arguments) => match arguments.json() { + Ok(value) => value.clone(), + Err(err) => { + return ExecutedToolResult { + result: error_result( + &tc.id, + format!("Tool arguments are not valid JSON: {err}"), + ), + output_stats: None, + }; + } + }, + _ => tool_call_arguments(tc), + }; + if matches!(tc.input, ToolInput::Function(_)) { + if let fabro_types::ToolDefinitionKind::Function { input_schema } = + &tool.definition.kind { - return ExecutedToolResult { - result: ToolResult::error(&tc.id, validation_error), - output_stats: None, - }; + if let Err(validation_error) = validate_tool_args(input_schema, &arguments) { + return ExecutedToolResult { + result: error_result(&tc.id, validation_error), + output_stats: None, + }; + } } } @@ -555,15 +593,15 @@ async fn execute_one_tool( tool_call_id: Some(tc.id.clone()), agent_event_emitter, }; - let execution = (tool.executor)(tc.arguments.clone(), ctx); + let execution = (tool.executor)(arguments, ctx); let result = match question_tools::scope_agent_tool_runtime( agent_tool_runtime.clone(), execution, ) .await { - Ok(output) => ToolResult::success(&tc.id, serde_json::json!(output)), - Err(err) => ToolResult::error(&tc.id, err), + Ok(output) => success_result(&tc.id, serde_json::Value::String(output)), + Err(err) => error_result(&tc.id, err), }; ExecutedToolResult { result, @@ -571,7 +609,7 @@ async fn execute_one_tool( } } None => ExecutedToolResult { - result: ToolResult::error(&tc.id, format!("Unknown tool: {}", tc.name)), + result: error_result(&tc.id, format!("Unknown tool: {}", tc.name)), output_stats: None, }, } @@ -583,19 +621,18 @@ fn truncate_tool_result( tool_name: &str, config: &SessionOptions, ) -> ToolResult { - let truncated_content = match &result.content { - serde_json::Value::String(s) => { - serde_json::json!(truncate_tool_output(s, tool_name, config)) - } - other => other.clone(), + let content = match result.content.as_slice() { + [fabro_types::ContentPart::Text { text }] => vec![fabro_types::ContentPart::Text { + text: truncate_tool_output(text, tool_name, config), + }], + other => other.to_vec(), }; ToolResult { - tool_call_id: result.tool_call_id.clone(), - content: truncated_content, - is_error: result.is_error, - image_data: result.image_data.clone(), - image_media_type: result.image_media_type.clone(), + tool_call_id: result.tool_call_id.clone(), + name: result.name.clone(), + content, + is_error: result.is_error, } } @@ -634,9 +671,8 @@ mod tests { use std::sync::{Arc, Mutex}; use async_trait::async_trait; - use fabro_llm::types::{ToolCall, ToolDefinition}; - use fabro_model::AgentProfileKind; use fabro_types::run_event::{AgentToolCompletedProps, MAX_RUN_EVENT_BODY_BYTES}; + use fabro_types::{AgentProfileKind, ToolCall, ToolDefinition, tool_result_to_json}; use tokio::sync::broadcast; use super::*; @@ -681,17 +717,17 @@ mod tests { fn make_echo_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "echo".to_string(), - description: "Echo input".to_string(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "echo", + "Echo input", + serde_json::json!({ "type": "object", "properties": { "text": {"type": "string"} }, "required": ["text"] }), - }, + ), executor: Arc::new(|args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { let text = args["text"].as_str().unwrap_or("").to_string(); @@ -704,11 +740,11 @@ mod tests { fn make_fail_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "fail_tool".to_string(), - description: "Always fails".to_string(), - parameters: serde_json::json!({}), - }, + definition: ToolDefinition::function( + "fail_tool", + "Always fails", + serde_json::json!({}), + ), executor: Arc::new(|_args: serde_json::Value, _ctx: ToolContext| { Box::pin(async move { Err("tool failed".to_string()) }) }), @@ -717,14 +753,7 @@ mod tests { } fn make_tool_call(name: &str, id: &str, args: serde_json::Value) -> ToolCall { - ToolCall { - id: id.to_string(), - name: name.to_string(), - tool_type: "function".to_string(), - arguments: args, - raw_arguments: None, - provider_metadata: None, - } + ToolCall::function(id, name, args) } struct StubQuestionRuntime; @@ -793,8 +822,7 @@ mod tests { assert_eq!(results[1].tool_call_id, "call_echo"); assert!(results[1].is_error); assert!( - results[1] - .content + tool_result_to_json(&results[1]) .as_str() .unwrap() .contains("human-question tools must run alone") @@ -838,8 +866,7 @@ mod tests { assert!(!results[0].is_error); assert!(results[1].is_error); assert!( - results[1] - .content + tool_result_to_json(&results[1]) .as_str() .unwrap() .contains("Combine all questions into a single questions[] batch") @@ -922,8 +949,8 @@ mod tests { .await; assert!(result.is_error); - let content = result.content.as_str().unwrap(); - assert!(content.contains("blocked by hook")); + let content = tool_result_to_json(&result); + assert!(content.as_str().unwrap().contains("blocked by hook")); } #[tokio::test] @@ -953,7 +980,7 @@ mod tests { .await; assert!(!result.is_error); - let content = result.content.to_string(); + let content = tool_result_to_json(&result).to_string(); assert!(content.contains("echo: hello")); } @@ -980,7 +1007,8 @@ mod tests { ) .await; - let result_output = result.content.as_str().expect("string tool output"); + let result_output = tool_result_to_json(&result); + let result_output = result_output.as_str().expect("string tool output"); assert!(result_output.len() <= MAX_RETAINED_TOOL_OUTPUT_BYTES); assert!(result_output.starts_with("Warning: truncated output")); assert!(result_output.contains("bytes omitted")); @@ -1210,7 +1238,7 @@ mod tests { .await; assert!(!result.is_error); - let content = result.content.to_string(); + let content = tool_result_to_json(&result).to_string(); assert!(content.contains("echo: hello")); } @@ -1220,11 +1248,11 @@ mod tests { let mut registry = ToolRegistry::new(); let executions_for_tool = Arc::clone(&executions); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "write_file".to_string(), - description: "Writes a file".to_string(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "write_file", + "Writes a file", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| { let executions = Arc::clone(&executions_for_tool); Box::pin(async move { @@ -1260,8 +1288,7 @@ mod tests { assert!(result.is_error); assert!( - result - .content + tool_result_to_json(&result) .as_str() .unwrap_or_default() .contains("denied by tool access policy") @@ -1275,11 +1302,11 @@ mod tests { let mut registry = ToolRegistry::new(); let executions_for_tool = Arc::clone(&executions); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "shell".to_string(), - description: "Runs a command".to_string(), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + "shell", + "Runs a command", + serde_json::json!({"type": "object"}), + ), executor: Arc::new(move |_args: serde_json::Value, _ctx: ToolContext| { let executions = Arc::clone(&executions_for_tool); Box::pin(async move { @@ -1315,8 +1342,7 @@ mod tests { assert!(result.is_error); assert!( - result - .content + tool_result_to_json(&result) .as_str() .unwrap_or_default() .contains("requires approval") @@ -1394,9 +1420,12 @@ mod tests { assert!(result.is_error); assert!( - result.content.as_str().unwrap().contains("Exit code: 7"), + tool_result_to_json(&result) + .as_str() + .unwrap() + .contains("Exit code: 7"), "got: {}", - result.content + tool_result_to_json(&result) ); } @@ -1554,12 +1583,16 @@ mod tests { #[test] fn truncation_preserves_tool_call_id_and_error_state() { - let result = ToolResult::error("call_1", "x".repeat(60_000)); + let result = tool_result_from_json( + "call_1", + serde_json::Value::String("x".repeat(60_000)), + true, + ); let truncated = truncate_tool_result(&result, "shell", &SessionOptions::default()); assert_eq!(truncated.tool_call_id, "call_1"); assert!(truncated.is_error); - assert!(truncated.content.as_str().unwrap().len() < 60_000); + assert!(tool_result_to_json(&truncated).as_str().unwrap().len() < 60_000); } } diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index 7e955aed0..c8b02e267 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -3,8 +3,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use fabro_llm::types::ToolDefinition; -use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary}; +use fabro_types::{AgentToolCategory, AgentToolSource, AgentToolSummary, ToolDefinition}; use tokio_util::sync::CancellationToken; use crate::config::{ToolAccessPolicy, ToolExposureMode}; @@ -66,6 +65,37 @@ impl ToolContext { } } +/// Schema accessors over the lithos tool definition. +/// +/// lithos keeps the schema inside [`ToolDefinitionKind`] so a custom tool can +/// never leak a JSON Schema onto the wire. Fabro's tool code reads the +/// function schema often enough to want a direct accessor. +pub trait ToolDefinitionExt { + /// The JSON Schema of a function tool. Panics for a custom tool, which + /// has no schema; Fabro registers custom tools only where the codec + /// accepts them. + fn parameters(&self) -> &serde_json::Value; + + /// The provider-specific format of a custom tool. + fn custom_format(&self) -> Option<&serde_json::Value>; +} + +impl ToolDefinitionExt for ToolDefinition { + fn parameters(&self) -> &serde_json::Value { + match &self.kind { + fabro_types::ToolDefinitionKind::Function { input_schema } => input_schema, + _ => panic!("custom tool '{}' has no parameter schema", self.name), + } + } + + fn custom_format(&self) -> Option<&serde_json::Value> { + match &self.kind { + fabro_types::ToolDefinitionKind::Custom { format } => Some(format), + _ => None, + } + } +} + pub type ToolExecutor = Arc< dyn Fn( serde_json::Value, @@ -306,11 +336,11 @@ mod tests { fn make_tool(name: &str) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: name.into(), - description: format!("Tool {name}"), - parameters: serde_json::json!({"type": "object"}), - }, + definition: ToolDefinition::function( + name, + format!("Tool {name}"), + serde_json::json!({"type": "object"}), + ), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("ok".into()) })), source: ToolSource::Native, } @@ -391,20 +421,12 @@ mod tests { fn name_collision_overrides() { let mut registry = ToolRegistry::new(); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "tool_a".into(), - description: "version 1".into(), - parameters: serde_json::json!({}), - }, + definition: ToolDefinition::function("tool_a", "version 1", serde_json::json!({})), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v1".into()) })), source: ToolSource::Native, }); registry.register(RegisteredTool { - definition: ToolDefinition { - name: "tool_a".into(), - description: "version 2".into(), - parameters: serde_json::json!({}), - }, + definition: ToolDefinition::function("tool_a", "version 2", serde_json::json!({})), executor: Arc::new(|_args, _ctx| Box::pin(async { Ok("v2".into()) })), source: ToolSource::Native, }); @@ -530,14 +552,14 @@ mod tests { fn tool_with_source(name: &str, source: ToolSource) -> ToolDefinitionWithSource { ToolDefinitionWithSource { - definition: ToolDefinition { - name: name.to_string(), - description: format!("{name} description"), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + name.to_string(), + format!("{name} description"), + serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } } }), - }, + ), source, } } diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 24ad2c80f..7e284954a 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -2,11 +2,10 @@ use std::borrow::Cow; use std::fmt::Write; use std::sync::Arc; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, ToolDefinition}; -use fabro_model::ModelHandle; +use fabro_llm::{Client, Request}; #[cfg(test)] use fabro_static::EnvVars; +use fabro_types::{ModelHandle, ToolDefinition}; use futures::{StreamExt, stream}; use tokio::task; @@ -115,10 +114,10 @@ pub(crate) fn optional_usize_arg( #[must_use] pub fn make_read_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "read_file".into(), - description: "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "read_file", + "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -127,13 +126,12 @@ pub fn make_read_file_tool() -> RegisteredTool { }, "required": ["file_path"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; let offset_usize = optional_usize_arg(&args, "offset")?; - let limit_usize = - optional_usize_arg(&args, "limit")?.or(Some(DEFAULT_READ_LINES)); + let limit_usize = optional_usize_arg(&args, "limit")?.or(Some(DEFAULT_READ_LINES)); let content = ctx .env @@ -150,10 +148,10 @@ pub fn make_read_file_tool() -> RegisteredTool { #[must_use] pub fn make_write_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "write_file".into(), - description: "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "write_file", + "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -161,7 +159,7 @@ pub fn make_write_file_tool() -> RegisteredTool { }, "required": ["file_path", "content"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; @@ -181,10 +179,10 @@ pub fn make_write_file_tool() -> RegisteredTool { #[must_use] pub fn make_edit_file_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "edit_file".into(), - description: "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "edit_file", + "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + serde_json::json!({ "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute path to the file"}, @@ -194,7 +192,7 @@ pub fn make_edit_file_tool() -> RegisteredTool { }, "required": ["file_path", "old_string", "new_string"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let file_path = required_str(&args, "file_path")?; @@ -248,10 +246,10 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo let default_timeout = options.default_command_timeout_ms; let max_timeout = options.max_command_timeout_ms; RegisteredTool { - definition: ToolDefinition { - name: "shell".into(), - description: "Execute Bash commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "shell", + "Execute Bash commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + serde_json::json!({ "type": "object", "properties": { "command": {"type": "string", "description": "Bash source to evaluate, run by a non-login Bash shell"}, @@ -260,7 +258,7 @@ pub fn make_shell_tool_with_options(options: &NativeToolOptions) -> RegisteredTo }, "required": ["command"] }), - }, + ), executor: Arc::new(move |args, ctx| { Box::pin(async move { let command = required_str(&args, "command")?; @@ -412,10 +410,10 @@ fn render_shell_result(streaming: &ExecStreamingResult) -> String { #[must_use] pub fn make_grep_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "grep".into(), - description: "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "grep", + "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Regex pattern to search for"}, @@ -426,7 +424,7 @@ pub fn make_grep_tool() -> RegisteredTool { }, "required": ["pattern"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; @@ -501,10 +499,10 @@ pub(crate) fn grep_result_path<'a>(line: &'a str, searched: &'a str) -> &'a str #[must_use] pub fn make_glob_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "glob".into(), - description: "Find files by search-root-relative path using a glob pattern. Use path to choose the search root. `*` stays within one path segment and `**` searches recursively. Prefer this over shell find or ls when locating repository files.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "glob", + "Find files by search-root-relative path using a glob pattern. Use path to choose the search root. `*` stays within one path segment and `**` searches recursively. Prefer this over shell find or ls when locating repository files.", + serde_json::json!({ "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern relative to the search root"}, @@ -512,7 +510,7 @@ pub fn make_glob_tool() -> RegisteredTool { }, "required": ["pattern"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let pattern = required_str(&args, "pattern")?; @@ -533,10 +531,10 @@ pub fn make_glob_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_read_many_files_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "read_many_files".into(), - description: "Read multiple files at once".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "read_many_files", + "Read multiple files at once", + serde_json::json!({ "type": "object", "properties": { "paths": { @@ -547,7 +545,7 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { }, "required": ["paths"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let paths: Vec = args["paths"] @@ -594,10 +592,10 @@ pub(crate) fn make_read_many_files_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_list_dir_tool() -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "list_dir".into(), - description: "List directory contents with depth control".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "list_dir", + "List directory contents with depth control", + serde_json::json!({ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list"}, @@ -605,7 +603,7 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { }, "required": ["path"] }), - }, + ), executor: Arc::new(|args, ctx| { Box::pin(async move { let path = required_str(&args, "path")?; @@ -636,10 +634,10 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { #[must_use] pub(crate) fn make_web_fetch_tool(summarizer: Option) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: "web_fetch".into(), - description: "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + "web_fetch", + "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + serde_json::json!({ "type": "object", "properties": { "url": {"type": "string", "description": "URL to fetch (must be http:// or https://)"}, @@ -648,8 +646,8 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg }, "required": ["url"] }), - }, - executor: Arc::new(move |args, ctx| { + ), + executor: Arc::new(move |args, ctx| { let summarizer = summarizer.clone(); Box::pin(async move { let url = required_str(&args, "url")?; @@ -702,36 +700,30 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg let summarization_prompt = format!( "Content from {url}:\n---\n{content}\n---\n\n{user_prompt}\n\nRespond concisely based only on the content above." ); - let request = Request { - model: s.model_id.model_id().to_string(), - messages: vec![Message::user(summarization_prompt)], - provider: Some(s.model_id.provider().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - let response = s.client.complete(&request).await.map_err(|e| { - format!("web_fetch summarization (model={}) failed: {e}", s.model_id.model_id()) + let request = Request::builder() + .model(s.model_id.to_string()) + .user(summarization_prompt) + .build() + .map_err(|e| format!("web_fetch summarization request invalid: {e}"))?; + let response = s.client.complete(request).await.map_err(|e| { + format!( + "web_fetch summarization (model={}) failed: {e}", + s.model_id.model() + ) })?; Ok(response.text()) } (Some(_), None) => { // Graceful degradation: return content with a note - Ok(format!("[Note: prompt summarization unavailable, returning full content]\n\n{content}")) + Ok(format!( + "[Note: prompt summarization unavailable, returning full content]\n\n{content}" + )) } (None, _) => Ok(content), } }) }), - source: ToolSource::Native, + source: ToolSource::Native, } } @@ -739,9 +731,8 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option) -> Reg mod tests { use std::collections::HashMap; - use fabro_llm::provider::ProviderAdapter; - use fabro_model::ProviderId; - use fabro_types::CommandTermination; + use fabro_llm::adapter::ProviderAdapter; + use fabro_types::{CommandTermination, ModelId, provider_ids}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -751,7 +742,7 @@ mod tests { use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; use crate::truncation; use crate::types::SessionEvent; use crate::web_search::make_web_search_tool_with_api_key; @@ -818,7 +809,7 @@ mod tests { assert_eq!(tool.definition.name, "shell"); assert_eq!( - tool.definition.parameters, + *tool.definition.parameters(), serde_json::json!({ "type": "object", "properties": { @@ -1987,10 +1978,7 @@ mod tests { let client = make_client(provider).await; let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "mock-model".to_string(), - }, + model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("mock-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); @@ -2066,40 +2054,28 @@ mod tests { #[tokio::test] async fn web_fetch_summarizer_routes_to_specified_provider() { - use fabro_llm::Error as LlmError; - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; + use fabro_llm::test_support::client_with_adapters; + use fabro_llm::{ClientOptions, ErrorKind}; use crate::test_support::{MockErrorProvider, MockLlmProvider, text_response}; - // "other_provider" is the default — it rejects all requests. - let default_provider: Arc = Arc::new(MockErrorProvider { - error: LlmError::Provider { - kind: ProviderErrorKind::NotFound, - detail: Box::new(ProviderErrorDetail::new( - "model not found", - "other_provider", - )), - }, - }); - // "anthropic" provider has the model we actually want. + // OpenAI rejects all requests, so a summary can only come from the + // provider the summarizer names. + let default_provider: Arc = Arc::new(MockErrorProvider::new(|| { + fabro_llm::Error::new(ErrorKind::NotFound, "model not found") + })); let target_provider: Arc = Arc::new(MockLlmProvider::new(vec![text_response( "summarized content", )])); - - let mut providers = HashMap::new(); - providers.insert("other_provider".to_string(), default_provider); - // Register under "anthropic" so ModelRef { provider: "anthropic", .. } routes - // here - providers.insert("anthropic".to_string(), target_provider); - let client = Client::new(providers, Some("other_provider".into()), vec![]); + let client = client_with_adapters( + vec![("openai", default_provider), ("anthropic", target_provider)], + ClientOptions::default(), + ); let summarizer = WebFetchSummarizer { client, - model_id: ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "target-model".to_string(), - }, + model_id: ModelHandle::new(provider_ids::anthropic(), ModelId::new("target-model")), }; let tool = make_web_fetch_tool(Some(summarizer)); diff --git a/lib/components/fabro-agent/src/truncation.rs b/lib/components/fabro-agent/src/truncation.rs index 719cda287..f3c0bfc4e 100644 --- a/lib/components/fabro-agent/src/truncation.rs +++ b/lib/components/fabro-agent/src/truncation.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use fabro_llm::token_count; +use fabro_llm::estimate; use fabro_types::run_event::MAX_RUN_EVENT_BODY_BYTES; use serde::Serialize; @@ -181,8 +181,8 @@ fn render_truncated_segments( stats: OutputCaptureStats, line_count_omitted: Option, ) -> String { - let original_tokens = token_count::estimate_byte_tokens(stats.observed_bytes); - let omitted_tokens = token_count::estimate_byte_tokens(stats.omitted_bytes); + let original_tokens = estimate::byte_tokens(stats.observed_bytes); + let omitted_tokens = estimate::byte_tokens(stats.omitted_bytes); let middle_marker = line_count_omitted.map_or_else( || format!("... approximately {omitted_tokens} tokens truncated ..."), |lines| { diff --git a/lib/components/fabro-agent/src/types.rs b/lib/components/fabro-agent/src/types.rs index eba037472..897ff6410 100644 --- a/lib/components/fabro-agent/src/types.rs +++ b/lib/components/fabro-agent/src/types.rs @@ -1,14 +1,11 @@ use std::time::SystemTime; use chrono::{DateTime, Utc}; -use fabro_llm::Error as LlmError; -use fabro_llm::types::{ - ContentPart, Message as LlmMessage, Role, ThinkingData, TokenCounts, ToolCall, ToolResult, -}; -use fabro_model::{CostSource, ModelRef}; +use fabro_llm::LlmError; use fabro_types::{ - CommandTermination, ExecOutputTail, LlmOutputKind, LlmRetryPhase, ReasoningOutput, - SessionMessage, StageContextWindowProjection, + CommandTermination, ContentPart, Cost, ExecOutputTail, LlmOutputKind, LlmRetryPhase, + Message as LlmMessage, ModelRef, ReasoningOutput, Role, SessionMessage, + StageContextWindowProjection, TokenCounts, ToolCall, ToolResult, controls, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -52,9 +49,9 @@ pub enum Message { /// Provider-specific content parts (e.g. `OpenAI` reasoning items, /// `Anthropic` thinking blocks with signatures) preserved for /// round-tripping. Reasoning/thinking text is stored here as - /// `ContentPart::Thinking`. + /// `ContentPart::Reasoning`. provider_parts: Vec, - usage: Box, + usage: TokenCounts, response_id: String, timestamp: SystemTime, }, @@ -86,11 +83,9 @@ impl Message { return None; }; provider_parts.iter().find_map(|p| match p { - ContentPart::Thinking(ThinkingData { - text, - redacted: false, - .. - }) => Some(text.as_str()), + ContentPart::Reasoning(reasoning) if !reasoning.redacted => { + Some(reasoning.text.as_str()) + } _ => None, }) } @@ -101,7 +96,9 @@ impl Message { #[must_use] pub fn to_llm_message(&self) -> LlmMessage { match self { - Self::User { content, .. } => LlmMessage::user(content), + Self::User { content, .. } | Self::Steering { content, .. } => { + LlmMessage::text(Role::User, content) + } Self::Assistant { content, tool_calls, @@ -114,39 +111,28 @@ impl Message { // function calls for correct round-tripping. parts.extend(provider_parts.iter().cloned()); if !content.is_empty() { - parts.push(ContentPart::text(content)); + parts.push(ContentPart::Text { + text: content.clone(), + }); } for tc in tool_calls { parts.push(ContentPart::ToolCall(tc.clone())); } - LlmMessage { - role: Role::Assistant, - content: parts, - name: None, - tool_call_id: None, - } + LlmMessage::new(Role::Assistant, parts) } Self::ToolResults { results, .. } => { let content: Vec = results .iter() .map(|r| ContentPart::ToolResult(r.clone())) .collect(); + let message = LlmMessage::new(Role::Tool, content); // Use the first result's tool_call_id if available - let tool_call_id = results.first().map(|r| r.tool_call_id.clone()); - LlmMessage { - role: Role::Tool, - content, - name: None, - tool_call_id, + match results.first() { + Some(first) => message.with_tool_call_id(first.tool_call_id.clone()), + None => message, } } - Self::System { content, .. } => LlmMessage::system(content), - Self::Steering { content, .. } => LlmMessage { - role: Role::User, - content: vec![ContentPart::text(content)], - name: None, - tool_call_id: None, - }, + Self::System { content, .. } => LlmMessage::text(Role::System, content), } } @@ -168,7 +154,7 @@ impl Message { content: content.clone(), tool_calls: values_or_empty(tool_calls), provider_parts: values_or_empty(provider_parts), - usage: value_or_null(&**usage), + usage: value_or_null(usage), response_id: response_id.clone(), timestamp: system_time_to_utc(*timestamp), }, @@ -204,7 +190,7 @@ impl Message { content: content.clone(), tool_calls: values_from_json(tool_calls)?, provider_parts: values_from_json(provider_parts)?, - usage: Box::new(serde_json::from_value(usage.clone())?), + usage: serde_json::from_value(usage.clone())?, response_id: response_id.clone(), timestamp: utc_to_system_time(*timestamp), }, @@ -318,12 +304,10 @@ pub enum AgentEvent { text: String, model: ModelRef, usage: TokenCounts, - /// USD cost reported or estimated for this individual response. + /// Cost reported or estimated for this individual response, with its + /// provenance. #[serde(default, skip_serializing_if = "Option::is_none")] - cost_usd: Option, - /// Provenance of `cost_usd`. - #[serde(default, skip_serializing_if = "Option::is_none")] - cost_source: Option, + cost: Option, tool_call_count: usize, #[serde(default, skip_serializing_if = "Option::is_none")] context_window: Option, @@ -527,7 +511,7 @@ impl AgentEvent { session_id, provider = %requested_model.provider, model = %requested_model.model_id, - speed = requested_model.speed.map_or("", <&'static str>::from), + speed = requested_model.speed.map_or("", controls::speed_name), "LLM request started" ); } @@ -544,8 +528,8 @@ impl AgentEvent { session_id, provider = %model.provider, model = model.model_id.as_str(), - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, + input_tokens = usage.input, + output_tokens = usage.output, tool_call_count, "Assistant message" ); @@ -832,10 +816,18 @@ pub struct SessionEvent { #[cfg(test)] mod tests { - use fabro_model::ProviderId; + use fabro_llm::{ErrorFacts, ErrorKind, RetryClassification}; + use fabro_types::{CostSource, ModelId, ProviderId, provider_ids}; use super::*; + fn network_error(message: &str) -> LlmError { + LlmError::from( + fabro_llm::Error::new(ErrorKind::Network, message) + .with_retry(RetryClassification::Safe), + ) + } + #[test] fn session_event_construction() { let event = SessionEvent { @@ -1102,40 +1094,37 @@ mod tests { #[test] fn agent_event_assistant_message() { let usage = TokenCounts { - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 80, - cache_write_tokens: 10, - reasoning_tokens: 20, + input: 100, + output: 50, + cache_read: 80, + cache_write: 10, + reasoning: 20, }; let event = AgentEvent::AssistantMessage { - text: "Hello".into(), - model: ModelRef { - provider: ProviderId::openai(), - model_id: "test-model".into(), - speed: None, - }, - usage: usage.clone(), - cost_usd: Some(0.125), - cost_source: Some(CostSource::Authoritative), + text: "Hello".into(), + model: ModelRef::new(provider_ids::openai(), ModelId::new("test-model")), + usage, + cost: Some(Cost { + usd_micros: 125_000, + source: CostSource::Provider, + }), tool_call_count: 2, - context_window: None, - reasoning: None, + context_window: None, + reasoning: None, }; match &event { AgentEvent::AssistantMessage { usage, - cost_usd, - cost_source, + cost, tool_call_count, .. } => { assert_eq!(*tool_call_count, 2); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.cache_read_tokens, 80); - assert_eq!(usage.reasoning_tokens, 20); - assert_eq!(*cost_usd, Some(0.125)); - assert_eq!(*cost_source, Some(CostSource::Authoritative)); + assert_eq!(usage.input, 100); + assert_eq!(usage.cache_read, 80); + assert_eq!(usage.reasoning, 20); + assert_eq!(cost.map(|cost| cost.usd_micros), Some(125_000)); + assert_eq!(cost.map(|cost| cost.source), Some(CostSource::Provider)); } _ => panic!("expected AssistantMessage"), } @@ -1163,10 +1152,7 @@ mod tests { #[test] fn error_event_serde_roundtrip_with_agent_error() { let event = AgentEvent::Error { - error: Error::Llm(LlmError::Network { - message: "refused".into(), - source: None, - }), + error: Error::Llm(network_error("refused")), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); @@ -1180,31 +1166,27 @@ mod tests { #[test] fn llm_retry_event_carries_sdk_error() { - use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind}; let event = AgentEvent::LlmRetry { provider: "openai".into(), model: "gpt-4".into(), attempt: 1, delay_secs: 2.0, phase: LlmRetryPhase::Open, - error: LlmError::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - message: "too fast".into(), - provider: "openai".into(), - status_code: Some(429), - error_code: None, - retry_after: Some(2.0), - raw: None, - }), - }, + error: LlmError::from( + fabro_llm::Error::new(ErrorKind::RateLimit, "too fast") + .with_provider(ProviderId::new("openai")) + .with_status(429) + .with_retry(RetryClassification::after(std::time::Duration::from_secs( + 2, + ))), + ), }; let json = serde_json::to_string(&event).unwrap(); let deserialized: AgentEvent = serde_json::from_str(&json).unwrap(); match deserialized { AgentEvent::LlmRetry { error, .. } => { - assert!(error.retryable()); - assert_eq!(error.retry_after(), Some(2.0)); + assert!(error.is_retryable()); + assert_eq!(error.retry_after(), Some(std::time::Duration::from_secs(2))); } _ => panic!("expected LlmRetry variant"), } diff --git a/lib/components/fabro-agent/src/web_search.rs b/lib/components/fabro-agent/src/web_search.rs index 08c23863a..6c9fdf151 100644 --- a/lib/components/fabro-agent/src/web_search.rs +++ b/lib/components/fabro-agent/src/web_search.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use std::sync::OnceLock; use std::time::Duration; -use fabro_llm::types::ToolDefinition; +use fabro_types::ToolDefinition; use crate::config::ToolSecrets; use crate::tool_registry::{RegisteredTool, ToolSource}; @@ -269,10 +269,10 @@ fn max_results_arg(args: &serde_json::Value) -> u64 { #[must_use] pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { RegisteredTool { - definition: ToolDefinition { - name: WEB_SEARCH_TOOL_NAME.into(), - description: "Search the web when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.".into(), - parameters: serde_json::json!({ + definition: ToolDefinition::function( + WEB_SEARCH_TOOL_NAME, + "Search the web when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + serde_json::json!({ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, @@ -280,7 +280,7 @@ pub(crate) fn make_web_search_tool(backend: SearchBackend) -> RegisteredTool { }, "required": ["query"] }), - }, + ), executor: std::sync::Arc::new(move |args, _ctx| { let backend = backend.clone(); Box::pin(async move { @@ -310,7 +310,7 @@ mod tests { use crate::config::ToolSecrets; use crate::sandbox::Sandbox; use crate::test_support::MockSandbox; - use crate::tool_registry::ToolContext; + use crate::tool_registry::{ToolContext, ToolDefinitionExt}; fn secrets(brave: Option<&str>, venice: Option<&str>) -> ToolSecrets { ToolSecrets { @@ -403,7 +403,10 @@ mod tests { fn brave_and_venice_use_the_same_tool_schema() { let brave = make_web_search_tool(SearchBackend::brave("key".into())); let venice = make_web_search_tool(SearchBackend::venice("key".into())); - assert_eq!(brave.definition.parameters, venice.definition.parameters); + assert_eq!( + brave.definition.parameters(), + venice.definition.parameters() + ); } #[tokio::test] diff --git a/lib/components/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs index 14cb88d5e..64e46b68c 100644 --- a/lib/components/fabro-agent/tests/it/compaction.rs +++ b/lib/components/fabro-agent/tests/it/compaction.rs @@ -1,12 +1,9 @@ -use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use fabro_agent::{AgentProfile, LocalSandbox, OpenAiProfile, Session, SessionOptions}; -use fabro_llm::client::Client; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiAdapter; -use fabro_model::ProviderId; +use fabro_llm::test_support::client_from_env; +use fabro_llm::{Client, ClientOptions}; use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; use tokio::fs::read_to_string; @@ -23,7 +20,7 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { load_compaction_scenarios(&api_key).await; - let mut session = make_openai_session(tmp.path(), base_url, api_key); + let mut session = make_openai_session(tmp.path(), base_url, api_key).await; session.initialize().await.unwrap(); let result = session @@ -44,12 +41,8 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { ); } -fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { - let adapter: Arc = - Arc::new(OpenAiAdapter::new(api_key).with_base_url(base_url)); - let mut providers = HashMap::new(); - providers.insert(ProviderId::OPENAI.to_string(), adapter); - let client = Client::new(providers, Some(ProviderId::OPENAI.to_string()), Vec::new()); +async fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { + let client = openai_client(base_url, api_key).await; let profile: Arc = Arc::new(OpenAiProfile::new(MODEL)); let sandbox = Arc::new(LocalSandbox::new(cwd.to_path_buf())); let options = SessionOptions { @@ -96,3 +89,18 @@ async fn load_compaction_scenarios(namespace: &str) { .load(twin_openai().await) .await; } + +/// A client whose `openai` provider points at `base_url` and authenticates +/// with `api_key`, the way the twin expects. +async fn openai_client(base_url: String, api_key: String) -> Client { + let catalog = fabro_llm::build_catalog(&fabro_config::LlmLayer::default(), &move |name| { + (name == fabro_static::EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("catalog should build"); + client_from_env( + catalog, + move |name| (name == fabro_static::EnvVars::OPENAI_API_KEY).then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await +} diff --git a/lib/components/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs index 0e6136473..4f8c41de3 100644 --- a/lib/components/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -1,23 +1,28 @@ use std::sync::Arc; use fabro_agent::{AgentProfile, AgentProfileBuilder}; -use fabro_model::Catalog; +use fabro_llm::catalog; +use fabro_llm::test_support::test_catalog; #[test] fn profile_context_window_matches_catalog_for_default_models() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - for provider in catalog.providers() { - let catalog_info = catalog - .default_for_provider(&provider.id) - .cloned() - .unwrap_or_else(|| panic!("no default model for {:?} in catalog", provider.id)); - let model = &catalog_info.id; - let context_window = usize::try_from(catalog_info.context_window()) - .expect("catalog context window should be non-negative and fit in usize"); + let catalog = Arc::new(test_catalog()); + for provider in catalog::listed_providers(&catalog) { + let provider_id = provider.provider.id().clone(); + let Some(default) = catalog::default_model(&catalog, provider_id.as_str()) else { + // Deployment-defined providers (LiteLLM, Modal, Ollama) carry no + // built-in default model. + continue; + }; + let model = default.model.id().clone(); + let context_window = default.model.limits().map_or_else( + || panic!("no limits for {provider_id}/{model} in catalog"), + |limits| usize::try_from(limits.context_tokens).expect("context fits usize"), + ); let profile: Box = AgentProfileBuilder::new( - provider.agent_profile, - provider.id.clone(), + default.agent_profile(), + provider_id.clone(), model.as_str(), Arc::clone(&catalog), ) @@ -26,9 +31,7 @@ fn profile_context_window_matches_catalog_for_default_models() { assert_eq!( profile.context_window_size(), context_window, - "context_window_size mismatch for {:?} model '{}': profile={} catalog={}", - provider.id, - model, + "context_window_size mismatch for {provider_id} model '{model}': profile={} catalog={}", profile.context_window_size(), context_window ); diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 3f8f9e55e..249577234 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -3,7 +3,6 @@ reason = "agent parity test harness: sync std::fs for staging fixture trees and reading captured outputs" )] -use std::collections::HashMap; use std::fmt::Write as _; use std::path::Path; use std::sync::Arc; @@ -14,12 +13,12 @@ use fabro_agent::{ SessionOptions, SubAgentSupervisor, ToolSecrets, WebFetchSummarizer, }; use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::{OpenAiAdapter, OpenAiCompatibleAdapter}; -use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{Catalog, ModelHandle, ProviderId}; +use fabro_config::LlmLayer; +use fabro_llm::lithos_catalog::Catalog; +use fabro_llm::test_support::client_from_env; +use fabro_llm::{Client, ClientOptions, catalog}; use fabro_test::{EnvVars, TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; +use fabro_types::{ModelHandle, ModelId, ProviderId, provider_ids}; type Provider = ProviderId; @@ -30,21 +29,15 @@ struct OpenAiTwinOptions { } fn summarizer_model_id(provider: &Provider) -> ModelHandle { - match provider.as_str() { - ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName { - provider: ProviderId::openai(), - model: "gpt-5.4-mini".to_string(), - }, - ProviderId::GEMINI => ModelHandle::ByName { - provider: ProviderId::gemini(), - model: "gemini-3-flash-preview".to_string(), - }, - ProviderId::ANTHROPIC => ModelHandle::ByName { - provider: ProviderId::anthropic(), - model: "claude-haiku-4-5".to_string(), - }, + let (provider, model) = match provider.as_str() { + provider_ids::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => { + (provider_ids::openai(), "gpt-5.4-mini") + } + provider_ids::GEMINI => (provider_ids::gemini(), "gemini-3-flash-preview"), + provider_ids::ANTHROPIC => (provider_ids::anthropic(), "claude-haiku-4.5"), other => panic!("unexpected provider {other}"), - } + }; + ModelHandle::new(provider, ModelId::new(model)) } fn build_summarizer(provider: &Provider, client: &Client) -> WebFetchSummarizer { @@ -61,11 +54,10 @@ fn profile_builder( tool_secrets: ToolSecrets, ) -> AgentProfileBuilder { let summarizer = Some(build_summarizer(provider, client)); - let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); + let catalog = Arc::new(live_catalog()); // Ask the catalog rather than keeping a provider->profile list in the test, // so adding a provider to the catalog cannot silently skip this matrix. - let profile_kind = catalog - .effective_agent_profile(provider, Some(model)) + let profile_kind = catalog::agent_profile(&catalog, provider.as_str(), Some(model)) .unwrap_or_else(|| panic!("no agent profile for provider {provider:?} in catalog")); AgentProfileBuilder::new(profile_kind, provider.clone(), model, Arc::clone(&catalog)) .with_web_fetch_summarizer(summarizer) @@ -127,61 +119,81 @@ async fn make_session_with_config( Session::new(client, profile, env, config, None) } +/// The catalog live tests run against: built-ins plus Fabro policy, with the +/// `openai` provider repointed at `OPENAI_BASE_URL` when the environment sets +/// it. +#[expect( + clippy::disallowed_methods, + reason = "live parity tests read provider endpoints from the process environment" +)] +fn live_catalog() -> Catalog { + fabro_llm::build_catalog(&LlmLayer::default(), &|name| std::env::var(name).ok()) + .expect("default catalog should build") +} + +/// A catalog whose `openai` provider is served by the twin at `base_url`. +fn twin_catalog(base_url: &str, overlay: &str) -> Catalog { + let base_url = base_url.to_string(); + let overlay = LlmLayer(toml::from_str(overlay).expect("overlay should parse")); + fabro_llm::build_catalog(&overlay, &move |name| { + (name == EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("twin catalog should build") +} + async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> Client { - if provider == &ProviderId::openai() && fabro_test::TestMode::from_env().is_twin() { - return make_twin_client(twin.expect("openai twin config should be provided")); + if provider == &provider_ids::openai() && fabro_test::TestMode::from_env().is_twin() { + return make_twin_client(twin.expect("openai twin config should be provided")).await; } - let source = EnvCredentialSource::new(); - let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); - Client::from_source(&source, catalog) + let source = Arc::new(EnvCredentialSource::new()); + fabro_llm::build_client(live_catalog(), source, ClientOptions::standard()) .await - .expect("Client::from_source failed") + .expect("LLM client should build") + .client } -fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { - let adapter: Arc = - Arc::new(OpenAiAdapter::new(twin.api_key.clone()).with_base_url(twin.base_url.clone())); - let mut providers: HashMap> = HashMap::new(); - providers.insert("openai".to_string(), adapter); - Client::new(providers, Some("openai".to_string()), Vec::new()) +async fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { + let api_key = twin.api_key.clone(); + client_from_env( + twin_catalog(&twin.base_url, ""), + move |name| (name == EnvVars::OPENAI_API_KEY).then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await } -fn make_openai_compatible_twin_client(provider: &Provider, twin: &OpenAiTwinOptions) -> Client { - let provider_name = provider.to_string(); - let adapter: Arc = Arc::new( - OpenAiCompatibleAdapter::new(twin.api_key.clone(), twin.base_url.clone()) - .with_name(provider_name.clone()), - ); - let mut providers: HashMap> = HashMap::new(); - providers.insert(provider_name.clone(), adapter); - Client::new(providers, Some(provider_name), Vec::new()) +/// LiteLLM is opt-in in the built-in catalog and has no fixed endpoint. Enable +/// it and point it at the twin's Chat Completions endpoint so the profile +/// resolves the OpenAI-compatible codec the twin speaks. +fn litellm_twin_overlay(base_url: &str) -> String { + format!( + "[providers.litellm]\nbase_url = {}\n\n[providers.litellm.metadata.fabro]\nenabled = true\n", + toml::Value::String(base_url.to_string()) + ) } -fn make_openai_compatible_twin_session( +async fn make_openai_compatible_twin_client(catalog: Catalog, twin: &OpenAiTwinOptions) -> Client { + let api_key = twin.api_key.clone(); + client_from_env( + catalog, + move |name| (name == "LITELLM_API_KEY").then(|| api_key.clone()), + ClientOptions::standard(), + ) + .await +} + +async fn make_openai_compatible_twin_session( provider: Provider, model: &str, cwd: &Path, config: SessionOptions, twin: &OpenAiTwinOptions, ) -> Session { - let client = make_openai_compatible_twin_client(&provider, twin); - // LiteLLM is opt-in in the built-in catalog. Enable the provider in this - // twin fixture so the profile can resolve the same OpenAI-compatible - // codec that the manually registered adapter uses. - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - enabled: Some(true), - ..ProviderCatalogSettings::default() - }); - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .expect("OpenAI-compatible twin catalog should build"), - ); + let catalog = twin_catalog(&twin.base_url, &litellm_twin_overlay(&twin.base_url)); + let client = make_openai_compatible_twin_client(catalog.clone(), twin).await; let profile: Arc = - Arc::new(OpenAiProfile::new(model).with_route(provider, catalog)); + Arc::new(OpenAiProfile::new(model).with_route(provider, Arc::new(catalog))); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); Session::new(client, profile, env, config, None) } @@ -249,7 +261,7 @@ macro_rules! openai_twin_provider_test { .await; } let mut session = make_session( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", tmp.path(), ToolSecrets::default(), @@ -266,14 +278,14 @@ macro_rules! provider_tests { ($scenario:ident) => { provider_test!( $scenario, - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -342,7 +354,8 @@ async fn openai_compatible_twin_uses_json_edit_file_tool() { tmp.path(), SessionOptions::default(), &twin, - ); + ) + .await; session.initialize().await.unwrap(); let mut rx = session.subscribe(); @@ -393,21 +406,21 @@ provider_tests!(subagent_spawn); provider_test!( web_fetch, - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( web_fetch, - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY"] ); provider_test!( web_fetch, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -444,19 +457,19 @@ provider_test!( ); web_search_provider_test!( - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); web_search_provider_test!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -505,14 +518,14 @@ macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( $scenario, - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] @@ -772,7 +785,7 @@ macro_rules! reasoning_effort_tests { async fn $test_name() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); let config = SessionOptions { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::Low), + reasoning_effort: Some(fabro_types::ReasoningEffort::Low), ..SessionOptions::default() }; let mut session = @@ -787,15 +800,15 @@ macro_rules! reasoning_effort_tests { } reasoning_effort_tests!( - ProviderId::anthropic(), - "claude-haiku-4-5", + provider_ids::anthropic(), + "claude-haiku-4.5", anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] ); // gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI // test. reasoning_effort_tests!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini_reasoning_effort, keys = ["GEMINI_API_KEY"] @@ -865,19 +878,19 @@ macro_rules! loop_detection_tests { } loop_detection_tests!( - ProviderId::anthropic(), + provider_ids::anthropic(), "claude-haiku-4-5", anthropic_loop_detection, keys = ["ANTHROPIC_API_KEY"] ); loop_detection_tests!( - ProviderId::openai(), + provider_ids::openai(), "gpt-5.4-mini", openai_loop_detection, keys = ["OPENAI_API_KEY"] ); loop_detection_tests!( - ProviderId::gemini(), + provider_ids::gemini(), "gemini-3-flash-preview", gemini_loop_detection, keys = ["GEMINI_API_KEY"] diff --git a/lib/components/fabro-llm/Cargo.toml b/lib/components/fabro-llm/Cargo.toml index d7bcb8df7..546fd1f1e 100644 --- a/lib/components/fabro-llm/Cargo.toml +++ b/lib/components/fabro-llm/Cargo.toml @@ -4,56 +4,46 @@ edition.workspace = true version.workspace = true publish = false license.workspace = true -description = "A unified client library for multiple LLM providers" -repository = "https://github.com/brynary/arc" -readme = "README.md" -keywords = ["llm", "ai", "openai", "anthropic"] -categories = ["api-bindings"] +description = "Fabro's integration layer over the lithos-llm catalog and client" [lib] doctest = false +[features] +default = [] +test-support = ["fabro-auth/test-support"] + [lints] workspace = true [dependencies] anyhow.workspace = true -thiserror.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -strum.workspace = true -tokio.workspace = true -uuid.workspace = true -rand.workspace = true -futures.workspace = true -tokio-stream.workspace = true async-trait.workspace = true base64.workspace = true bytes.workspace = true -tokio-util.workspace = true -tracing.workspace = true -aws-config.workspace = true -aws-credential-types.workspace = true -aws-sigv4.workspace = true -aws-smithy-eventstream.workspace = true -aws-smithy-runtime-api.workspace = true -aws-smithy-types.workspace = true -fabro-http.workspace = true fabro-auth = { path = "../../foundation/fabro-auth" } -fabro-model = { path = "../../foundation/fabro-model" } +fabro-config = { path = "../../foundation/fabro-config" } +fabro-http.workspace = true fabro-redact.workspace = true fabro-static.workspace = true fabro-types = { path = "../../foundation/fabro-types" } -fabro-util = { path = "../../foundation/fabro-util" } +futures.workspace = true +lithos-llm = { workspace = true, features = ["builtin-catalog", "openai", "anthropic", "gemini", "openai-compatible", "bedrock", "bedrock-aws"] } +mime_guess = "2" +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +toml.workspace = true +tracing.workspace = true [dev-dependencies] -http = "1" -insta = { workspace = true } -tokio = { workspace = true, features = ["test-util", "macros"] } -httpmock = "0.8" -serde_json.workspace = true -toml.workspace = true +fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] } +fabro-llm = { path = ".", features = ["test-support"] } fabro-macros = { path = "../../foundation/fabro-macros" } fabro-test = { workspace = true } -tracing-subscriber.workspace = true +httpmock = "0.8" +tempfile = "3" +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/components/fabro-llm/README.md b/lib/components/fabro-llm/README.md deleted file mode 100644 index aa992daaa..000000000 --- a/lib/components/fabro-llm/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# fabro-llm - -A unified async Rust client library for multiple LLM providers. Write your LLM integration code once and switch between Anthropic, OpenAI, and Google Gemini without changing your application logic. - -## Key concepts - -- **Client** -- Routes requests to registered provider adapters. Build it from a `CredentialSource` or explicit typed credentials. -- **ProviderAdapter** -- The trait every provider implements (`complete` and `stream`). Built-in adapters: `AnthropicAdapter`, `OpenAiAdapter`, `GeminiAdapter`, `OpenAiCompatibleAdapter`. -- **Middleware** -- Intercepts requests/responses for logging, caching, or transformation. Supports both blocking and streaming paths. -- **generate()** -- High-level function that wraps `Client.complete()` with automatic tool execution loops, retries, timeouts, and cancellation. -- **Tool** -- Active tools (with an execute handler) run automatically in the tool loop. Passive tools (no handler) surface tool calls back to the caller. -- **Model catalog** -- Built-in metadata for common models. Advisory only; unknown model strings pass through. - -## Providers - -| Provider | Adapter | API | Env var | -|----------|---------|-----|---------| -| Anthropic | `AnthropicAdapter` | Messages API | `ANTHROPIC_API_KEY` | -| OpenAI | `OpenAiAdapter` | Responses API | `OPENAI_API_KEY` | -| Google Gemini | `GeminiAdapter` | generateContent | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | -| OpenAI-compatible | `OpenAiCompatibleAdapter` | Chat Completions | (custom) | - -All adapters support streaming, tool calling, structured output (`response_format`), and provider-specific options via `provider_options`. - -## Usage - -### Create from an environment-backed credential source - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; - -let source = EnvCredentialSource::new(); -let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let client = Client::from_source(&source, Arc::clone(&catalog)).await?; - -let request = Request { - model: "claude-sonnet-4-5".to_string(), - messages: vec![Message::user("What is the capital of France?")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(100), - stop_sequences: None, - reasoning_effort: None, - metadata: None, - provider_options: None, -}; - -let response = client.complete(&request).await?; -println!("{}", response.text()); -``` - -### High-level generate() - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate, GenerateParams}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; - -let source = EnvCredentialSource::new(); -let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let client = Client::from_source(&source, Arc::clone(&catalog)).await?; -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("Explain monads in one sentence") - .system("You are a concise programming tutor.") - .max_tokens(200) -).await?; - -println!("{}", result.text()); -``` - -### Tool calling - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::generate::{generate, GenerateParams}; -use fabro_llm::tools::Tool; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use std::sync::Arc; - -let source = EnvCredentialSource::new(); -let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let client = Client::from_source(&source, Arc::clone(&catalog)).await?; -let weather_tool = Tool::active( - "get_weather", - "Get the current weather for a city", - serde_json::json!({ - "type": "object", - "properties": { - "city": {"type": "string", "description": "City name"} - }, - "required": ["city"] - }), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(serde_json::json!({"temp": "72F", "city": city})) - }, -); - -let result = generate( - GenerateParams::new("claude-sonnet-4-5", client.clone()) - .prompt("What's the weather in San Francisco?") - .tools(vec![weather_tool]) - .max_tool_rounds(3) -).await?; -``` - -### Streaming - -```rust -use fabro_auth::EnvCredentialSource; -use fabro_llm::client::Client; -use fabro_llm::types::{Message, Request, StreamEvent}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::Catalog; -use futures::StreamExt; -use std::sync::Arc; - -let source = EnvCredentialSource::new(); -let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?); -let client = Client::from_source(&source, Arc::clone(&catalog)).await?; -let request = Request { - model: "claude-sonnet-4-5".to_string(), - messages: vec![Message::user("Tell me a joke")], - // ...other fields set to None/defaults - # provider: None, tools: None, tool_choice: None, - # response_format: None, temperature: None, top_p: None, - # max_tokens: None, stop_sequences: None, reasoning_effort: None, - # metadata: None, provider_options: None, -}; - -let mut stream = client.stream(&request).await?; -while let Some(event) = stream.next().await { - match event? { - StreamEvent::TextDelta { delta, .. } => print!("{delta}"), - StreamEvent::Finish { response, .. } => { - println!("\nTokens used: {}", response.usage.total_tokens); - } - _ => {} - } -} -``` - -### Middleware - -```rust -use fabro_llm::error::Error; -use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; -use fabro_llm::provider::StreamEventStream; -use fabro_llm::types::{Request, Response}; - -struct LoggingMiddleware; - -#[async_trait::async_trait] -impl Middleware for LoggingMiddleware { - async fn handle_complete( - &self, - request: Request, - next: NextFn, - ) -> Result { - eprintln!("Request to model: {}", request.model); - let response = next(request).await?; - eprintln!("Response tokens: {}", response.usage.total_tokens); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } -} -``` - -### OpenAI-compatible providers - -```rust -use fabro_llm::providers::OpenAiCompatibleAdapter; -use std::sync::Arc; - -let adapter = OpenAiCompatibleAdapter::new("your-api-key", "https://api.groq.com/openai/v1") - .with_name("groq"); -``` - -### Model catalog - -```rust -use fabro_llm::catalog::{get_latest_model, get_model_info, list_models}; - -let info = get_model_info("claude-opus-4-6"); -let anthropic_models = list_models(Some("anthropic")); -let best_reasoner = get_latest_model("anthropic", Some("reasoning")); -``` - -### Input token counting - -Use `count_input_tokens` when you need the current model-visible context size -without creating a completion: - -```rust -use fabro_llm::{InputTokenCountPreference, Client}; - -let count = client - .count_input_tokens(&request, InputTokenCountPreference::PreferProvider) - .await?; -``` - -`InputTokenCountPreference` controls precision and data exposure: - -- `PreferProvider` sends the provider-serialized request to the upstream - token-count endpoint when supported, then falls back to a local estimate only - for unsupported adapters, network/timeout failures, rate limits, and provider - server errors. -- `RequireProvider` sends the provider-serialized request and returns either a - provider count or an error. It never returns a local estimate. -- `EstimateOnly` validates and resolves the provider locally, does not call the - adapter count endpoint, and returns a deterministic local estimate. - -Provider-native counting sends model-visible request content to the provider's -token-count endpoint. That can include messages, system/developer instructions, -tools, schemas, structured content, and media metadata/content after provider -serialization. Use `EstimateOnly` when that extra upstream exposure is not -acceptable. - -`InputTokenCount` is for input/context sizing. It is not billing usage and does -not include output, reasoning-output, cache-read, or cache-write token buckets. - -## Key types - -| Type | Description | -|------|-------------| -| `Request` | Unified request with model, messages, tools, temperature, etc. | -| `Response` | Unified response with message, finish reason, usage, rate limit info | -| `Message` | A message with role, content parts, and optional tool call ID | -| `ContentPart` | Text, Image, Audio, Document, ToolCall, ToolResult, Thinking | -| `StreamEvent` | Events for streaming: TextDelta, ToolCallStart/Delta/End, Finish, etc. | -| `SdkError` | Typed errors with retryability, status codes, and provider error kinds | -| `GenerateParams` | Builder for the high-level `generate()` function | -| `GenerateResult` | Result containing response, tool results, total usage, and step history | -| `ToolDefinition` | Tool name, description, and JSON Schema parameters | -| `ToolChoice` | Auto, None, Required, or Named tool selection | -| `InputTokenCount` | Input/context token count from a provider count API or local estimate | -| `TokenCounts` | Billing-oriented token counts including input, output, reasoning, and cache tokens | -| `RetryPolicy` | Configurable retry with exponential backoff, jitter, and max delay | -| `Model` | Metadata about a model (context window, capabilities, costs) | - -## Error handling - -`SdkError` provides structured error variants with built-in retryability classification: - -- **Retryable**: `RateLimit`, `Server`, `Network`, `Stream`, `RequestTimeout` -- **Non-retryable**: `Authentication`, `AccessDenied`, `InvalidRequest`, `ContextLength`, `Configuration` - -The `retry()` function and `generate()` respect `Retry-After` headers and use exponential backoff with jitter. - -## Provider-specific options - -Pass provider-specific parameters via `provider_options` without losing portability: - -```rust -use fabro_llm::types::Request; - -let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000}, - "auto_cache": true - }, - "openai": { - "store": true, - "previous_response_id": "resp_abc123" - }, - "gemini": { - "safetySettings": [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"} - ] - } - })), - // ...other fields - # model: String::new(), messages: vec![], provider: None, tools: None, - # tool_choice: None, response_format: None, temperature: None, - # top_p: None, max_tokens: None, stop_sequences: None, - # reasoning_effort: None, metadata: None, -}; -``` diff --git a/lib/components/fabro-llm/src/adapter_registry.rs b/lib/components/fabro-llm/src/adapter_registry.rs deleted file mode 100644 index 60ad8ec42..000000000 --- a/lib/components/fabro-llm/src/adapter_registry.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Adapter factory registry keyed by [`fabro_model::AdapterKind`]. -//! -//! Every adapter kind ships with a matching factory in this module. Tests in -//! this file enforce that the registry covers every adapter kind. -//! -//! Factories take a pre-built [`AdapterConfig`] derived from resolved -//! credentials + provider settings, and produce a boxed -//! [`ProviderAdapter`] ready to register with the [`crate::Client`]. -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_auth::ApiKeyHeader; -use fabro_model::{ - AdapterKind, AgentProfileKind, BillingPolicy, Catalog, CodecKind, Model, ProviderId, -}; - -use crate::error::Error; -use crate::provider::ProviderAdapter; -use crate::providers; - -/// Configuration passed to an adapter factory. All values are pre-resolved -/// from settings + credentials; factories never touch the environment or the -/// vault directly. -#[derive(Debug, Clone)] -pub struct AdapterConfig { - /// Provider ID this adapter will register under (used as the registry - /// name on the resulting adapter). - pub provider_id: String, - /// Authentication header constructed by `fabro-auth` from the provider's - /// catalog auth policy and resolved credential. - pub auth_header: Option, - /// Provider base URL. Native adapters can use their direct-constructor - /// defaults when this is `None`; OpenAI-compatible providers require it. - pub base_url: Option, - /// Extra HTTP headers attached to every outgoing request. - pub extra_headers: HashMap, - /// Adapter-kind-specific options; factories for other kinds ignore - /// options that are not theirs. - pub kind_options: AdapterKindOptions, - pub catalog: Option>, -} - -/// Construction options that only apply to one adapter kind, kept out of the -/// shared [`AdapterConfig`] fields. -#[derive(Debug, Clone, Default)] -pub enum AdapterKindOptions { - /// No kind-specific options. - #[default] - None, - OpenAi(OpenAiAdapterOptions), -} - -/// OpenAI-only construction options. -#[derive(Debug, Clone, Default)] -pub struct OpenAiAdapterOptions { - /// Route through the ChatGPT Codex backend. - pub codex_mode: bool, - /// Organization ID. - pub org_id: Option, - /// Project ID. - pub project_id: Option, -} - -impl AdapterConfig { - /// Construct a minimal config with just provider ID and auth header. - pub fn new(provider_id: impl Into, auth_header: ApiKeyHeader) -> Self { - Self { - provider_id: provider_id.into(), - auth_header: Some(auth_header), - base_url: None, - extra_headers: HashMap::new(), - kind_options: AdapterKindOptions::None, - catalog: None, - } - } -} - -/// Factory function signature. Takes a fully-resolved [`AdapterConfig`] and -/// returns a registered-ready [`ProviderAdapter`]. -/// -/// Adapter constructors validate provider-specific construction requirements -/// before a provider is registered with the client. -pub type AdapterFactory = fn(AdapterConfig) -> Result, Error>; - -fn apply_primary_auth_header( - auth_header: Option, - extra_headers: &mut HashMap, -) -> Option { - match auth_header { - Some(ApiKeyHeader::Bearer(value)) => Some(value), - Some(ApiKeyHeader::Custom { name, value }) => { - extra_headers.insert(name, value); - None - } - // SigV4 is not a static header; only the Bedrock adapter consumes - // the marker (it signs at request time). - Some(ApiKeyHeader::AwsSigv4) | None => None, - } -} - -fn build_anthropic_adapter(mut config: AdapterConfig) -> providers::AnthropicAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = providers::AnthropicAdapter::new_optional_auth(api_key) - .with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_anthropic(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_anthropic_adapter(config))) -} - -fn build_openai_adapter(mut config: AdapterConfig) -> providers::OpenAiAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let options = match config.kind_options { - AdapterKindOptions::OpenAi(options) => options, - AdapterKindOptions::None => OpenAiAdapterOptions::default(), - }; - let mut adapter = - providers::OpenAiAdapter::new_optional_auth(api_key).with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if options.codex_mode { - adapter = adapter.with_codex_mode(); - } - if let Some(org_id) = options.org_id { - adapter = adapter.with_org_id(org_id); - } - if let Some(project_id) = options.project_id { - adapter = adapter.with_project_id(project_id); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_openai(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_openai_adapter(config))) -} - -fn build_gemini_adapter(mut config: AdapterConfig) -> providers::GeminiAdapter { - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = - providers::GeminiAdapter::new_optional_auth(api_key).with_name(config.provider_id.clone()); - if let Some(base_url) = config.base_url { - adapter = adapter.with_base_url(base_url); - } - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - adapter -} - -#[expect( - clippy::unnecessary_wraps, - reason = "Adapter factories share a fallible signature; openai_compatible validates base_url." -)] -fn build_gemini(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_gemini_adapter(config))) -} - -fn build_openai_compatible_adapter( - mut config: AdapterConfig, -) -> Result { - let base_url = config.base_url.ok_or_else(|| Error::Configuration { - message: format!( - "provider '{}' uses openai_compatible adapter but does not configure base_url", - config.provider_id - ), - source: None, - })?; - let api_key = apply_primary_auth_header(config.auth_header.take(), &mut config.extra_headers); - let mut adapter = providers::OpenAiCompatibleAdapter::new_optional_auth(api_key, base_url) - .with_name(config.provider_id); - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - Ok(adapter) -} - -fn build_openai_compatible(config: AdapterConfig) -> Result, Error> { - Ok(Arc::new(build_openai_compatible_adapter(config)?)) -} - -/// Return the factory for a known adapter kind. -#[must_use] -pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { - match adapter_kind { - AdapterKind::Anthropic => build_anthropic, - AdapterKind::OpenAi => build_openai, - AdapterKind::Gemini => build_gemini, - AdapterKind::OpenAiCompatible => build_openai_compatible, - AdapterKind::Bedrock => providers::bedrock::build, - } -} - -/// A resolved route for one catalog model: the transport+auth key, wire -/// dialect, and provider-facing identifiers a request for that model travels -/// with. -/// -/// `(provider row, model row)` → route. Codec/transport pairings are -/// validated at catalog build, so any model in a successfully built catalog -/// resolves. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Route { - /// Canonical provider this route belongs to. - pub provider: ProviderId, - /// Transport + auth scheme (the adapter registry key). - pub transport: AdapterKind, - /// Wire dialect spoken on this route. - pub codec: CodecKind, - /// Identifier sent to the provider API (the catalog `api_id`). - pub deployment_id: String, - /// Billing family used to translate usage into billed tokens. - pub billing_policy: BillingPolicy, - /// Agent profile driving profile-specific behavior. - pub agent_profile: AgentProfileKind, -} - -/// Resolve the route for one already-selected catalog offering. -#[must_use] -pub fn resolve_route(catalog: &Catalog, model: &Model) -> Option { - let provider = catalog.provider(&model.provider)?; - let settings = catalog.settings_for(model)?; - Some(Route { - provider: provider.id.clone(), - transport: provider.adapter, - codec: settings.codec, - deployment_id: settings.api_id.clone(), - billing_policy: settings.billing_policy, - agent_profile: settings.agent_profile, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn select_from_all<'a>(catalog: &'a Catalog, selector: &str) -> &'a Model { - catalog - .select(selector, None, &catalog.all_provider_ids()) - .unwrap_or_else(|error| panic!("built-in model '{selector}' should resolve: {error}")) - } - - #[test] - fn every_builtin_catalog_offering_resolves() { - let catalog = Catalog::builtin(); - - for model in catalog.list(None) { - let route = resolve_route(catalog, model).unwrap_or_else(|| { - panic!( - "built-in offering '{}/{}' should resolve", - model.provider, model.id - ) - }); - assert_eq!(route.provider, model.provider); - assert!(!route.deployment_id.is_empty()); - } - } - - #[test] - fn resolve_route_follows_model_aliases() { - let catalog = Catalog::builtin(); - - let by_alias = resolve_route(catalog, select_from_all(catalog, "sonnet")) - .expect("alias should resolve"); - let by_id = resolve_route(catalog, select_from_all(catalog, "claude-sonnet-5")) - .expect("id should resolve"); - - assert_eq!(by_alias, by_id); - assert_eq!(by_alias.provider, ProviderId::anthropic()); - } - - #[test] - fn resolve_route_resolves_by_id_for_model_from_another_catalog_instance() { - let other = Catalog::from_builtin().unwrap(); - let model = select_from_all(&other, "gpt-5.4"); - assert!(resolve_route(Catalog::builtin(), model).is_some()); - } - - #[test] - fn anthropic_factory_builds_anthropic_adapter() { - let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "test-key".to_string(), - }); - let adapter = factory_for(AdapterKind::Anthropic)(config).unwrap(); - assert_eq!(adapter.name(), "anthropic"); - } - - #[test] - fn custom_primary_auth_header_is_preserved() { - let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "test-key".to_string(), - }); - - let adapter = build_anthropic_adapter(config); - - assert!(adapter.http.api_key.is_none()); - assert_eq!( - adapter.http.default_headers.get("x-api-key"), - Some(&"test-key".to_string()) - ); - } - - #[test] - fn custom_primary_auth_header_overrides_extra_header() { - let config = AdapterConfig { - base_url: Some("https://api.custom.test/v1".to_string()), - extra_headers: HashMap::from([("x-api-key".to_string(), "secondary-key".to_string())]), - ..AdapterConfig::new("custom", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "primary-key".to_string(), - }) - }; - - let adapter = build_openai_compatible_adapter(config).unwrap(); - - assert!(adapter.http.api_key.is_none()); - assert_eq!( - adapter.http.default_headers.get("x-api-key"), - Some(&"primary-key".to_string()) - ); - } - - #[test] - fn openai_compatible_factory_uses_provider_id_for_name() { - let config = AdapterConfig { - base_url: Some("https://api.moonshot.ai/v1".to_string()), - ..AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())) - }; - let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap(); - assert_eq!(adapter.name(), "moonshot"); - } - - #[test] - fn openai_compatible_factory_preserves_extra_headers() { - let config = AdapterConfig { - base_url: Some("https://api.portkey.ai/v1".to_string()), - extra_headers: HashMap::from([ - ( - "x-portkey-api-key".to_string(), - "resolved-portkey-key".to_string(), - ), - ( - "x-portkey-provider".to_string(), - "@bedrock-prod".to_string(), - ), - ]), - ..AdapterConfig::new( - "portkey", - ApiKeyHeader::Bearer("unused-primary-key".to_string()), - ) - }; - - let adapter = build_openai_compatible_adapter(config).unwrap(); - - assert_eq!(adapter.name(), "portkey"); - assert_eq!( - adapter.http.default_headers.get("x-portkey-api-key"), - Some(&"resolved-portkey-key".to_string()), - ); - assert_eq!( - adapter.http.default_headers.get("x-portkey-provider"), - Some(&"@bedrock-prod".to_string()), - ); - } - - #[test] - fn anthropic_factory_preserves_extra_headers() { - let config = AdapterConfig { - base_url: Some("https://api.portkey.ai/v1".to_string()), - extra_headers: HashMap::from([( - "x-portkey-api-key".to_string(), - "resolved-portkey-key".to_string(), - )]), - ..AdapterConfig::new("anthropic-through-portkey", ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "unused-primary-key".to_string(), - }) - }; - - let adapter = build_anthropic_adapter(config); - - assert_eq!(adapter.name(), "anthropic-through-portkey"); - assert_eq!( - adapter.http.default_headers.get("x-portkey-api-key"), - Some(&"resolved-portkey-key".to_string()), - ); - } - - #[test] - fn openai_compatible_factory_errors_without_base_url() { - let config = AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string())); - let Err(err) = factory_for(AdapterKind::OpenAiCompatible)(config) else { - panic!("expected missing base_url error"); - }; - assert!( - err.to_string() - .contains("uses openai_compatible adapter but does not configure base_url") - ); - } -} diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs new file mode 100644 index 000000000..023133b3c --- /dev/null +++ b/lib/components/fabro-llm/src/api.rs @@ -0,0 +1,164 @@ +//! API projections of the catalog for `GET /models` and `GET /providers`. +//! +//! Every row is a lithos catalog entry plus its Fabro policy, stamped with +//! whether the caller holds credential material for the provider. + +use std::collections::HashSet; + +use fabro_types::controls::REASONING_EFFORTS; +use fabro_types::{ + Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, +}; +use lithos_llm::catalog::Catalog; + +use crate::catalog::{self, ModelEntry, ProviderEntry}; + +const USD_MICROS_PER_USD: f64 = 1_000_000.0; + +/// Every enabled model on every listed provider, provider priority order. +#[must_use] +pub fn models(catalog: &Catalog, configured: &HashSet) -> Vec { + catalog::models(catalog) + .iter() + .map(|entry| model_view(entry, configured.contains(entry.provider.id()))) + .collect() +} + +/// Every listed provider, priority order. +#[must_use] +pub fn providers(catalog: &Catalog, configured: &HashSet) -> Vec { + catalog::listed_providers(catalog) + .iter() + .map(|entry| provider_view(entry, configured.contains(entry.provider.id()))) + .collect() +} + +fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { + let model = entry.model; + let capabilities = model.capabilities(); + let pricing = model.pricing(); + let limits = model.limits(); + Model { + id: model.id().clone(), + provider: entry.provider.id().clone(), + family: entry + .policy + .family + .clone() + .unwrap_or_else(|| model.id().to_string()), + display_name: model.display_name().to_string(), + limits: ModelLimits { + context_window: limits.map_or(0, |limits| saturating_i64(limits.context_tokens)), + max_output: limits + .map(|limits| limits.max_output_tokens) + .filter(|tokens| *tokens > 0) + .map(saturating_i64), + }, + training: entry.policy.training.clone(), + knowledge_cutoff: entry.policy.knowledge_cutoff.clone(), + features: ModelFeatures { + tools: capabilities.tools().is_supported(), + vision: capabilities.images().is_supported(), + reasoning: capabilities.reasoning().is_supported(), + prompt_cache: capabilities.caching().is_supported(), + sampling: capabilities.sampling().is_supported(), + }, + controls: ModelControls { + reasoning_effort: REASONING_EFFORTS + .iter() + .copied() + .filter(|effort| capabilities.reasoning_effort(*effort).is_supported()) + .collect(), + }, + costs: ModelCosts { + input_cost_per_mtok: pricing + .and_then(|pricing| pricing.input_usd_micros_per_million) + .map(usd_per_million), + output_cost_per_mtok: pricing + .and_then(|pricing| pricing.output_usd_micros_per_million) + .map(usd_per_million), + cache_input_cost_per_mtok: pricing + .and_then(|pricing| pricing.cached_input_usd_micros_per_million) + .map(usd_per_million), + }, + estimated_output_tps: entry.policy.estimated_output_tps, + aliases: model.aliases().to_vec(), + default: entry.provider.default_model() == Some(model.id().as_str()), + small_default: entry.policy.small_default, + configured, + } +} + +fn provider_view(entry: &ProviderEntry<'_>, configured: bool) -> Provider { + let provider = entry.provider; + Provider { + id: provider.id().clone(), + display_name: provider.display_name().to_string(), + adapter: provider.adapter().as_str().to_string(), + base_url: provider.base_url().to_string(), + api_key_url: entry.policy.api_key_url.clone(), + priority: provider.priority(), + aliases: provider.aliases().to_vec(), + model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX), + default_model: provider.default_model().map(str::to_string), + configured, + expected_secret_name: fabro_auth::expected_vault_secret_name(provider), + } +} + +#[allow( + clippy::cast_precision_loss, + reason = "Catalog prices are display values; micros fit f64 exactly at these magnitudes." +)] +fn usd_per_million(micros: u64) -> f64 { + micros as f64 / USD_MICROS_PER_USD +} + +fn saturating_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use fabro_types::provider_ids; + + use super::*; + use crate::test_support::test_catalog; + + #[test] + fn models_are_stamped_with_configured_providers() { + let catalog = test_catalog(); + let configured = HashSet::from([provider_ids::openai()]); + let models = models(&catalog, &configured); + let openai = models + .iter() + .find(|model| model.provider == provider_ids::openai()) + .expect("openai models listed"); + assert!(openai.configured); + assert!(openai.limits.context_window > 0); + let anthropic = models + .iter() + .find(|model| model.provider == provider_ids::anthropic()) + .expect("anthropic models listed"); + assert!(!anthropic.configured); + assert!(models.iter().any(|model| model.default)); + } + + #[test] + fn providers_skip_stand_ins_and_disabled_entries() { + let catalog = test_catalog(); + let providers = providers(&catalog, &HashSet::new()); + assert!(providers.iter().any(|p| p.id == provider_ids::openai())); + assert!(providers.iter().all(|p| p.id.as_str() != "openai-codex")); + assert!(providers.iter().all(|p| p.id.as_str() != "ollama")); + let openai = providers + .iter() + .find(|p| p.id == provider_ids::openai()) + .unwrap(); + assert_eq!( + openai.expected_secret_name.as_deref(), + Some("OPENAI_API_KEY") + ); + assert!(openai.model_count > 0); + } +} diff --git a/lib/components/fabro-llm/src/attachments.rs b/lib/components/fabro-llm/src/attachments.rs index c650d3ddd..37c9c8432 100644 --- a/lib/components/fabro-llm/src/attachments.rs +++ b/lib/components/fabro-llm/src/attachments.rs @@ -1,109 +1,268 @@ -//! Resolve file-backed attachments to inline data before a codec encodes. +//! Inlines local file attachments before a request reaches a codec. //! -//! Codec `encode` is sync and never touches the filesystem, so any -//! `Image`/`Document`/`Audio` part whose `url` is a local file path is loaded -//! here (async) and rewritten to inline bytes + MIME, per the codec's policy. -//! Loads that fail drop the part silently — the long-standing contract — and -//! non-file URLs and already-inline data pass through untouched. -//! -//! Shared infra for the per-dialect codecs (anthropic/openai_responses/gemini): -//! each constructs its own [`AttachmentPolicy`] and calls [`resolve`] from its -//! adapter shell. +//! lithos accepts media as a URL or as base64. Fabro lets a caller point an +//! image, document, or audio part at a local path; this middleware reads the +//! file and rewrites the part to inline base64 with an inferred media type. +//! A part whose file cannot be read is dropped, so the model sees the rest of +//! the message rather than a request that fails outright. -use std::borrow::Cow; +use std::sync::Arc; -use crate::providers::common; -use crate::types::{AudioData, ContentPart, DocumentData, ImageData, Request}; +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use fabro_static::EnvVars; +use lithos_llm::middleware::{Call, Middleware, Next, Output}; +use lithos_llm::types::{ + AudioContent, ContentPart, DocumentContent, Error, ImageContent, MediaSource, Message, Request, + ToolResult, +}; +use tokio::fs; -/// Which attachment kinds a codec loads from local file paths. Each dialect -/// adapter constructs the policy it wants (e.g. images + documents but not -/// audio for Anthropic, which renders audio as a text placeholder). -#[derive(Clone, Copy)] -pub(crate) struct AttachmentPolicy { - pub images: bool, - pub documents: bool, - pub audio: bool, +/// Resolves an environment variable name to its value. +type EnvLookup = Arc Option + Send + Sync>; + +/// Middleware that inlines local-path media parts. +#[derive(Clone, Default)] +pub struct InlineLocalAttachments { + env_lookup: Option, } -/// Resolve file-path attachments (per `policy`) to inline data. Parts whose -/// file fails to load are dropped. Borrows the request untouched in the common -/// case where nothing needs loading; only requests with policy-matching -/// local-file parts pay for a copy. -pub(crate) async fn resolve(request: &Request, policy: AttachmentPolicy) -> Cow<'_, Request> { - if !needs_resolution(request, policy) { - return Cow::Borrowed(request); +impl std::fmt::Debug for InlineLocalAttachments { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InlineLocalAttachments") + .finish_non_exhaustive() + } +} + +impl InlineLocalAttachments { + #[must_use] + pub fn new() -> Self { + Self::default() } - let mut resolved = request.clone(); - for message in &mut resolved.messages { - let mut new_content = Vec::with_capacity(message.content.len()); - for part in std::mem::take(&mut message.content) { - if let Some(part) = resolve_part(part, policy).await { - new_content.push(part); + /// Resolves `~/` against this lookup instead of the process environment. + #[must_use] + pub fn with_env_lookup(env_lookup: EnvLookup) -> Self { + Self { + env_lookup: Some(env_lookup), + } + } + + #[expect( + clippy::disallowed_methods, + reason = "Attachment path expansion supports the conventional HOME env var." + )] + fn home(&self) -> Option { + match &self.env_lookup { + Some(lookup) => lookup(EnvVars::HOME), + None => std::env::var(EnvVars::HOME).ok(), + } + } + + fn expand(&self, path: &str) -> String { + path.strip_prefix("~/").map_or_else( + || path.to_string(), + |rest| format!("{}/{rest}", self.home().unwrap_or_else(|| "/".to_string())), + ) + } + + async fn load(&self, path: &str) -> Option { + let expanded = self.expand(path); + match fs::read(&expanded).await { + Ok(bytes) => Some(MediaSource::base64( + BASE64_STANDARD.encode(bytes), + media_type_for_path(&expanded), + )), + Err(err) => { + tracing::warn!(path = %expanded, error = %err, "dropping unreadable attachment"); + None } } - message.content = new_content; } - Cow::Owned(resolved) + + async fn inline_part(&self, part: ContentPart) -> Option { + match part { + ContentPart::Image(ImageContent { source, detail }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Image(ImageContent { source, detail })) + } + ContentPart::Document(DocumentContent { source, name }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Document(DocumentContent { source, name })) + } + ContentPart::Audio(AudioContent { source }) if is_local_file(&source) => { + let source = self.load(url_of(&source)).await?; + Some(ContentPart::Audio(AudioContent { source })) + } + ContentPart::ToolResult(result) if result.content.iter().any(part_is_local_file) => { + let mut content = Vec::with_capacity(result.content.len()); + for part in result.content { + if let Some(part) = Box::pin(self.inline_part(part)).await { + content.push(part); + } + } + Some(ContentPart::ToolResult(ToolResult { content, ..result })) + } + other => Some(other), + } + } + + async fn inline_request(&self, request: Request) -> Request { + let mut messages = Vec::with_capacity(request.messages().len()); + for message in request.messages() { + let mut content = Vec::with_capacity(message.content().len()); + for part in message.content() { + if let Some(part) = self.inline_part(part.clone()).await { + content.push(part); + } + } + let mut rebuilt = Message::new(message.role(), content); + if let Some(name) = message.name() { + rebuilt = rebuilt.with_name(name); + } + if let Some(id) = message.tool_call_id() { + rebuilt = rebuilt.with_tool_call_id(id); + } + messages.push(rebuilt); + } + replace_messages(&request, messages).unwrap_or(request) + } } -/// Whether any part is a policy-matching local-file attachment. -fn needs_resolution(request: &Request, policy: AttachmentPolicy) -> bool { - request - .messages - .iter() - .flat_map(|message| &message.content) - .any(|part| match part { - ContentPart::Image(img) => policy.images && is_local_file(img.url.as_deref()), - ContentPart::Document(doc) => policy.documents && is_local_file(doc.url.as_deref()), - ContentPart::Audio(audio) => policy.audio && is_local_file(audio.url.as_deref()), - _ => false, - }) +/// Rebuilds `request` with `messages` in place of its own. +/// +/// The request builder appends messages and has no way to clear them, so the +/// swap goes through the request's serde form. +fn replace_messages(request: &Request, messages: Vec) -> Option { + let mut value = serde_json::to_value(request).ok()?; + value["messages"] = serde_json::to_value(messages).ok()?; + serde_json::from_value(value).ok() } -/// Resolve a single part. `None` means the part was dropped (load error). -async fn resolve_part(part: ContentPart, policy: AttachmentPolicy) -> Option { +fn part_is_local_file(part: &ContentPart) -> bool { match part { - ContentPart::Image(img) if policy.images && is_local_file(img.url.as_deref()) => { - // `is_local_file` guarantees `url` is `Some`. - let url = img.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Image(ImageData { - url: None, - data: Some(data), - media_type: Some(mime), - detail: img.detail, - })), - Err(_) => None, - } - } - ContentPart::Document(doc) if policy.documents && is_local_file(doc.url.as_deref()) => { - let url = doc.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Document(DocumentData { - url: None, - data: Some(data), - media_type: Some(mime), - file_name: doc.file_name, - })), - Err(_) => None, - } - } - ContentPart::Audio(audio) if policy.audio && is_local_file(audio.url.as_deref()) => { - let url = audio.url.as_deref().unwrap_or_default(); - match common::load_file_bytes(url).await { - Ok((data, mime)) => Some(ContentPart::Audio(AudioData { - url: None, - data: Some(data), - media_type: Some(mime), - })), - Err(_) => None, - } - } - other => Some(other), + ContentPart::Image(ImageContent { source, .. }) + | ContentPart::Document(DocumentContent { source, .. }) + | ContentPart::Audio(AudioContent { source }) => is_local_file(source), + _ => false, } } -fn is_local_file(url: Option<&str>) -> bool { - url.is_some_and(common::is_file_path) +fn url_of(source: &MediaSource) -> &str { + match source { + MediaSource::Url { url, .. } => url, + _ => "", + } +} + +fn is_local_file(source: &MediaSource) -> bool { + matches!( + source, + MediaSource::Url { url, .. } + if url.starts_with('/') || url.starts_with("./") || url.starts_with("~/") + ) +} + +fn needs_inlining(request: &Request) -> bool { + request.messages().iter().any(|message| { + message.content().iter().any(|part| match part { + ContentPart::ToolResult(result) => result.content.iter().any(part_is_local_file), + part => part_is_local_file(part), + }) + }) +} + +/// Media type for a local path, from its extension. +#[must_use] +pub fn media_type_for_path(path: &str) -> String { + mime_guess::from_path(path) + .first_raw() + .unwrap_or("application/octet-stream") + .to_string() +} + +#[async_trait] +impl Middleware for InlineLocalAttachments { + async fn handle(&self, call: Call, next: Next) -> Result { + if !needs_inlining(call.request()) { + return next.run(call).await; + } + let inlined = self.inline_request(call.request().clone()).await; + let call = call.map_request(|_| Ok(inlined))?; + next.run(call).await + } +} + +#[cfg(test)] +mod tests { + use lithos_llm::types::Role; + + use super::*; + + fn request_with(part: ContentPart) -> Request { + Request::builder() + .model("openai/gpt-5.4") + .message(Message::new(Role::User, [ + ContentPart::Text { + text: "look".to_string(), + }, + part, + ])) + .build() + .unwrap() + } + + #[tokio::test] + async fn inlines_local_images_and_drops_missing_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pixel.png"); + fs::write(&path, b"\x89PNG").await.unwrap(); + let middleware = InlineLocalAttachments::new(); + + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + path.to_string_lossy().to_string(), + )))); + let inlined = middleware.inline_request(request).await; + match &inlined.messages()[0].content()[1] { + ContentPart::Image(image) => { + assert_eq!(image.source.media_type(), Some("image/png")); + assert_eq!( + image.source.base64_data(), + Some(BASE64_STANDARD.encode(b"\x89PNG").as_str()) + ); + } + other => panic!("expected inlined image, got {other:?}"), + } + + let missing = request_with(ContentPart::Document(DocumentContent::new( + MediaSource::url("/definitely/missing.pdf"), + ))); + let inlined = middleware.inline_request(missing).await; + assert_eq!(inlined.messages()[0].content().len(), 1); + } + + #[test] + fn remote_urls_and_inline_data_pass_through() { + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + "https://example.com/a.png", + )))); + assert!(!needs_inlining(&request)); + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::base64( + "AAAA", + "image/png", + )))); + assert!(!needs_inlining(&request)); + let request = request_with(ContentPart::Image(ImageContent::new(MediaSource::url( + "~/shot.png", + )))); + assert!(needs_inlining(&request)); + } + + #[test] + fn media_types_follow_extensions() { + assert_eq!(media_type_for_path("a.jpg"), "image/jpeg"); + assert_eq!(media_type_for_path("a.pdf"), "application/pdf"); + assert_eq!(media_type_for_path("a.bin"), "application/octet-stream"); + } } diff --git a/lib/components/fabro-llm/src/catalog.rs b/lib/components/fabro-llm/src/catalog.rs new file mode 100644 index 000000000..939655cbd --- /dev/null +++ b/lib/components/fabro-llm/src/catalog.rs @@ -0,0 +1,498 @@ +//! Catalog construction and Fabro-policy queries. +//! +//! Layer order is fixed: lithos built-ins, then Fabro's policy layer, then the +//! operator's `[llm]` overlay. Every query here reads Fabro policy from the +//! `metadata.fabro` namespace and never bypasses `enabled`. + +use std::collections::{BTreeMap, HashSet}; + +use fabro_config::LlmLayer; +use fabro_static::EnvVars; +use fabro_types::catalog_policy::{self, ModelPolicy, ProviderPolicy}; +use fabro_types::{AgentProfileKind, Cost, ModelId, ModelRef, ProviderId, TokenCounts}; +use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider}; +use lithos_llm::resolver::ResolvedRoute; + +/// Fabro's policy layer, applied above the lithos built-ins. +pub const FABRO_POLICY_TOML: &str = include_str!("../catalog/fabro-policy.toml"); + +/// Builds the effective catalog. +/// +/// `env_lookup` supplies `OPENAI_BASE_URL`, the one environment override +/// Fabro honors: it repoints the `openai` provider so test doubles and +/// gateways can stand in for the real API without editing settings. +pub fn build_catalog( + overlay: &LlmLayer, + env_lookup: &dyn Fn(&str) -> Option, +) -> Result { + let mut builder = Catalog::builder() + .with_builtin() + .toml_layer("fabro-policy.toml", FABRO_POLICY_TOML)?; + if !overlay.is_empty() { + let mut document = overlay.to_overlay_toml(); + document.insert_str(0, "schema_version = 1\n"); + builder = builder.toml_layer("settings [llm]", &document)?; + } + if let Some(base_url) = env_lookup(EnvVars::OPENAI_BASE_URL) { + let document = format!( + "schema_version = 1\n[providers.openai]\nbase_url = {}\n", + toml::Value::String(base_url.trim_end_matches("/v1").to_string()) + ); + builder = builder.toml_layer("OPENAI_BASE_URL", &document)?; + } + builder.build() +} + +/// A provider with its Fabro policy attached. +#[derive(Debug, Clone)] +pub struct ProviderEntry<'a> { + pub provider: &'a CatalogProvider, + pub policy: ProviderPolicy, +} + +/// A model with its Fabro policy attached. +#[derive(Debug, Clone)] +pub struct ModelEntry<'a> { + pub provider: &'a CatalogProvider, + pub model: &'a CatalogModel, + pub policy: ModelPolicy, +} + +impl ModelEntry<'_> { + /// Whether requests to this model reason when no effort is requested. + /// + /// Fabro policy can state it outright. Otherwise a model that supports + /// reasoning and takes named effort levels reasons by default, while one + /// that needs an explicit thinking budget does not. + #[must_use] + pub fn reasons_by_default(&self) -> bool { + self.policy.reasoning_by_default.unwrap_or_else(|| { + self.model.capabilities().reasoning().is_supported() + && self.model.protocol_options().reasoning_effort_levels + }) + } + + #[must_use] + pub fn agent_profile(&self) -> AgentProfileKind { + catalog_policy::effective_agent_profile(self.provider, self.model) + } +} + +/// The catalog with no operator overlay: lithos built-ins plus Fabro policy. +/// +/// Used where no settings file is in play, such as the standalone hook +/// runner. Servers and the CLI build from the operator's `[llm]` overlay +/// with [`build_catalog`] instead. +#[must_use] +pub fn default_catalog() -> Catalog { + build_catalog(&LlmLayer::default(), &|_| None) + .expect("the built-in catalog and Fabro policy layer always build") +} + +/// Estimates the catalog cost of `usage` on `model`, when the catalog prices +/// that route. Passthrough models and unknown providers have no price. +#[must_use] +pub fn estimate_cost(catalog: &Catalog, model: &ModelRef, usage: TokenCounts) -> Option { + let entry = model_on_provider(catalog, model.provider.as_str(), model.model_id.as_str())?; + ResolvedRoute::try_new(entry.provider.clone(), entry.model.clone()) + .ok()? + .estimate_cost(usage, model.speed) +} + +/// Enabled providers, highest priority first, ties broken by id. +#[must_use] +pub fn enabled_providers(catalog: &Catalog) -> Vec> { + let mut providers: Vec<_> = catalog + .providers() + .map(|provider| ProviderEntry { + provider, + policy: catalog_policy::provider_policy(provider), + }) + .filter(|entry| entry.policy.is_enabled()) + .collect(); + providers.sort_by(|left, right| { + right + .provider + .priority() + .cmp(&left.provider.priority()) + .then_with(|| left.provider.id().cmp(right.provider.id())) + }); + providers +} + +/// Enabled providers that Fabro lists to operators. Stand-in providers such +/// as `openai-codex` route requests but are not offerings of their own. +#[must_use] +pub fn listed_providers(catalog: &Catalog) -> Vec> { + enabled_providers(catalog) + .into_iter() + .filter(|entry| entry.policy.stands_in_for.is_none()) + .collect() +} + +/// The ids of every enabled provider. +#[must_use] +pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet { + enabled_providers(catalog) + .into_iter() + .map(|entry| entry.provider.id().clone()) + .collect() +} + +/// Looks up an enabled provider by id or alias. +#[must_use] +pub fn provider<'a>(catalog: &'a Catalog, selector: &str) -> Option> { + let provider = catalog.provider(selector).ok()?; + let policy = catalog_policy::provider_policy(provider); + policy + .is_enabled() + .then_some(ProviderEntry { provider, policy }) +} + +/// Canonicalizes a provider id or alias to its catalog id, when enabled. +#[must_use] +pub fn canonical_provider_id(catalog: &Catalog, selector: &str) -> Option { + provider(catalog, selector).map(|entry| entry.provider.id().clone()) +} + +/// Enabled models of an enabled provider, in catalog order. +#[must_use] +pub fn provider_models(provider: &CatalogProvider) -> Vec> { + provider + .models() + .map(|model| ModelEntry { + provider, + model, + policy: catalog_policy::model_policy(model), + }) + .filter(|entry| entry.policy.is_enabled()) + .collect() +} + +/// Every enabled model across listed providers, provider priority order. +#[must_use] +pub fn models(catalog: &Catalog) -> Vec> { + listed_providers(catalog) + .into_iter() + .flat_map(|entry| provider_models(entry.provider)) + .collect() +} + +/// Finds an enabled model on an enabled provider by id, alias, or wire id. +#[must_use] +pub fn model_on_provider<'a>( + catalog: &'a Catalog, + provider_selector: &str, + model_selector: &str, +) -> Option> { + let entry = provider(catalog, provider_selector)?; + // lithos matches ids and aliases. The provider's wire id (an aggregator's + // `vendor/model`) is accepted too, so a selector copied from the + // provider's own listing lands on the catalog row instead of passing + // through unknown. + let model = entry.provider.model(model_selector).or_else(|| { + entry + .provider + .models() + .find(|model| model.api_model() == model_selector) + })?; + let policy = catalog_policy::model_policy(model); + policy.is_enabled().then_some(ModelEntry { + provider: entry.provider, + model, + policy, + }) +} + +/// Enabled models matching `selector` by id or alias, ordered like lithos +/// selection: exact ids before aliases, then provider priority. +#[must_use] +pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec> { + let mut matches: Vec<_> = enabled_providers(catalog) + .into_iter() + .flat_map(|entry| provider_models(entry.provider)) + .filter(|entry| { + entry.model.id().as_str() == selector + || entry.model.aliases().iter().any(|alias| alias == selector) + }) + .collect(); + matches.sort_by_key(|entry| entry.model.id().as_str() != selector); + matches +} + +/// Whether `selector` names an enabled model on any enabled provider. +#[must_use] +pub fn is_model_selector(catalog: &Catalog, selector: &str) -> bool { + !models_matching(catalog, selector).is_empty() +} + +/// Whether `selector` names an enabled provider. +#[must_use] +pub fn is_provider_selector(catalog: &Catalog, selector: &str) -> bool { + provider(catalog, selector).is_some() +} + +/// The enabled default model of an enabled provider. +#[must_use] +pub fn default_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { + let entry = provider(catalog, provider_selector)?; + let default = entry.provider.default_model()?; + model_on_provider(catalog, entry.provider.id().as_str(), default) +} + +/// The model Fabro probes a provider with: the `probe` model, else the +/// provider default. +#[must_use] +pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { + let entry = provider(catalog, provider_selector)?; + provider_models(entry.provider) + .into_iter() + .find(|model| model.policy.probe) + .or_else(|| default_model(catalog, provider_selector)) +} + +/// The default model across `ready` providers: the highest-priority ready +/// provider's default. Falls back to any enabled provider's default when no +/// provider is ready, so callers always have a model to name. +#[must_use] +pub fn default_for_ready<'a>( + catalog: &'a Catalog, + ready: &HashSet, +) -> Option> { + let providers = enabled_providers(catalog); + providers + .iter() + .filter(|entry| ready.contains(entry.provider.id())) + .chain(providers.iter()) + .find_map(|entry| default_model(catalog, entry.provider.id().as_str())) +} + +/// The small utility model across `ready` providers: the first +/// `small_default` model in provider priority order, else the ready default. +#[must_use] +pub fn small_default_for_ready<'a>( + catalog: &'a Catalog, + ready: &HashSet, +) -> Option> { + let providers = enabled_providers(catalog); + providers + .iter() + .filter(|entry| ready.contains(entry.provider.id())) + .flat_map(|entry| provider_models(entry.provider)) + .find(|model| model.policy.small_default) + .or_else(|| default_for_ready(catalog, ready)) +} + +/// Canonicalizes a model selector to a catalog model id, preferring +/// `provider`'s offering. Unknown selectors pass through verbatim so +/// passthrough models keep their names. +#[must_use] +pub fn canonical_model_id(catalog: &Catalog, provider: &ProviderId, selector: &str) -> String { + model_on_provider(catalog, provider.as_str(), selector) + .map(|entry| entry.model.id().to_string()) + .or_else(|| { + models_matching(catalog, selector) + .first() + .map(|entry| entry.model.id().to_string()) + }) + .unwrap_or_else(|| selector.to_string()) +} + +/// The agent profile for a route. Unknown (passthrough) models take the +/// provider default. +#[must_use] +pub fn agent_profile( + catalog: &Catalog, + provider_selector: &str, + model_selector: Option<&str>, +) -> Option { + let entry = provider(catalog, provider_selector)?; + let model = model_selector.and_then(|selector| entry.provider.model(selector)); + Some(match model { + Some(model) => catalog_policy::effective_agent_profile(entry.provider, model), + None => entry + .policy + .agent_profile + .unwrap_or_else(|| catalog_policy::default_agent_profile(entry.provider)), + }) +} + +/// The `target` provider's model closest to `reference` in capability and +/// input price, for provider-level fallbacks. +#[must_use] +pub fn closest_model<'a>( + catalog: &'a Catalog, + target: &str, + reference: &CatalogModel, +) -> Option> { + let target = provider(catalog, target)?; + let reference_caps = reference.capabilities(); + let reference_price = reference + .pricing() + .and_then(|pricing| pricing.input_usd_micros_per_million) + .unwrap_or(0); + provider_models(target.provider) + .into_iter() + .filter(|entry| { + let caps = entry.model.capabilities(); + caps.tools().is_supported() == reference_caps.tools().is_supported() + && caps.images().is_supported() == reference_caps.images().is_supported() + && caps.reasoning().is_supported() == reference_caps.reasoning().is_supported() + }) + .min_by_key(|entry| { + let price = entry + .model + .pricing() + .and_then(|pricing| pricing.input_usd_micros_per_million) + .unwrap_or(0); + price.abs_diff(reference_price) + }) +} + +/// Model ids grouped by provider, for diagnostics and documentation. +#[must_use] +pub fn model_ids_by_provider(catalog: &Catalog) -> BTreeMap> { + listed_providers(catalog) + .into_iter() + .map(|entry| { + ( + entry.provider.id().clone(), + provider_models(entry.provider) + .into_iter() + .map(|model| model.model.id().clone()) + .collect(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::test_catalog; + + #[test] + fn policy_layer_builds_over_the_builtins() { + let catalog = test_catalog(); + let ids: Vec<_> = enabled_providers(&catalog) + .iter() + .map(|entry| entry.provider.id().to_string()) + .collect(); + assert_eq!(ids[0], "anthropic"); + assert!(ids.contains(&"openai".to_string())); + assert!( + !ids.contains(&"bedrock".to_string()), + "bedrock ships disabled" + ); + assert!( + !listed_providers(&catalog) + .iter() + .any(|entry| entry.provider.id().as_str() == "openai-codex"), + "stand-in providers are not listed" + ); + } + + #[test] + fn operator_overlay_applies_last() { + let overlay = LlmLayer( + toml::from_str( + r" +[providers.openai] +priority = 500 +[providers.openai.metadata.fabro] +enabled = false +", + ) + .unwrap(), + ); + let catalog = build_catalog(&overlay, &|_| None).unwrap(); + assert!(provider(&catalog, "openai").is_none()); + assert_eq!( + catalog.provider("openai").unwrap().priority(), + 500, + "overlay values win over the policy layer" + ); + } + + #[test] + fn openai_base_url_env_repoints_the_openai_provider() { + let catalog = build_catalog(&LlmLayer::default(), &|name| { + (name == EnvVars::OPENAI_BASE_URL).then(|| "http://127.0.0.1:1234/v1".to_string()) + }) + .unwrap(); + assert_eq!( + catalog.provider("openai").unwrap().base_url(), + "http://127.0.0.1:1234" + ); + } + + #[test] + fn probe_and_small_default_follow_policy() { + let catalog = test_catalog(); + assert_eq!( + probe_model(&catalog, "openai").unwrap().model.id().as_str(), + "gpt-5.4-mini" + ); + assert_eq!( + probe_model(&catalog, "anthropic") + .unwrap() + .model + .id() + .as_str(), + "claude-haiku-4.5" + ); + let ready = HashSet::from([ProviderId::new("openai")]); + assert_eq!( + small_default_for_ready(&catalog, &ready) + .unwrap() + .model + .id() + .as_str(), + "gpt-5.4-mini" + ); + assert_eq!( + default_for_ready(&catalog, &ready) + .unwrap() + .model + .id() + .as_str(), + "gpt-5.6-sol" + ); + assert_eq!( + default_for_ready(&catalog, &HashSet::new()) + .unwrap() + .provider + .id() + .as_str(), + "anthropic" + ); + } + + #[test] + fn selectors_resolve_aliases_and_canonical_ids() { + let catalog = test_catalog(); + assert!(is_model_selector(&catalog, "sonnet")); + assert!(is_model_selector(&catalog, "gpt-5.4-mini")); + assert!(!is_model_selector(&catalog, "nope")); + assert_eq!( + canonical_model_id(&catalog, &ProviderId::new("openai"), "codex"), + "gpt-5.4" + ); + assert_eq!( + canonical_model_id(&catalog, &ProviderId::new("openai"), "unknown-model"), + "unknown-model" + ); + assert_eq!( + agent_profile(&catalog, "openai", Some("gpt-5.6-sol")), + Some(AgentProfileKind::Gpt56) + ); + assert_eq!( + agent_profile(&catalog, "moonshot", None), + Some(AgentProfileKind::OpenAi) + ); + assert_eq!( + agent_profile(&catalog, "moonshot", Some("kimi-k3")), + Some(AgentProfileKind::Kimi) + ); + } +} diff --git a/lib/components/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs index 59cfa421d..c8018cd2f 100644 --- a/lib/components/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -1,2248 +1,267 @@ -use std::collections::{HashMap, HashSet}; +//! Client construction from Fabro configuration and credentials. + use std::sync::Arc; +use std::time::Duration; -use fabro_auth::{ApiCredential, CredentialSource}; -use fabro_model::{AdapterKind, Catalog, ModelSelectionError, ProviderId}; -use tracing::debug; - -use crate::adapter_registry::{ - AdapterConfig, AdapterKindOptions, OpenAiAdapterOptions, factory_for, +use fabro_auth::{CredentialSource, ResolveError, lithos_credentials}; +use fabro_types::ProviderId; +use lithos_llm::adapter::ProviderAdapter; +use lithos_llm::catalog::Catalog; +use lithos_llm::client::{Client, ClientBuildError, ClientBuilder, ProviderBuildIssue}; +use lithos_llm::middleware::{ + Call, Middleware, Observer, RetryMiddleware, RetryPolicy, RetryStage, }; -use crate::cost; -use crate::error::{Error, ProviderErrorKind}; -use crate::middleware::{Middleware, NextFn, NextStreamFn}; -use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::token_count::{ - InputTokenCount, InputTokenCountMethod, InputTokenCountPreference, estimate_input_tokens, -}; -use crate::types::{Request, Response, Speed, StreamEvent, Warning}; +use lithos_llm::types::Error; -/// The core client that routes requests to provider adapters (Section 2.2, 3). +use crate::attachments::InlineLocalAttachments; +use crate::error::LlmError; +use crate::resolver::FabroResolver; + +/// Default same-provider retry policy applied before visible output. +/// +/// Three attempts with short exponential backoff, capped at five seconds. +/// fabro-agent replays after visible output with the same policy. +pub fn default_retry_policy() -> RetryPolicy { + RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::from_millis(500)) + .max_delay(Duration::from_secs(5)) + .jitter(true) +} + +/// One retry the lithos retry middleware decided on. +#[derive(Clone, Debug)] +pub struct RetryNotice { + /// The failure that ended the attempt. + pub error: LlmError, + /// The attempt that failed, counted from 1. + pub attempt: u32, + /// How long the middleware waits before the next attempt. + pub delay: Duration, + /// Whether the request or its stream failed. + pub stage: RetryStage, +} + +/// A per-call hook that receives the retries the client performs. +/// +/// The retry middleware is shared by every call through a client, so a +/// caller that turns retries into its own events — the agent's durable +/// `LlmRetry` event — inserts a listener into the call's context extensions +/// before dispatch. Calls without a listener are retried silently. #[derive(Clone)] -pub struct Client { - providers: HashMap>, - default_provider: Option, - middleware: Vec>, - catalog: Option>, +pub struct RetryListener(Arc); + +impl RetryListener { + pub fn new(listener: impl Fn(RetryNotice) + Send + Sync + 'static) -> Self { + Self(Arc::new(listener)) + } + + fn notify(&self, notice: RetryNotice) { + (self.0)(notice); + } } -#[derive(Debug, Clone)] -pub struct ProviderRegistrationIssue { - pub provider: ProviderId, - pub error: Error, -} +/// Forwards middleware retries to the call's [`RetryListener`], if any. +struct RetryNotifier; -#[derive(Clone)] -pub struct ClientRegistrationReport { - pub client: Client, - pub registration_issues: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RegistrationMode { - FailFast, - CollectIssues, -} - -struct ResolvedRequest { - provider: Arc, - request: Request, -} - -impl Client { - /// Create a new Client with explicit configuration. - #[must_use] - pub fn new( - providers: HashMap>, - default_provider: Option, - middleware: Vec>, - ) -> Self { - Self { - providers, - default_provider, - middleware, - catalog: None, - } - } - - /// Create a Client from a credential source. - /// - /// # Errors - /// - /// Returns `Error` if the source cannot resolve credentials or any provider - /// adapter fails to initialize. - pub async fn from_source( - source: &dyn CredentialSource, - catalog: Arc, - ) -> Result { - let resolved = source - .resolve(&catalog) - .await - .map_err(|err| Error::Configuration { - message: format!("Failed to resolve LLM credentials: {err}"), - source: None, - })?; - Self::from_credentials(resolved.credentials, catalog).await - } - - /// Create a Client report from a credential source. - /// - /// # Errors - /// - /// Returns `Error` only when the credential source itself fails. Provider - /// adapter construction/registration failures are recorded on the report. - pub async fn from_source_report( - source: &dyn CredentialSource, - catalog: Arc, - ) -> Result { - let resolved = source - .resolve(&catalog) - .await - .map_err(|err| Error::Configuration { - message: format!("Failed to resolve LLM credentials: {err}"), - source: None, - })?; - Ok(Self::from_credentials_report(resolved.credentials, catalog).await) - } - - /// Create a Client from typed provider credentials. - /// - /// # Errors - /// - /// Returns `Error` if any provider adapter fails to initialize. - pub async fn from_credentials( - credentials: Vec, - catalog: Arc, - ) -> Result { - let (report, error) = - Self::from_credentials_internal(credentials, catalog, RegistrationMode::FailFast).await; - if let Some(error) = error { - return Err(error); - } - Ok(report.client) - } - - /// Create a Client while collecting provider adapter registration failures. - /// - /// Providers whose credentials resolve but whose adapter cannot be - /// constructed or initialized are omitted from the returned client and - /// reported in `registration_issues`. - pub async fn from_credentials_report( - credentials: Vec, - catalog: Arc, - ) -> ClientRegistrationReport { - let (report, _) = - Self::from_credentials_internal(credentials, catalog, RegistrationMode::CollectIssues) - .await; - report - } - - async fn from_credentials_internal( - credentials: Vec, - catalog: Arc, - mode: RegistrationMode, - ) -> (ClientRegistrationReport, Option) { - let mut client = Self { - providers: HashMap::new(), - default_provider: None, - middleware: Vec::new(), - catalog: Some(Arc::clone(&catalog)), - }; - let mut registration_issues = Vec::new(); - - for credential in credentials { - let provider_id = credential.provider.clone(); - let adapter = if let Some(provider) = catalog.provider(&provider_id) { - let factory = factory_for(provider.adapter); - let kind_options = match provider.adapter { - AdapterKind::OpenAi => AdapterKindOptions::OpenAi(OpenAiAdapterOptions { - codex_mode: credential.codex_mode, - org_id: credential.org_id, - project_id: credential.project_id, - }), - _ => AdapterKindOptions::None, - }; - factory(AdapterConfig { - provider_id: provider.id.to_string(), - auth_header: credential.auth_header, - base_url: credential.base_url.or_else(|| provider.base_url.clone()), - extra_headers: credential.extra_headers, - kind_options, - catalog: Some(Arc::clone(&catalog)), - }) - } else { - Err(Error::Configuration { - message: format!( - "Provider \"{provider_id}\" is not supported by credential-only registration" - ), - source: None, - }) - }; - match adapter { - Ok(adapter) => { - if let Err(error) = client.register_provider(adapter).await { - if mode == RegistrationMode::FailFast { - return ( - ClientRegistrationReport { - client, - registration_issues, - }, - Some(error), - ); - } - registration_issues.push(ProviderRegistrationIssue { - provider: provider_id, - error, - }); - } - } - Err(error) => { - if mode == RegistrationMode::FailFast { - return ( - ClientRegistrationReport { - client, - registration_issues, - }, - Some(error), - ); - } - registration_issues.push(ProviderRegistrationIssue { - provider: provider_id, - error, - }); - } - } - } - - debug!( - providers = ?client.provider_names(), - default = ?client.default_provider(), - "LLM client initialized from typed credentials" - ); - - ( - ClientRegistrationReport { - client, - registration_issues, - }, - None, - ) - } - - /// Register a provider adapter. Calls `initialize()` on the adapter - /// (Section 2.4). - /// - /// # Errors - /// - /// Returns `Error` if the adapter's `initialize()` method fails. - pub async fn register_provider( - &mut self, - adapter: Arc, - ) -> Result<(), Error> { - adapter.initialize().await?; - let name = adapter.name().to_string(); - if self.default_provider.is_none() { - self.default_provider = Some(name.clone()); - } - self.providers.insert(name.clone(), adapter); - debug!(provider = %name, "Provider registered"); - Ok(()) - } - - /// Add middleware. - pub fn add_middleware(&mut self, mw: Arc) { - self.middleware.push(mw); - } - - fn canonical_provider_name(&self, provider_name: &str) -> String { - self.catalog - .as_ref() - .and_then(|catalog| catalog.provider(&ProviderId::new(provider_name))) - .map_or_else( - || provider_name.to_string(), - |provider| provider.id.to_string(), - ) - } - - fn provider_adapter(&self, provider_name: &str) -> Option> { - let canonical = self.canonical_provider_name(provider_name); - self.providers.get(&canonical).cloned().or_else(|| { - self.providers.iter().find_map(|(name, adapter)| { - (self.canonical_provider_name(name) == canonical).then(|| Arc::clone(adapter)) - }) - }) - } - - fn eligible_provider_ids(&self) -> HashSet { - self.providers - .keys() - .map(|provider| ProviderId::new(self.canonical_provider_name(provider))) - .collect() - } - - /// Resolve one concrete provider/model offering and canonicalize a cloned - /// request. Explicit-provider unknown models remain passthrough values. - fn resolve_request_with_adapter(&self, request: &Request) -> Result { - let mut resolved = request.clone(); - let Some(catalog) = &self.catalog else { - let provider_name = request - .provider - .as_deref() - .or(self.default_provider.as_deref()) - .ok_or_else(|| Error::Configuration { - message: "No provider specified and no default provider set".into(), - source: None, - })?; - let provider = - self.provider_adapter(provider_name) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{provider_name}' not registered"), - source: None, - })?; - resolved.provider = Some(provider.name().to_string()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - }; - - let eligible = self.eligible_provider_ids(); - if let Some(explicit) = request.provider.as_deref() { - let explicit = ProviderId::new(explicit); - if catalog.provider(&explicit).is_some() { - let selected = catalog - .resolve_selection(Some(&request.model), Some(&explicit), &eligible) - .map_err(selection_error)?; - let provider = self - .provider_adapter(selected.provider.as_str()) - .ok_or_else(|| { - selection_error(ModelSelectionError::ProviderUnavailable { - provider: selected.provider.clone(), - }) - })?; - resolved.model = selected.model; - resolved.provider = Some(selected.provider.into_inner()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - } - - let provider = - self.provider_adapter(explicit.as_str()) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{explicit}' not registered"), - source: None, - })?; - resolved.provider = Some(provider.name().to_string()); - return Ok(ResolvedRequest { - provider, - request: resolved, - }); - } - - match catalog.select(&request.model, None, &eligible) { - Ok(model) => { - let provider = self - .provider_adapter(model.provider.as_str()) - .ok_or_else(|| { - selection_error(ModelSelectionError::ProviderUnavailable { - provider: model.provider.clone(), - }) - })?; - resolved.model = model.id.to_string(); - resolved.provider = Some(model.provider.to_string()); - Ok(ResolvedRequest { - provider, - request: resolved, - }) - } - Err(ModelSelectionError::UnknownSelector { .. }) => { - let provider_name = - self.default_provider - .as_deref() - .ok_or_else(|| Error::Configuration { - message: "No provider specified and no default provider set".into(), - source: None, - })?; - let provider = - self.provider_adapter(provider_name) - .ok_or_else(|| Error::Configuration { - message: format!("Provider '{provider_name}' not registered"), - source: None, - })?; - resolved.provider = Some(self.canonical_provider_name(provider.name())); - Ok(ResolvedRequest { - provider, - request: resolved, - }) - } - Err(error) => Err(selection_error(error)), - } - } - - /// Resolve the concrete provider/model route and return a canonicalized - /// clone of the request without dispatching it. - /// - /// This is useful at persistence and API boundaries that must expose the - /// selected provider alongside the canonical model ID. The caller-owned - /// request is never modified. - pub fn resolve_request(&self, request: &Request) -> Result { - self.resolve_request_with_adapter(request) - .map(|resolved| resolved.request) - } - - fn validate_request_controls(&self, request: &Request) -> Result<(), Error> { - let Some(catalog) = &self.catalog else { - return Ok(()); - }; - let Some(provider) = request.provider.as_deref() else { - return Ok(()); - }; - let Some(model) = catalog.get_on_provider(&ProviderId::new(provider), &request.model) - else { - return Ok(()); - }; - let Some(settings) = catalog.settings_for(model) else { - return Ok(()); - }; - let model_id = model.id.as_str(); - - if let Some(effort) = request.reasoning_effort { - if !settings.controls.reasoning_effort.contains(&effort) { - return Err(Error::InvalidRequest { - message: format!( - "model '{model_id}' does not support reasoning_effort '{effort}'; allowed values: {}", - format_control_values(&settings.controls.reasoning_effort), - ), - }); - } - } - - if let Some(speed) = request.speed { - if speed != Speed::Standard && !settings.controls.speed.contains(&speed) { - return Err(Error::InvalidRequest { - message: format!( - "model '{model_id}' does not support speed '{speed}'; allowed values: standard{}", - format_additional_speeds(&settings.controls.speed), - ), - }); - } - } - - Ok(()) - } - - /// Send a blocking request (Section 4.1). - /// - /// # Errors - /// - /// Returns `Error::InvalidRequest` when a catalog-declared request control - /// is unsupported, `Error::Configuration` if no provider is specified or - /// registered, or any provider/middleware error encountered during the - /// request. - pub async fn complete(&self, request: &Request) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - - if self.middleware.is_empty() { - return complete_stamped(&provider, self.catalog.as_deref(), &request).await; - } - - // Build middleware chain. Cost is stamped at the base so middleware - // observes the final response. - let catalog = self.catalog.clone(); - let base: NextFn = Arc::new(move |req: Request| { - let provider = provider.clone(); - let catalog = catalog.clone(); - Box::pin(async move { complete_stamped(&provider, catalog.as_deref(), &req).await }) - }); - - let chain = self.middleware.iter().rev().fold(base, |next, mw| { - let mw = mw.clone(); - Arc::new(move |req: Request| { - let mw = mw.clone(); - let next = next.clone(); - Box::pin(async move { mw.handle_complete(req, next).await }) - }) - }); - - chain(request).await - } - - /// Send a streaming request (Section 4.2). - /// - /// # Errors - /// - /// Returns `Error::InvalidRequest` when a catalog-declared request control - /// is unsupported, `Error::Configuration` if no provider is specified or - /// registered, or any provider/middleware error encountered during the - /// request. - pub async fn stream(&self, request: &Request) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - - if self.middleware.is_empty() { - return stream_stamped(&provider, self.catalog.clone(), &request).await; - } - - // Build streaming middleware chain. Cost is stamped at the base so - // middleware observes the final Finish events. - let catalog = self.catalog.clone(); - let base: NextStreamFn = Arc::new(move |req: Request| { - let provider = provider.clone(); - let catalog = catalog.clone(); - Box::pin(async move { stream_stamped(&provider, catalog, &req).await }) - }); - - let chain = self.middleware.iter().rev().fold(base, |next, mw| { - let mw = mw.clone(); - Arc::new(move |req: Request| { - let mw = mw.clone(); - let next = next.clone(); - Box::pin(async move { mw.handle_stream(req, next).await }) - }) - }); - - chain(request).await - } - - /// Count the model-visible input/context tokens for a request without - /// creating a completion. - /// - /// # Errors - /// - /// Returns request validation/provider resolution errors, and returns - /// provider count errors when the selected preference requires provider - /// semantics or when the error is not fallback-eligible. - pub async fn count_input_tokens( +impl Observer for RetryNotifier { + fn on_retry( &self, - request: &Request, - preference: InputTokenCountPreference, - ) -> Result { - let ResolvedRequest { provider, request } = self.resolve_request_with_adapter(request)?; - self.validate_request_controls(&request)?; - provider.validate_request(&request)?; - - if preference == InputTokenCountPreference::EstimateOnly { - return Ok(estimate_input_tokens(&request, provider.name())); + call: &Call, + error: &Error, + attempt: u32, + delay: Duration, + stage: RetryStage, + ) { + if let Some(listener) = call.context().extensions().get::() { + listener.notify(RetryNotice { + error: LlmError::from(error), + attempt, + delay, + stage, + }); } + } +} - match provider.count_input_tokens(&request).await { - Ok(Some(count)) => Ok(count), - Ok(None) if preference == InputTokenCountPreference::PreferProvider => { - Ok(fallback_estimate( - &request, - provider.name(), - "provider_token_count_unsupported", - "provider does not support input token counting; returned local estimate", - )) - } - Ok(None) => Err(Error::Configuration { - message: format!( - "provider '{}' does not support input token counting", - provider.name() - ), - source: None, - }), - Err(error) - if preference == InputTokenCountPreference::PreferProvider - && token_count_fallback_eligible(&error) => - { - Ok(fallback_estimate( - &request, - provider.name(), - "provider_token_count_failed", - "provider input token counting failed; returned local estimate", - )) - } - Err(error) => Err(error), +/// The retry middleware Fabro installs: `policy`, reporting each retry to +/// the call's [`RetryListener`]. +pub fn retry_middleware(policy: RetryPolicy) -> RetryMiddleware { + RetryMiddleware::new(policy).observer(RetryNotifier) +} + +/// Options for [`build_client`] and [`build_offline_client`]. +#[derive(Default)] +pub struct ClientOptions { + /// Retry policy for the lithos retry middleware. `None` disables retries. + pub retry: Option, + /// Extra middleware, run after retry and attachment inlining. + pub middleware: Vec>, + /// Custom adapters that replace the built-in adapter for a provider. + pub adapters: Vec<(ProviderId, Arc)>, + /// HTTP client for provider requests. `None` builds lithos's default. + pub http: Option, + /// Whether to inline local file attachments. Off for gateway clients that + /// forward requests to a Fabro server, which inlines them itself. + pub inline_attachments: bool, +} + +impl ClientOptions { + /// Retries and attachment inlining on, nothing else. + #[must_use] + pub fn standard() -> Self { + Self { + retry: Some(default_retry_policy()), + inline_attachments: true, + ..Self::default() } } - /// Close all provider adapters. - /// - /// # Errors - /// - /// Returns any error from a provider adapter's `close()` method. - pub async fn close(&self) -> Result<(), Error> { - for provider in self.providers.values() { - provider.close().await?; + #[must_use] + pub fn with_retry(mut self, policy: Option) -> Self { + self.retry = policy; + self + } + + #[must_use] + pub fn with_middleware(mut self, middleware: Arc) -> Self { + self.middleware.push(middleware); + self + } + + #[must_use] + pub fn with_adapter(mut self, provider: ProviderId, adapter: Arc) -> Self { + self.adapters.push((provider, adapter)); + self + } + + fn adapter_providers(&self) -> impl Iterator { + self.adapters.iter().map(|(provider, _)| provider) + } + + fn apply(self, mut builder: ClientBuilder) -> ClientBuilder { + if let Some(http) = self.http { + builder = builder.http(http); } - Ok(()) + if let Some(policy) = self.retry { + builder = builder.middleware(retry_middleware(policy)); + } + if self.inline_attachments { + builder = builder.middleware(InlineLocalAttachments::new()); + } + for middleware in self.middleware { + builder = builder.middleware_arc(middleware); + } + for (provider, adapter) in self.adapters { + builder = builder.adapter_arc(provider, adapter); + } + builder } +} - /// Get the list of registered provider names. +/// A built client plus what the build learned about provider readiness. +pub struct FabroClient { + pub client: Client, + /// Enabled providers with working credentials, in catalog order. + pub ready: Vec, + /// Enabled providers whose credential material could not be used. + pub auth_issues: Vec<(ProviderId, ResolveError)>, + /// Ready providers lithos could not build an adapter for. + pub build_issues: Vec, +} + +impl FabroClient { + /// Whether `provider` can serve requests through this client. #[must_use] - pub fn provider_names(&self) -> Vec<&str> { - self.providers - .keys() - .map(std::string::String::as_str) - .collect() + pub fn has_provider(&self, provider: &ProviderId) -> bool { + self.client.available_providers().contains(provider) } - /// Canonical IDs for provider adapters that registered successfully. + /// The providers this client can route to. #[must_use] - pub fn provider_ids(&self) -> HashSet { - self.eligible_provider_ids() - } - - /// Check whether a provider adapter is registered. - #[must_use] - pub fn has_provider(&self, name: &str) -> bool { - self.providers.contains_key(name) - || self - .catalog - .as_ref() - .and_then(|catalog| catalog.provider(&ProviderId::new(name))) - .is_some_and(|provider| self.providers.contains_key(provider.id.as_str())) - } - - /// Get the default provider name. - #[must_use] - pub fn default_provider(&self) -> Option<&str> { - self.default_provider.as_deref() + pub fn provider_ids(&self) -> Vec { + self.client.available_providers().iter().cloned().collect() } } -fn selection_error(error: ModelSelectionError) -> Error { - Error::configuration_error(error.to_string(), error) +#[derive(Debug, thiserror::Error)] +pub enum LlmSetupError { + #[error("failed to build the LLM client")] + Build(#[from] ClientBuildError), } -/// Validate, run, and cost-stamp a blocking request. Shared by -/// [`Client::complete`]'s direct path and its middleware-chain base so cost -/// stamping stays single-sited. -async fn complete_stamped( - provider: &Arc, - catalog: Option<&Catalog>, - request: &Request, -) -> Result { - provider.validate_request(request)?; - let mut response = provider.complete(request).await?; - let selected_provider = request - .provider - .as_deref() - .unwrap_or_else(|| provider.name()); - response.model.clone_from(&request.model); - response.provider = selected_provider.to_string(); - cost::apply_estimated_cost( - catalog, - selected_provider, - &request.model, - request.speed, - &mut response, - ); - Ok(response) -} - -/// Validate and run a streaming request, cost-stamping terminal -/// [`StreamEvent::Finish`] responses. Shared by [`Client::stream`]'s direct -/// path and its middleware-chain base so cost stamping stays single-sited. -async fn stream_stamped( - provider: &Arc, - catalog: Option>, - request: &Request, -) -> Result { - provider.validate_request(request)?; - let stream = provider.stream(request).await?; - Ok(stamp_stream_costs( - catalog, - request - .provider - .clone() - .unwrap_or_else(|| provider.name().to_string()), - request.model.clone(), - request.speed, - stream, - )) -} - -/// Wrap a provider event stream so terminal [`StreamEvent::Finish`] -/// responses carry a catalog-estimated cost, mirroring what -/// [`Client::complete`] stamps on blocking responses. -fn stamp_stream_costs( - catalog: Option>, - provider: String, - model: String, - speed: Option, - stream: StreamEventStream, -) -> StreamEventStream { - use futures::StreamExt; - - Box::pin(stream.map(move |event| { - event.map(|mut event| { - if let StreamEvent::Finish { response, .. } | StreamEvent::StepFinish { response, .. } = - &mut event - { - response.model.clone_from(&model); - response.provider.clone_from(&provider); - cost::apply_estimated_cost(catalog.as_deref(), &provider, &model, speed, response); - } - event - }) - })) -} - -fn token_count_fallback_eligible(error: &Error) -> bool { - matches!( - error, - Error::Network { .. } - | Error::RequestTimeout { .. } - | Error::Provider { - kind: ProviderErrorKind::RateLimit | ProviderErrorKind::Server, - .. - } - ) -} - -fn fallback_estimate( - request: &Request, - provider: &str, - code: &'static str, - message: &'static str, -) -> InputTokenCount { - let mut count = estimate_input_tokens(request, provider); - if count.method == InputTokenCountMethod::LocalEstimate - && !count - .warnings - .iter() - .any(|warning| warning.code.as_deref() == Some(code)) - { - count.warnings.push(Warning { - message: message.to_string(), - code: Some(code.to_string()), - }); +/// Builds a client whose ready providers are those the credential source can +/// serve. Credentials are re-read from `source` on every provider attempt. +pub async fn build_client( + catalog: Catalog, + source: Arc, + options: ClientOptions, +) -> Result { + let resolved = source.resolve_all(&catalog).await; + let mut ready = resolved.ready; + for provider in options.adapter_providers() { + if !ready.contains(provider) { + ready.push(provider.clone()); + } } - count + let builder = Client::builder() + .catalog(catalog) + .resolver(FabroResolver) + .credentials_arc(lithos_credentials(source)) + .enabled_providers(ready.iter().cloned()); + let build = options.apply(builder).build()?; + Ok(FabroClient { + client: build.client, + ready, + auth_issues: resolved.auth_issues, + build_issues: build.issues, + }) } -fn format_control_values(values: &[T]) -> String { - if values.is_empty() { - "none".to_string() - } else { - values - .iter() - .map(ToString::to_string) - .collect::>() - .join(", ") - } -} - -fn format_additional_speeds(values: &[Speed]) -> String { - if values.is_empty() { - String::new() - } else { - format!(", {}", format_control_values(values)) - } +/// Builds a client that needs no credentials: every available provider is +/// served by a custom adapter from `options.adapters`, such as the +/// `fabro exec` gateway or a test double. +pub fn build_offline_client( + catalog: Catalog, + options: ClientOptions, +) -> Result { + let ready: Vec = options.adapter_providers().cloned().collect(); + let builder = Client::builder() + .catalog(catalog) + .resolver(FabroResolver) + .enabled_providers(ready.iter().cloned()); + let build = options.apply(builder).build()?; + Ok(FabroClient { + client: build.client, + ready, + auth_issues: Vec::new(), + build_issues: build.issues, + }) } #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use async_trait::async_trait; - use fabro_auth::{ApiKeyHeader, CredentialSource, ResolvedCredentials}; - use fabro_model::ProviderId; - use fabro_model::catalog::LlmCatalogSettings; - use futures::stream; + use fabro_auth::test_support::env_credential_source; use super::*; - use crate::adapter_registry; - use crate::error::ProviderErrorDetail; - use crate::providers::openai_compatible; - use crate::types::*; - - /// A mock provider for testing. - struct MockProvider { - provider_name: String, - response_text: String, - } - - impl MockProvider { - fn new(name: &str, response: &str) -> Self { - Self { - provider_name: name.to_string(), - response_text: response.to_string(), - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for MockProvider { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_mock".into(), - model: "mock-model".into(), - provider: self.provider_name.clone(), - message: Message::assistant(&self.response_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let text = self.response_text.clone(); - let provider = self.provider_name.clone(); - let events = vec![ - Ok(StreamEvent::text_delta(&text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - Response { - id: "resp_mock".into(), - model: "mock-model".into(), - provider, - message: Message::assistant(&text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - fn test_request() -> Request { - Request { - model: "mock-model".into(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - struct CountingProvider { - provider_name: String, - count_result: std::sync::Mutex, Error>>, - count_calls: Arc, - reject_named: bool, - } - - impl CountingProvider { - fn new(result: Result, Error>) -> Self { - Self { - provider_name: "counter".to_string(), - count_result: std::sync::Mutex::new(result), - count_calls: Arc::new(AtomicUsize::new(0)), - reject_named: false, - } - } - - fn with_name(mut self, name: &str) -> Self { - self.provider_name = name.to_string(); - self - } - - fn count_calls(&self) -> Arc { - Arc::clone(&self.count_calls) - } - - fn rejecting_named(mut self) -> Self { - self.reject_named = true; - self - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for CountingProvider { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - !(self.reject_named && mode == "named") - } - - async fn count_input_tokens( - &self, - _request: &Request, - ) -> Result, Error> { - self.count_calls.fetch_add(1, Ordering::SeqCst); - self.count_result.lock().unwrap().clone() - } - } - - fn provider_count(tokens: i64) -> InputTokenCount { - InputTokenCount { - input_tokens: tokens, - method: InputTokenCountMethod::ProviderApi, - provider: "counter".to_string(), - model: "mock-model".to_string(), - warnings: vec![], - } - } - - fn warning_codes(count: &InputTokenCount) -> Vec<&str> { - count - .warnings - .iter() - .filter_map(|warning| warning.code.as_deref()) - .collect() - } - - fn provider_error(kind: ProviderErrorKind) -> Error { - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail::new("provider failed", "counter")), - } - } - - async fn client_with_counting_provider( - provider: CountingProvider, - ) -> (Client, Arc) { - let calls = provider.count_calls(); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.register_provider(Arc::new(provider)).await.unwrap(); - (client, calls) - } - - struct StubSource { - credentials: Vec, - } - - fn catalog_with(overrides: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(overrides).unwrap(); - Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()) - } - - #[async_trait] - impl CredentialSource for StubSource { - async fn resolve(&self, catalog: &Catalog) -> anyhow::Result { - let _ = catalog; - Ok(ResolvedCredentials { - credentials: self.credentials.clone(), - auth_issues: Vec::new(), - }) - } - - async fn configured_providers(&self, catalog: &Catalog) -> Vec { - let _ = catalog; - self.credentials - .iter() - .map(|credential| credential.provider.clone()) - .collect() - } - } + use crate::test_support::test_catalog; #[tokio::test] - async fn complete_routes_to_default_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) + async fn ready_providers_follow_credentials_and_policy() { + let source = env_credential_source(|name| match name { + "OPENAI_API_KEY" | "BEDROCK_API_KEY" => Some("key".to_string()), + _ => None, + }); + let built = build_client(test_catalog(), source, ClientOptions::standard()) .await .unwrap(); - - let response = client.complete(&test_request()).await.unwrap(); - assert_eq!(response.text(), "Hello!"); - assert_eq!(response.provider, "test"); - } - - /// Hermetic catalog pricing `mock-model` under the `test` provider so - /// cost stamping has something to estimate from. - fn priced_mock_catalog() -> Arc { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.test] -display_name = "Test" -adapter = "openai_compatible" -base_url = "https://test.invalid/v1" - -[models."mock-model"] -provider = "test" -display_name = "Mock" -family = "mock" -default = true - -[models."mock-model".limits] -context_window = 100000 - -[models."mock-model".features] -tools = false -vision = false -reasoning = false - -[models."mock-model".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 -"#, - ) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) - } - - fn portable_model_catalog(openrouter_base_url: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://openai.invalid/v1" -priority = 90 - -[providers.openai.models."gpt-5.6-sol"] -display_name = "GPT-5.6 Sol" -family = "gpt-5" -aliases = ["gpt-56-sol"] -default = true - -[providers.openai.models."gpt-5.6-sol".limits] -context_window = 1000 - -[providers.openai.models."gpt-5.6-sol".features] -tools = true -vision = false -reasoning = false - -[providers.openai.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 1.0 -output_cost_per_mtok = 2.0 - -[providers.openrouter] -display_name = "OpenRouter" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "{openrouter_base_url}" -priority = 25 - -[providers.openrouter.models."gpt-5.6-sol"] -api_id = "openai/gpt-5.6-sol" -display_name = "GPT-5.6 Sol (via OpenRouter)" -family = "gpt-5" -aliases = ["gpt-56-sol"] -default = true - -[providers.openrouter.models."gpt-5.6-sol".limits] -context_window = 1000 - -[providers.openrouter.models."gpt-5.6-sol".features] -tools = true -vision = false -reasoning = false - -[providers.openrouter.models."gpt-5.6-sol".costs] -input_cost_per_mtok = 10.0 -output_cost_per_mtok = 20.0 -"#, - )) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) - } - - async fn portable_mock_client(catalog: &Arc, providers: &[&str]) -> Client { - let mut client = Client::new(HashMap::new(), None, vec![]); - for provider in providers { - client - .register_provider(Arc::new(MockProvider::new(provider, provider))) - .await - .unwrap(); - } - client.catalog = Some(Arc::clone(catalog)); - client - } - - #[tokio::test] - async fn shared_alias_selects_by_ready_providers_and_priority_without_mutating_request() { - let catalog = portable_model_catalog("https://openrouter.invalid/v1"); - let mut original = test_request(); - original.model = "gpt-56-sol".to_string(); - - let direct = portable_mock_client(&catalog, &["openai"]).await; - let direct_request = direct.resolve_request(&original).unwrap(); - assert_eq!(direct_request.model, "gpt-5.6-sol"); - assert_eq!(direct_request.provider.as_deref(), Some("openai")); - - let aggregator = portable_mock_client(&catalog, &["openrouter"]).await; - let aggregator_request = aggregator.resolve_request(&original).unwrap(); - assert_eq!(aggregator_request.model, "gpt-5.6-sol"); - assert_eq!(aggregator_request.provider.as_deref(), Some("openrouter")); - - let both = portable_mock_client(&catalog, &["openrouter", "openai"]).await; - let both_request = both.resolve_request(&original).unwrap(); - assert_eq!(both_request.provider.as_deref(), Some("openai")); - - assert_eq!(original.model, "gpt-56-sol"); - assert_eq!(original.provider, None); - - let response = aggregator.complete(&original).await.unwrap(); - assert_eq!(response.model, "gpt-5.6-sol"); - assert_eq!(response.provider, "openrouter"); - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert_eq!(response.cost_usd, Some(0.0005)); - } - - #[tokio::test] - async fn explicit_provider_pins_shared_alias_and_preserves_unknown_passthrough() { - let catalog = portable_model_catalog("https://openrouter.invalid/v1"); - let client = portable_mock_client(&catalog, &["openai", "openrouter"]).await; - - let mut aliased = test_request(); - aliased.model = "gpt-56-sol".to_string(); - aliased.provider = Some("openrouter".to_string()); - let resolved = client.resolve_request(&aliased).unwrap(); - assert_eq!(resolved.model, "gpt-5.6-sol"); - assert_eq!(resolved.provider.as_deref(), Some("openrouter")); - - let mut unknown = test_request(); - unknown.model = "provider-private-preview".to_string(); - unknown.provider = Some("openrouter".to_string()); - let resolved = client.resolve_request(&unknown).unwrap(); - assert_eq!(resolved.model, "provider-private-preview"); - assert_eq!(resolved.provider.as_deref(), Some("openrouter")); - } - - #[tokio::test] - async fn selected_offering_api_id_reaches_openai_compatible_wire_request() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/chat/completions") - .json_body_includes(r#"{"model":"openai/gpt-5.6-sol"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-portable", - "model": "openai/gpt-5.6-sol", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = portable_model_catalog(&upstream.base_url()); - let adapter = openai_compatible::Adapter::new("test-key", upstream.base_url()) - .with_name("openrouter") - .with_catalog(Arc::clone(&catalog)); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.register_provider(Arc::new(adapter)).await.unwrap(); - client.catalog = Some(catalog); - let mut request = test_request(); - request.model = "gpt-56-sol".to_string(); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.model, "gpt-5.6-sol"); - assert_eq!(response.provider, "openrouter"); - completion.assert_async().await; - } - - #[tokio::test] - async fn modal_routes_kimi_k3_with_proxy_headers_and_no_bearer_auth() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/v1/chat/completions") - .header("Modal-Key", "wk-test") - .header("Modal-Secret", "ws-test") - .header_missing("Authorization") - .json_body_includes(r#"{"model":"moonshotai/Kimi-K3"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-modal", - "model": "moonshotai/Kimi-K3", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = catalog_with(&format!( - r#" -[providers.modal] -enabled = true -base_url = "{}/v1" -"#, - upstream.base_url() - )); - let modal = ProviderId::new("modal"); - let client = Client::from_credentials( - vec![ApiCredential::with_extra_headers( - modal.clone(), - HashMap::from([ - ("Modal-Key".to_string(), "wk-test".to_string()), - ("Modal-Secret".to_string(), "ws-test".to_string()), - ]), - )], - catalog, - ) - .await - .unwrap(); - let mut request = test_request(); - request.model = "kimi-k3".to_string(); - request.provider = Some(modal.to_string()); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "OK"); - assert_eq!(response.model, "kimi-k3"); - assert_eq!(response.provider, "modal"); - completion.assert_async().await; - } - - #[tokio::test] - async fn fireworks_routes_kimi_k3_fast_to_router_model_id() { - let upstream = httpmock::MockServer::start_async().await; - let completion = upstream - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/inference/v1/chat/completions") - .header("Authorization", "Bearer test-key") - .json_body_includes(r#"{"model":"accounts/fireworks/routers/kimi-k3-fast"}"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "chatcmpl-fireworks", - "model": "accounts/fireworks/routers/kimi-k3-fast", - "choices": [{ - "message": {"role": "assistant", "content": "OK"}, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2 - } - })); - }) - .await; - let catalog = catalog_with(&format!( - r#" -[providers.fireworks] -enabled = true -base_url = "{}/inference/v1" -"#, - upstream.base_url() - )); - let fireworks = ProviderId::new("fireworks"); - let client = Client::from_credentials( - vec![ - ApiCredential::from_api_key(fireworks.clone(), "test-key".to_string(), &catalog) - .unwrap(), - ], - catalog, - ) - .await - .unwrap(); - let mut request = test_request(); - request.model = "kimi-k3-fast".to_string(); - request.provider = Some(fireworks.to_string()); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "OK"); - assert_eq!(response.model, "kimi-k3-fast"); - assert_eq!(response.provider, "fireworks"); - completion.assert_async().await; - } - - #[tokio::test] - async fn complete_stamps_estimated_cost_from_catalog() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let response = client.complete(&test_request()).await.unwrap(); - - // 10 input tokens at $1/MTok + 20 output tokens at $2/MTok. - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - let cost = response.cost_usd.expect("cost should be stamped"); - assert!((cost - 0.000_05).abs() < 1e-12, "got {cost}"); - } - - #[tokio::test] - async fn complete_leaves_cost_unset_without_catalog() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - - let response = client.complete(&test_request()).await.unwrap(); - - assert_eq!(response.cost_usd, None); - assert_eq!(response.cost_source, None); - } - - #[tokio::test] - async fn complete_stamps_cost_beneath_middleware() { - struct Passthrough; - - #[async_trait] - impl Middleware for Passthrough { - async fn handle_complete( - &self, - request: Request, - next: NextFn, - ) -> Result { - next(request).await - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } - } - - let mut client = Client::new(HashMap::new(), None, vec![Arc::new(Passthrough)]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let response = client.complete(&test_request()).await.unwrap(); - - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - } - - #[tokio::test] - async fn stream_stamps_estimated_cost_on_finish() { - use futures::StreamExt; - - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello!"))) - .await - .unwrap(); - client.catalog = Some(priced_mock_catalog()); - - let mut stream = client.stream(&test_request()).await.unwrap(); - let mut finish_response = None; - while let Some(event) = stream.next().await { - if let StreamEvent::Finish { response, .. } = event.unwrap() { - finish_response = Some(response); - } - } - - let response = finish_response.expect("stream should yield a Finish event"); - // MockProvider's Finish usage is zero tokens — priced, just $0. - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert_eq!(response.cost_usd, Some(0.0)); - } - - #[tokio::test] - async fn count_input_tokens_returns_provider_result() { - let (client, calls) = - client_with_counting_provider(CountingProvider::new(Ok(Some(provider_count(42))))) - .await; - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.input_tokens, 42); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - assert_eq!(calls.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_falls_back_for_unsupported_adapter() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", ""))) - .await - .unwrap(); - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&"provider_token_count_unsupported")); - } - - #[tokio::test] - async fn count_input_tokens_require_provider_errors_for_unsupported_adapter() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", ""))) - .await - .unwrap(); - - let error = client - .count_input_tokens(&test_request(), InputTokenCountPreference::RequireProvider) - .await - .unwrap_err(); - - assert!(matches!(error, Error::Configuration { .. })); - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_falls_back_for_eligible_errors() { - let errors = vec![ - Error::Network { - message: "network down".to_string(), - source: None, - }, - Error::RequestTimeout { - message: "timed out".to_string(), - source: None, - }, - provider_error(ProviderErrorKind::RateLimit), - provider_error(ProviderErrorKind::Server), - ]; - - for error in errors { - let (client, _) = - client_with_counting_provider(CountingProvider::new(Err(error))).await; - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&"provider_token_count_failed")); - } - } - - #[tokio::test] - async fn count_input_tokens_prefer_provider_returns_non_fallback_errors() { - let errors = vec![ - provider_error(ProviderErrorKind::InvalidRequest), - provider_error(ProviderErrorKind::Authentication), - provider_error(ProviderErrorKind::AccessDenied), - provider_error(ProviderErrorKind::NotFound), - provider_error(ProviderErrorKind::ContextLength), - provider_error(ProviderErrorKind::ContentFilter), - provider_error(ProviderErrorKind::QuotaExceeded), - Error::Configuration { - message: "bad config".to_string(), - source: None, - }, - Error::UnsupportedToolChoice { - message: "bad tool choice".to_string(), - }, - ]; - - for error in errors { - let (client, _) = - client_with_counting_provider(CountingProvider::new(Err(error))).await; - let err = client - .count_input_tokens(&test_request(), InputTokenCountPreference::PreferProvider) - .await - .unwrap_err(); - - assert!(!token_count_fallback_eligible(&err)); - } - } - - #[tokio::test] - async fn count_input_tokens_require_provider_returns_fallback_eligible_errors() { - let (client, _) = client_with_counting_provider(CountingProvider::new(Err( - provider_error(ProviderErrorKind::RateLimit), - ))) - .await; - - let err = client - .count_input_tokens(&test_request(), InputTokenCountPreference::RequireProvider) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - } - - #[tokio::test] - async fn count_input_tokens_estimate_only_does_not_call_adapter() { - let provider = CountingProvider::new(Ok(Some(provider_count(99)))); - let calls = provider.count_calls(); - let (client, _) = client_with_counting_provider(provider).await; - - let count = client - .count_input_tokens(&test_request(), InputTokenCountPreference::EstimateOnly) - .await - .unwrap(); - - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn count_input_tokens_validation_errors_still_return_err() { - let (client, calls) = client_with_counting_provider( - CountingProvider::new(Ok(Some(provider_count(1)))) - .with_name("restricted") - .rejecting_named(), - ) - .await; - let mut request = test_request(); - request.tool_choice = Some(ToolChoice::named("search")); - - let err = client - .count_input_tokens(&request, InputTokenCountPreference::PreferProvider) - .await - .unwrap_err(); - - assert!(matches!(err, Error::UnsupportedToolChoice { .. })); - assert_eq!(calls.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn complete_routes_to_named_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("provider_a", "from A"))) - .await - .unwrap(); - client - .register_provider(Arc::new(MockProvider::new("provider_b", "from B"))) - .await - .unwrap(); - - let mut req = test_request(); - req.provider = Some("provider_b".into()); - let response = client.complete(&req).await.unwrap(); - assert_eq!(response.text(), "from B"); - } - - #[tokio::test] - async fn complete_errors_on_missing_provider() { - let client = Client::new(HashMap::new(), None, vec![]); - let result = client.complete(&test_request()).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_errors_on_unknown_provider() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "Hello"))) - .await - .unwrap(); - - let mut req = test_request(); - req.provider = Some("nonexistent".into()); - let result = client.complete(&req).await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_rejects_unsupported_reasoning_effort_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new( - "moonshot", - "should not dispatch", - ))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "kimi-k2.5".to_string(); - request.provider = Some("moonshot".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - - let err = client.complete(&request).await.unwrap_err(); - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'kimi-k2.5' does not support reasoning_effort 'high'") - )); - } - - #[tokio::test] - async fn complete_accepts_supported_kimi_k3_reasoning_effort() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("moonshot", "accepted"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "kimi-k3".to_string(); - request.provider = Some("moonshot".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "accepted"); - } - - #[tokio::test] - async fn complete_rejects_unsupported_speed_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Fast); - - let err = client.complete(&request).await.unwrap_err(); - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'gpt-5.4' does not support speed 'fast'") - )); - } - - #[tokio::test] - async fn complete_accepts_standard_speed_without_catalog_declaration() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "standard"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Standard); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "standard"); - } - - #[tokio::test] - async fn complete_accepts_reasoning_effort_for_anthropic_budget_fallback_model() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("anthropic", "accepted"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "claude-sonnet-4-5".to_string(); - request.provider = Some("anthropic".to_string()); - request.reasoning_effort = Some(ReasoningEffort::Low); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "accepted"); - } - - #[tokio::test] - async fn complete_skips_control_validation_for_unknown_model_passthrough() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "passthrough"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "custom-model".to_string(); - request.provider = Some("openai".to_string()); - request.reasoning_effort = Some(ReasoningEffort::High); - request.speed = Some(Speed::Fast); - - let response = client.complete(&request).await.unwrap(); - - assert_eq!(response.text(), "passthrough"); - } - - #[tokio::test] - async fn stream_rejects_unsupported_speed_before_dispatch() { - let catalog = Arc::new(Catalog::from_builtin().unwrap()); - let mut client = Client::new(HashMap::new(), None, vec![]); - client.catalog = Some(Arc::clone(&catalog)); - client - .register_provider(Arc::new(MockProvider::new("openai", "should not dispatch"))) - .await - .unwrap(); - - let mut request = test_request(); - request.model = "gpt-5.4".to_string(); - request.provider = Some("openai".to_string()); - request.speed = Some(Speed::Fast); - - let Err(err) = client.stream(&request).await else { - panic!("unsupported speed should fail before stream dispatch"); - }; - - assert!(matches!( - err, - Error::InvalidRequest { - ref message, - } if message.contains("model 'gpt-5.4' does not support speed 'fast'") - )); - } - - #[tokio::test] - async fn from_credentials_registers_multiple_providers() { - let catalog = catalog_with(""); - let client = Client::from_credentials( - vec![ - ApiCredential { - provider: ProviderId::anthropic(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "anthropic-key".to_string(), - }), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ApiCredential { - provider: ProviderId::openai(), - auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ], - catalog, - ) - .await - .unwrap(); - - let mut providers = client.provider_names(); - providers.sort_unstable(); - assert_eq!(providers, vec!["anthropic", "openai"]); - assert_eq!(client.default_provider(), Some("anthropic")); - } - - #[tokio::test] - async fn from_credentials_supports_builtin_openai_compatible_providers() { - let catalog = catalog_with(""); - let client = Client::from_credentials( - vec![ApiCredential { - provider: ProviderId::new("moonshot"), - auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - catalog, - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["moonshot"]); - assert_eq!(client.default_provider(), Some("moonshot")); - } - - #[tokio::test] - async fn from_credentials_rejects_custom_provider_id_without_adapter() { - let catalog = catalog_with(""); - let result = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("custom"), - auth_header: Some(ApiKeyHeader::Bearer("custom-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - catalog, - ) - .await; - let Err(err) = result else { - panic!("custom provider credentials should fail without a registered adapter"); - }; - - assert!(matches!( - err, - Error::Configuration { - ref message, - .. - } if message == "Provider \"custom\" is not supported by credential-only registration" - )); - } - - #[tokio::test] - async fn from_credentials_report_skips_provider_that_cannot_register() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - let report = Client::from_credentials_report( - vec![ - ApiCredential { - provider: ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ApiCredential { - provider: ProviderId::openai(), - auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }, - ], - Arc::clone(&catalog), - ) - .await; - - assert_eq!(report.client.provider_names(), vec!["openai"]); - assert_eq!(report.registration_issues.len(), 1); - assert_eq!( - report.registration_issues[0].provider, - ProviderId::new("acme") - ); + assert!(built.has_provider(&ProviderId::new("openai"))); assert!( - report.registration_issues[0] - .error - .to_string() - .contains("uses openai_compatible adapter but does not configure base_url") + !built.has_provider(&ProviderId::new("bedrock")), + "disabled providers never become ready" ); - } - - #[tokio::test] - async fn from_source_registers_provider_from_resolved_credentials() { - let source = StubSource { - credentials: vec![ApiCredential { - provider: ProviderId::anthropic(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "anthropic-key".to_string(), - }), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - }; - let catalog = catalog_with(""); - - let client = Client::from_source(&source, catalog).await.unwrap(); - - assert_eq!(client.provider_names(), vec!["anthropic"]); - } - - #[tokio::test] - async fn from_credentials_registers_custom_openai_compatible_provider() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["acme"]); - assert!(client.has_provider("acme")); - assert!(client.has_provider("acme-ai")); - } - - #[tokio::test] - async fn resolve_provider_accepts_catalog_provider_alias() { - let catalog = catalog_with( - r#" -[providers.acme] -display_name = "Acme" -adapter = "openai_compatible" -agent_profile = "openai" -base_url = "https://api.acme.test/v1" -aliases = ["acme-ai"] - -[providers.acme.auth] -credentials = ["env:ACME_API_KEY"] - -[models."acme-large"] -provider = "acme" -display_name = "Acme Large" -family = "acme" -default = true - -[models."acme-large".limits] -context_window = 128000 - -[models."acme-large".features] -tools = true -vision = false -reasoning = false -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("acme"), - auth_header: Some(ApiKeyHeader::Bearer("acme-key".to_string())), - extra_headers: HashMap::new(), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - let mut request = test_request(); - request.provider = Some("acme-ai".to_string()); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), "acme"); - } - - /// Build a Client with one registered mock per catalog provider, so - /// dispatch tests can observe which provider a request resolves to. - async fn client_with_all_catalog_providers(catalog: &Arc) -> Client { - let mut client = Client::new(HashMap::new(), None, vec![]); - for provider in catalog.providers() { - client - .register_provider(Arc::new(MockProvider::new(provider.id.as_str(), "ok"))) - .await - .unwrap(); - } - client.catalog = Some(Arc::clone(catalog)); - client - } - - /// For every built-in model selector, live dispatch and catalog selection - /// choose the same provider from the same ready-provider set. - #[tokio::test] - async fn dispatch_agrees_with_resolve_route_for_every_builtin_model() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - let ready_providers = catalog.all_provider_ids(); - - for model in catalog.list(None) { - let selected = catalog - .select(model.id.as_str(), None, &ready_providers) - .expect("built-in model should be selectable"); - let route = adapter_registry::resolve_route(&catalog, selected) - .expect("selected built-in model should resolve to a route"); - let mut request = test_request(); - request.model = model.id.to_string(); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), route.provider.as_str(), "{}", model.id); - } - } - - #[tokio::test] - async fn explicit_provider_wins_over_the_model_route() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - - let mut request = test_request(); - request.model = "gpt-5.4-mini".to_string(); - request.provider = Some("anthropic".to_string()); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), "anthropic"); - } - - #[tokio::test] - async fn unknown_model_falls_back_to_default_provider() { - let catalog = catalog_with(""); - let client = client_with_all_catalog_providers(&catalog).await; - let default = client.default_provider().unwrap().to_string(); - - let mut request = test_request(); - request.model = "model-not-in-any-catalog".to_string(); - - let provider = client - .resolve_request_with_adapter(&request) - .unwrap() - .provider; - - assert_eq!(provider.name(), default); - } - - #[tokio::test] - async fn from_credentials_registers_no_auth_provider_with_extra_headers() { - let catalog = catalog_with( - r#" -[providers.portkey] -display_name = "Portkey Bedrock" -adapter = "anthropic" -agent_profile = "anthropic" -base_url = "https://api.portkey.ai/v1" - -[providers.portkey.extra_headers] -x-portkey-api-key = "pk-live" - -[models."portkey-claude"] -provider = "portkey" -display_name = "Portkey Claude" -family = "claude" -default = true - -[models."portkey-claude".limits] -context_window = 200000 - -[models."portkey-claude".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - - let client = Client::from_credentials( - vec![ApiCredential { - provider: fabro_model::ProviderId::new("portkey"), - auth_header: None, - extra_headers: HashMap::from([( - "x-portkey-api-key".to_string(), - "pk-live".to_string(), - )]), - base_url: None, - codex_mode: false, - org_id: None, - project_id: None, - }], - Arc::clone(&catalog), - ) - .await - .unwrap(); - - assert_eq!(client.provider_names(), vec!["portkey"]); - } - - #[tokio::test] - async fn from_source_supports_empty_credentials() { - let source = StubSource { - credentials: Vec::new(), - }; - let catalog = catalog_with(""); - - let client = Client::from_source(&source, catalog).await.unwrap(); - - assert!(client.provider_names().is_empty()); - } - - #[tokio::test] - async fn register_sets_first_as_default() { - let mut client = Client::new(HashMap::new(), None, vec![]); - assert_eq!(client.default_provider(), None); - - client - .register_provider(Arc::new(MockProvider::new("first", "1"))) - .await - .unwrap(); - assert_eq!(client.default_provider(), Some("first")); - - client - .register_provider(Arc::new(MockProvider::new("second", "2"))) - .await - .unwrap(); - assert_eq!(client.default_provider(), Some("first")); - } - - #[tokio::test] - async fn stream_routes_to_provider() { - use futures::StreamExt; - - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "streamed"))) - .await - .unwrap(); - - let mut stream = client.stream(&test_request()).await.unwrap(); - let first = stream.next().await.unwrap().unwrap(); - match &first { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "streamed"), - other => panic!("Expected TextDelta, got {other:?}"), - } - } - - #[tokio::test] - async fn provider_names_returns_registered() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("alpha", ""))) - .await - .unwrap(); - client - .register_provider(Arc::new(MockProvider::new("beta", ""))) - .await - .unwrap(); - let mut names = client.provider_names(); - names.sort_unstable(); - assert_eq!(names, vec!["alpha", "beta"]); - } - - /// Test middleware gets called - struct UppercaseMiddleware; - - #[async_trait::async_trait] - impl Middleware for UppercaseMiddleware { - async fn handle_complete(&self, request: Request, next: NextFn) -> Result { - let mut response = next(request).await?; - let text = response.text().to_uppercase(); - response.message = Message::assistant(text); - Ok(response) - } - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result { - next(request).await - } - } - - #[tokio::test] - async fn middleware_wraps_complete() { - let mut client = Client::new(HashMap::new(), None, vec![]); - client - .register_provider(Arc::new(MockProvider::new("test", "hello"))) - .await - .unwrap(); - client.add_middleware(Arc::new(UppercaseMiddleware)); - - let response = client.complete(&test_request()).await.unwrap(); - assert_eq!(response.text(), "HELLO"); + assert!(!built.has_provider(&ProviderId::new("anthropic"))); + assert!(built.auth_issues.is_empty()); + assert!(built.build_issues.is_empty(), "{:?}", built.build_issues); } } diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs deleted file mode 100644 index dd8f4a0f2..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/decode.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Response decoding: Anthropic Messages body → canonical `Response`. - -use serde::Deserialize; - -use super::SYNTHETIC_TOOL_NAME; -use super::wire::{ApiResponse, ApiUsage, CountTokensResponse}; -use crate::codec::CodecCtx; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Request, Response, ResponseFormatType, Role, - ThinkingData, TokenCounts, ToolCall, -}; - -pub(super) fn token_counts_from_api_usage(usage: &ApiUsage) -> TokenCounts { - // Anthropic does not expose a separate billed thinking/reasoning token - // count. Thinking tokens are billed as part of `output_tokens`. When - // Anthropic adds a real thinking token field, wire it through and subtract - // it here. - TokenCounts { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - reasoning_tokens: 0, - cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0), - cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0), - } -} - -pub(super) fn map_finish_reason(stop_reason: Option<&str>) -> FinishReason { - match stop_reason { - Some("end_turn" | "stop_sequence") | None => FinishReason::Stop, - Some("max_tokens") => FinishReason::Length, - Some("tool_use") => FinishReason::ToolCalls, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -pub(super) fn parse_content_block(block: &serde_json::Value) -> Option { - match block.get("type")?.as_str()? { - "text" => Some(ContentPart::text(block.get("text")?.as_str()?)), - "tool_use" => Some(ContentPart::ToolCall(ToolCall::new( - block.get("id")?.as_str()?, - block.get("name")?.as_str()?, - block.get("input")?.clone(), - ))), - "thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block.get("thinking")?.as_str()?.to_string(), - signature: block - .get("signature") - .and_then(serde_json::Value::as_str) - .map(String::from), - redacted: false, - })), - "redacted_thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block - .get("data") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(), - signature: None, - redacted: true, - })), - _ => None, - } -} - -/// Convert synthetic `tool_use` content blocks back to text content parts. -/// -/// When `response_format` uses `JsonSchema` mode, the model responds with a -/// `tool_use` block for our synthetic tool. We extract its arguments as a JSON -/// text string. -pub(super) fn convert_synthetic_tool_to_text(content_parts: Vec) -> Vec { - content_parts - .into_iter() - .map(|part| match &part { - ContentPart::ToolCall(tc) if tc.name == SYNTHETIC_TOOL_NAME => { - ContentPart::text(tc.arguments.to_string()) - } - _ => part, - }) - .collect() -} - -/// Check if the request uses `JsonSchema` `response_format`. -pub(super) fn uses_json_schema_format(request: &Request) -> bool { - request - .response_format - .as_ref() - .is_some_and(|f| matches!(f.kind, ResponseFormatType::JsonSchema)) -} - -/// Map a refusal stop reason (Claude Fable 5) to a content-filter provider -/// error. Shared by the response decoder and the stream decoder; the -/// `error_code = "refusal"` marker is what makes it failover-eligible. -pub(super) fn refusal_error( - provider_name: &str, - model: &str, - raw: serde_json::Value, - stop_details: Option<&serde_json::Value>, -) -> Error { - let model_label = if model.is_empty() { "The model" } else { model }; - let message = stop_details - .and_then(|details| details.get("explanation")) - .and_then(serde_json::Value::as_str) - .map_or_else( - || format!("{model_label} refused the request"), - |explanation| format!("{model_label} refused the request: {explanation}"), - ); - - Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider_name.to_string(), - status_code: None, - error_code: Some("refusal".to_string()), - retry_after: None, - raw: Some(raw), - }), - } -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body).map_err(|e| { - Error::network( - format!("failed to parse {} response: {e}", ctx.provider_name), - e, - ) - })?; - let api_resp = ApiResponse::deserialize(&raw).map_err(|e| { - Error::network( - format!("failed to parse {} response: {e}", ctx.provider_name), - e, - ) - })?; - - if api_resp.stop_reason.as_deref() == Some("refusal") { - return Err(refusal_error( - ctx.provider_name, - &api_resp.model, - raw, - api_resp.stop_details.as_ref(), - )); - } - - let content_parts: Vec = api_resp - .content - .iter() - .filter_map(parse_content_block) - .collect(); - - // If we used JsonSchema mode, convert the synthetic tool call back to text. - let json_schema_mode = uses_json_schema_format(ctx.request); - let content_parts = if json_schema_mode { - convert_synthetic_tool_to_text(content_parts) - } else { - content_parts - }; - - let finish_reason = if json_schema_mode { - // The model was forced to call a tool, so stop_reason is "tool_use", - // but from the caller's perspective, the request completed normally. - FinishReason::Stop - } else { - map_finish_reason(api_resp.stop_reason.as_deref()) - }; - - Ok(Response { - id: api_resp.id, - model: api_resp.model, - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage: token_counts_from_api_usage(&api_resp.usage), - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: CountTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse token count response: {e}"), - source: None, - })?; - Ok(response.input_tokens) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn api_token_counts_leaves_reasoning_zero_and_output_full() { - let body = serde_json::json!({ - "id": "msg_test", - "model": "claude-sonnet-4-5", - "content": [ - { "type": "thinking", "thinking": "summary text", "signature": "" }, - { "type": "text", "text": "answer" } - ], - "stop_reason": "end_turn", - "usage": { - "input_tokens": 50, - "output_tokens": 1200, - "cache_read_input_tokens": 9000, - "cache_creation_input_tokens": 1000 - } - }); - let api: ApiResponse = serde_json::from_value(body).unwrap(); - let usage = token_counts_from_api_usage(&api.usage); - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - } - - #[test] - fn convert_synthetic_tool_to_text_replaces_synthetic_tool() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"name": "Alice"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::Text(text) => { - assert!(text.contains("Alice")); - } - other => panic!("expected Text, got {other:?}"), - } - } - - #[test] - fn convert_synthetic_tool_to_text_preserves_other_tool_calls() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - "real_tool", - serde_json::json!({"key": "value"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "real_tool"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs deleted file mode 100644 index 5ed999f87..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/encode.rs +++ /dev/null @@ -1,1594 +0,0 @@ -//! Request encoding: canonical request → Anthropic Messages body + headers. -//! -//! Pure and sync. File-backed attachments are resolved to inline data by -//! `attachments::resolve` in the adapter *before* encode runs, so the content -//! translation here never touches the filesystem. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::SYNTHETIC_TOOL_NAME; -use super::wire::{ApiMessage, ApiRequest, ApiToolDef, CountTokensRequest}; -use crate::codec::cache::{self, CacheControl}; -use crate::codec::{AnthropicVersion, CodecCtx, EncodedRequest, extract_system_prompt}; -use crate::types::{ - ContentPart, Message, ReasoningEffort, ReasoningEffortFeature, Request, ResponseFormatType, - Role, Speed, ThinkingData, ToolChoice, ToolDefinition, -}; - -const CACHE_BETA_HEADER: &str = "prompt-caching-2024-07-31"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; - -/// Known `provider_options.anthropic` keys handled directly by the codec; not -/// re-merged into the body. -const KNOWN_ANTHROPIC_OPTION_KEYS: &[&str] = &["thinking", "auto_cache", "beta_headers"]; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - let request = build_request(ctx, stream); - let body = merge_provider_options(&request, ctx.request.provider_options.as_ref()); - EncodedRequest { - body, - endpoint: "/messages".to_string(), - headers: build_headers(ctx), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - let count_request = CountTokensRequest::from(build_request(ctx, false)); - let body = serde_json::to_value(&count_request).unwrap_or_else(|_| serde_json::json!({})); - EncodedRequest { - body, - endpoint: "/messages/count_tokens".to_string(), - headers: build_headers(ctx), - } -} - -/// Whether auto prompt-caching applies: the model supports it and the request -/// hasn't opted out. -fn auto_cache(ctx: &CodecCtx<'_>) -> bool { - ctx.model.is_some_and(|m| m.features.prompt_cache) - && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), "anthropic") -} - -fn build_headers(ctx: &CodecCtx<'_>) -> Vec<(String, String)> { - let mut headers = Vec::new(); - if let AnthropicVersion::Header(version) = ctx.params.anthropic_version { - headers.push(("anthropic-version".to_string(), version.to_string())); - } - if ctx.params.anthropic_beta { - if let Some(beta) = build_beta_header( - ctx.request.provider_options.as_ref(), - auto_cache(ctx), - ctx.request.speed == Some(Speed::Fast), - ) { - headers.push(("anthropic-beta".to_string(), beta)); - } - } - headers -} - -fn build_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest { - let request = ctx.request; - let (system, other_messages) = extract_system_prompt(&request.messages); - let mut api_messages = translate_messages(&other_messages); - - // `ToolChoice::None` omits the tools entirely instead of sending a choice. - let omit_tools = matches!(request.tool_choice, Some(ToolChoice::None)); - let mut tool_choice_json = if omit_tools { - None - } else { - request.tool_choice.as_ref().and_then(translate_tool_choice) - }; - - let mut api_tools = if omit_tools { - None - } else { - request.tools.as_ref().map(|t| translate_tools(t)) - }; - - let model_info = ctx.model; - let auto_cache = auto_cache(ctx); - - let mut system_value = system.and_then(|s| { - if s.trim().is_empty() { - None - } else if auto_cache { - Some(system_with_cache_control(&s)) - } else { - Some(serde_json::Value::String(s)) - } - }); - - // Apply response_format (may inject synthetic tool or system prompt suffix). - apply_response_format( - request, - &mut api_tools, - &mut tool_choice_json, - &mut system_value, - ); - - if auto_cache { - if let Some(ref mut tools) = api_tools { - apply_cache_control_to_last_tool(tools); - } - apply_cache_control_to_conversation_prefix(&mut api_messages); - } - - let explicit_thinking = extract_thinking_config(request.provider_options.as_ref()); - - // Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with - // `budget_tokens` instead of `output_config.effort`. - let supports_effort = model_info.is_none_or(fabro_model::Model::supports_reasoning_effort); - - let mut resolved_max_tokens = request - .max_tokens - .or_else(|| model_info.and_then(|m| m.limits.max_output)) - .unwrap_or(65536); - - // Default thinking when none is configured explicitly: adaptive for - // `levels` models, with or without an effort level — effort is guidance - // for thinking allocation, not a replacement for it. Natively adaptive - // models don't need one injected (and reject a manual on/off toggle). - let default_thinking = || { - if model_info.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels) - { - Some(serde_json::json!({"type": "adaptive"})) - } else { - None - } - }; - - let (mut thinking, mut output_config) = if let Some(effort) = &request.reasoning_effort { - if supports_effort { - ( - explicit_thinking.or_else(default_thinking), - Some(serde_json::json!({"effort": <&'static str>::from(*effort)})), - ) - } else if explicit_thinking.is_none() { - let budget = effort_to_budget_tokens(*effort, resolved_max_tokens); - if resolved_max_tokens <= budget { - resolved_max_tokens = budget + 1024; - } - ( - Some(serde_json::json!({"type": "enabled", "budget_tokens": budget})), - None, - ) - } else { - (explicit_thinking, None) - } - } else { - (explicit_thinking.or_else(default_thinking), None) - }; - - if tool_choice_forces_tool_use(tool_choice_json.as_ref()) { - thinking = None; - output_config = None; - } - - // Models with `sampling_params = false` reject classic sampling knobs. - // This gate covers only the typed request fields; values injected through - // `provider_options.anthropic` (e.g. `top_k`) are a raw escape hatch and - // pass through unfiltered. - let (temperature, top_p) = - if model_info.is_none_or(fabro_model::Model::supports_sampling_params) { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - ApiRequest { - model: ctx.deployment_id.to_string(), - messages: api_messages, - max_tokens: resolved_max_tokens, - system: system_value, - temperature, - top_p, - stop_sequences: request.stop_sequences.clone().unwrap_or_default(), - tools: api_tools, - tool_choice: tool_choice_json, - thinking, - output_config, - speed: request - .speed - .filter(|speed| *speed != Speed::Standard) - .map(<&'static str>::from) - .map(str::to_string), - metadata: request.metadata.clone(), - stream, - } -} - -// --- Content / message / tool translation ------------------------------------ - -/// Translate a unified `ContentPart` to an Anthropic content block. Sync: -/// file-backed attachments are already resolved to inline data upstream. -fn content_part_to_api(part: &ContentPart) -> Option { - match part { - ContentPart::Text(text) => Some(serde_json::json!({"type": "text", "text": text})), - ContentPart::ToolCall(tc) => Some(serde_json::json!({ - "type": "tool_use", - "id": tc.id, - "name": tc.name, - "input": tc.arguments, - })), - ContentPart::ToolResult(tr) => { - let content = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - Some(serde_json::json!({ - "type": "tool_result", - "tool_use_id": tr.tool_call_id, - "content": content, - "is_error": tr.is_error, - })) - } - ContentPart::Thinking(td) if td.redacted => Some(serde_json::json!({ - "type": "redacted_thinking", - "data": td.text, - })), - ContentPart::Thinking(ThinkingData { - text, signature, .. - }) => { - let mut block = serde_json::json!({ "type": "thinking", "thinking": text }); - if let Some(sig) = signature { - block["signature"] = serde_json::Value::String(sig.clone()); - } - Some(block) - } - ContentPart::Image(img) => media_block( - "image", - img.url.as_deref(), - img.data.as_deref(), - img.media_type.as_deref().unwrap_or("image/png"), - ), - ContentPart::Document(doc) => media_block( - "document", - doc.url.as_deref(), - doc.data.as_deref(), - doc.media_type.as_deref().unwrap_or("application/pdf"), - ), - ContentPart::Audio(_) => Some( - serde_json::json!({"type": "text", "text": "[Audio content not supported by this provider]"}), - ), - ContentPart::Other { .. } => None, - } -} - -/// An `image`/`document` content block: URL source when present, otherwise -/// base64-encoded inline data. -fn media_block( - kind: &str, - url: Option<&str>, - data: Option<&[u8]>, - mime: &str, -) -> Option { - if let Some(url) = url { - Some(serde_json::json!({"type": kind, "source": {"type": "url", "url": url}})) - } else { - data.map(|data| { - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"type": kind, "source": {"type": "base64", "media_type": mime, "data": b64}}) - }) - } -} - -/// Convert unified messages to Anthropic API messages (role mapping, strict -/// alternation, tool results folded into user turns). -fn translate_messages(messages: &[&Message]) -> Vec { - let mut api_messages: Vec = Vec::new(); - - for msg in messages { - let role = match msg.role { - Role::Assistant => "assistant", - Role::User | Role::Tool => "user", - Role::System | Role::Developer => continue, - }; - - let mut content = Vec::new(); - for part in &msg.content { - if let Some(block) = content_part_to_api(part) { - content.push(block); - } - } - - if content.is_empty() { - continue; - } - - if let Some(last) = api_messages.last_mut() { - if last.role == role { - last.content.extend(content); - continue; - } - } - - api_messages.push(ApiMessage { - role: role.to_string(), - content, - }); - } - - api_messages -} - -fn translate_tools(tools: &[ToolDefinition]) -> Vec { - tools - .iter() - .map(|t| ApiToolDef { - name: t.name.clone(), - description: t.description.clone(), - input_schema: t.parameters.clone(), - cache_control: None, - }) - .collect() -} - -fn translate_tool_choice(choice: &ToolChoice) -> Option { - match choice { - ToolChoice::Auto => Some(serde_json::json!({"type": "auto"})), - // Anthropic does not support tool_choice none with tools present; the - // caller omits tools instead. - ToolChoice::None => None, - ToolChoice::Required => Some(serde_json::json!({"type": "any"})), - ToolChoice::Named { tool_name } => { - Some(serde_json::json!({"type": "tool", "name": tool_name})) - } - } -} - -fn tool_choice_forces_tool_use(tool_choice: Option<&serde_json::Value>) -> bool { - matches!( - tool_choice - .and_then(|value| value.get("type")) - .and_then(serde_json::Value::as_str), - Some("any" | "tool") - ) -} - -// --- Structured output (response_format) ------------------------------------- - -fn apply_response_format( - request: &Request, - api_tools: &mut Option>, - tool_choice: &mut Option, - system: &mut Option, -) { - let Some(format) = &request.response_format else { - return; - }; - - match format.kind { - ResponseFormatType::JsonSchema => { - let schema = format - .json_schema - .clone() - .unwrap_or_else(|| serde_json::json!({"type": "object"})); - let synthetic_tool = ApiToolDef { - name: SYNTHETIC_TOOL_NAME.to_string(), - description: "Output the requested structured data".to_string(), - input_schema: schema, - cache_control: None, - }; - match api_tools { - Some(tools) => tools.push(synthetic_tool), - None => *api_tools = Some(vec![synthetic_tool]), - } - *tool_choice = Some(serde_json::json!({"type": "tool", "name": SYNTHETIC_TOOL_NAME})); - } - ResponseFormatType::JsonObject => { - let json_instruction = "\n\nYou must respond with valid JSON only, no other text."; - match system { - Some(serde_json::Value::Array(blocks)) => { - if let Some(last) = blocks.last_mut() { - if let Some(text) = last.get("text").and_then(serde_json::Value::as_str) { - let mut new_text = text.to_string(); - new_text.push_str(json_instruction); - last["text"] = serde_json::Value::String(new_text); - } - } else { - blocks.push( - serde_json::json!({"type": "text", "text": json_instruction.trim()}), - ); - } - } - Some(serde_json::Value::String(s)) => { - s.push_str(json_instruction); - } - None => { - *system = Some(serde_json::Value::String( - json_instruction.trim().to_string(), - )); - } - _ => {} - } - } - ResponseFormatType::Text => {} - } -} - -// --- Prompt caching / thinking / beta headers -------------------------------- - -/// The `provider_options.anthropic` namespace object, if any. -fn anthropic_options(provider_options: Option<&serde_json::Value>) -> Option<&serde_json::Value> { - provider_options.and_then(|opts| opts.get("anthropic")) -} - -/// A single `provider_options.anthropic.` value, if any. `pub(crate)` so -/// the adapter's `validate_request` reads the same namespace the same way. -pub(crate) fn anthropic_option<'a>( - provider_options: Option<&'a serde_json::Value>, - key: &str, -) -> Option<&'a serde_json::Value> { - anthropic_options(provider_options).and_then(|anthropic| anthropic.get(key)) -} - -fn extract_thinking_config( - provider_options: Option<&serde_json::Value>, -) -> Option { - anthropic_option(provider_options, "thinking").cloned() -} - -fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 { - let budget = match effort { - ReasoningEffort::Low => max_tokens / 4, - ReasoningEffort::Medium => max_tokens / 2, - ReasoningEffort::High => max_tokens * 3 / 4, - ReasoningEffort::XHigh => max_tokens * 7 / 8, - ReasoningEffort::Max => max_tokens, - }; - budget.max(1024) -} - -fn system_with_cache_control(system: &str) -> serde_json::Value { - serde_json::json!([{ - "type": "text", - "text": system, - "cache_control": {"type": "ephemeral"} - }]) -} - -fn apply_cache_control_to_last_tool(tools: &mut [ApiToolDef]) { - if let Some(last) = tools.last_mut() { - last.cache_control = Some(CacheControl::ephemeral()); - } -} - -fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { - let user_turns: Vec = messages.iter().map(|m| m.role == "user").collect(); - let Some(target_idx) = cache::conversation_breakpoint_index(&user_turns) else { - return; - }; - if let Some(serde_json::Value::Object(map)) = messages[target_idx].content.last_mut() { - map.insert( - "cache_control".to_string(), - serde_json::json!({"type": "ephemeral"}), - ); - } -} - -fn build_beta_header( - provider_options: Option<&serde_json::Value>, - include_cache_header: bool, - include_fast_mode_header: bool, -) -> Option { - let mut headers: Vec = Vec::new(); - - if let Some(beta_array) = - anthropic_option(provider_options, "beta_headers").and_then(serde_json::Value::as_array) - { - headers.extend( - beta_array - .iter() - .filter_map(serde_json::Value::as_str) - .map(String::from), - ); - } - - if include_cache_header && !headers.iter().any(|h| h == CACHE_BETA_HEADER) { - headers.push(CACHE_BETA_HEADER.to_string()); - } - - if include_fast_mode_header && !headers.iter().any(|h| h == FAST_MODE_BETA_HEADER) { - headers.push(FAST_MODE_BETA_HEADER.to_string()); - } - - if headers.is_empty() { - None - } else { - Some(headers.join(",")) - } -} - -/// Serialize the API request and merge any unknown `provider_options.anthropic` -/// keys into the body. -fn merge_provider_options( - api_request: &ApiRequest, - provider_options: Option<&serde_json::Value>, -) -> serde_json::Value { - let mut body = serde_json::to_value(api_request).unwrap_or_else(|_| serde_json::json!({})); - - if let Some(anthropic_opts) = anthropic_options(provider_options) { - if let (Some(base), Some(overrides)) = (body.as_object_mut(), anthropic_opts.as_object()) { - for (key, value) in overrides { - if !KNOWN_ANTHROPIC_OPTION_KEYS.contains(&key.as_str()) { - base.insert(key.clone(), value.clone()); - } - } - } - } - - body -} - -#[cfg(test)] -mod tests { - use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; - - use super::*; - use crate::codec::CodecParams; - use crate::providers::common; - use crate::types::{AudioData, DocumentData, ResponseFormat}; - - // --- Test helpers -------------------------------------------------------- - - fn make_base_request() -> Request { - Request { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![Message::user("Hello")], - provider: Some("anthropic".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn make_request_with_format(format: ResponseFormat) -> Request { - Request { - provider: None, - response_format: Some(format), - max_tokens: None, - ..make_base_request() - } - } - - fn catalog_with_anthropic_model(features: &str) -> Catalog { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -{features} -"# - )) - .unwrap(); - Catalog::from_settings(&settings).unwrap() - } - - /// Direct-Anthropic route params (version header + beta headers enabled), - /// matching what the adapter's `route_config()` resolves for "anthropic". - fn direct_params() -> CodecParams { - CodecParams { - anthropic_version: AnthropicVersion::Header("2023-06-01"), - anthropic_beta: true, - ..CodecParams::default() - } - } - - /// Encode `request` on the direct-Anthropic route, optionally with a - /// catalog (for capability-driven behavior like prompt-cache/effort). - fn encode_direct(request: &Request, catalog: Option<&Catalog>, stream: bool) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); - let params = direct_params(); - let ctx = CodecCtx { - request, - provider_name: "anthropic", - deployment_id: &deployment_id, - model: common::catalog_model(catalog, "anthropic", &request.model), - params: ¶ms, - }; - encode(&ctx, stream) - } - - fn encode_count_direct(request: &Request, catalog: Option<&Catalog>) -> EncodedRequest { - let deployment_id = common::api_model_id(catalog, "anthropic", &request.model); - let params = direct_params(); - let ctx = CodecCtx { - request, - provider_name: "anthropic", - deployment_id: &deployment_id, - model: common::catalog_model(catalog, "anthropic", &request.model), - params: ¶ms, - }; - encode_count_tokens(&ctx) - } - - fn header_value<'a>(encoded: &'a EncodedRequest, name: &str) -> Option<&'a str> { - encoded - .headers - .iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.as_str()) - } - - // --- prompt-cache helpers ------------------------------------------------ - - #[test] - fn system_prompt_cache_control_wraps_as_array() { - let result = system_with_cache_control("You are helpful."); - let arr = result.as_array().expect("should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["type"], "text"); - assert_eq!(arr[0]["text"], "You are helpful."); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_cache_control_applied_to_last_tool() { - let mut tools = vec![ - ApiToolDef { - name: "tool_a".to_string(), - description: "first".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ApiToolDef { - name: "tool_b".to_string(), - description: "second".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ]; - apply_cache_control_to_last_tool(&mut tools); - - assert!(tools[0].cache_control.is_none()); - assert!(tools[1].cache_control.is_some()); - assert_eq!(tools[1].cache_control.as_ref().unwrap().kind, "ephemeral"); - } - - #[test] - fn tool_cache_control_empty_slice() { - let mut tools: Vec = vec![]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools.is_empty()); - } - - #[test] - fn tool_cache_control_single_tool() { - let mut tools = vec![ApiToolDef { - name: "only_tool".to_string(), - description: "the one".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools[0].cache_control.is_some()); - } - - #[test] - fn conversation_prefix_cache_control_with_two_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hi there"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "How are you?"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // First user message should have cache_control - assert_eq!(messages[0].content[0]["cache_control"]["type"], "ephemeral"); - // Last user message should NOT have cache_control - assert!(messages[2].content[0].get("cache_control").is_none()); - // Assistant message should NOT have cache_control - assert!(messages[1].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_with_multiple_content_blocks() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![ - serde_json::json!({"type": "text", "text": "Part 1"}), - serde_json::json!({"type": "text", "text": "Part 2"}), - ], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Follow up"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the LAST content block of the first user message should have - // cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[0].content[1]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn conversation_prefix_cache_control_single_user_message() { - let mut messages = vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // With only one user message, no cache_control should be added - assert!(messages[0].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_no_user_messages() { - let mut messages: Vec = vec![]; - // Should not panic on empty messages - apply_cache_control_to_conversation_prefix(&mut messages); - } - - #[test] - fn conversation_prefix_cache_control_three_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "First"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 1"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Second"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 2"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Third"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the second-to-last user message (index 2) should get cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[2].content[0]["cache_control"]["type"], "ephemeral"); - assert!(messages[4].content[0].get("cache_control").is_none()); - } - - // --- beta headers -------------------------------------------------------- - - #[test] - fn beta_header_includes_cache_header() { - let result = build_beta_header(None, true, false); - assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); - } - - #[test] - fn beta_header_no_cache_no_user_headers() { - let result = build_beta_header(None, false, false); - assert_eq!(result, None); - } - - #[test] - fn beta_header_merges_user_headers_with_cache() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let result = build_beta_header(Some(&opts), true, false); - assert_eq!( - result, - Some(format!( - "interleaved-thinking-2025-05-14,{CACHE_BETA_HEADER}" - )) - ); - } - - #[test] - fn beta_header_no_duplicate_cache_header() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": [CACHE_BETA_HEADER] - } - }); - let result = build_beta_header(Some(&opts), true, false); - // Should not duplicate the header - assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); - } - - #[test] - fn beta_header_user_headers_only_when_cache_disabled() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let result = build_beta_header(Some(&opts), false, false); - assert_eq!(result, Some("interleaved-thinking-2025-05-14".to_string())); - } - - /// Regression test: deprecated beta header values must not be sent. - /// The Anthropic API rejects requests containing these old headers. - #[test] - fn beta_header_rejects_deprecated_values() { - let deprecated = [ - "extended-thinking-2025-04-14", - "max-tokens-3-5-sonnet-2025-04-14", - ]; - - // No user headers — only cache header should appear - let header = build_beta_header(None, true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "default header must not contain deprecated value {dep}" - ); - } - - // With a valid user header - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let header = build_beta_header(Some(&opts), true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "header with user values must not contain deprecated value {dep}" - ); - } - } - - #[test] - fn beta_header_includes_both_cache_and_fast_mode() { - let result = build_beta_header(None, true, true); - let header = result.expect("should produce a header"); - assert!( - header.contains(CACHE_BETA_HEADER), - "should contain cache header" - ); - assert!( - header.contains(FAST_MODE_BETA_HEADER), - "should contain fast-mode header" - ); - } - - // --- effort → thinking budget -------------------------------------------- - - #[test] - fn effort_to_budget_tokens_xhigh_maps_to_seven_eighths() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::XHigh, 16_000), - 14_000 - ); - } - - #[test] - fn effort_to_budget_tokens_max_maps_to_full_budget() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::Max, 16_000), - 16_000 - ); - } - - // --- system prompt serialization ----------------------------------------- - - #[test] - fn system_prompt_as_string_when_cache_disabled() { - let system = "You are helpful.".to_string(); - let value = serde_json::Value::String(system); - assert_eq!(value.as_str(), Some("You are helpful.")); - } - - #[test] - fn api_request_serialization_with_cached_system() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: Some(system_with_cache_control("You are helpful.")), - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let json = serde_json::to_value(&api_request).expect("should serialize"); - let system = json.get("system").expect("system should be present"); - let arr = system.as_array().expect("system should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - // --- response_format ------------------------------------------------------ - - #[test] - fn response_format_json_schema_injects_synthetic_tool() { - let schema = serde_json::json!({ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"] - }); - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema.clone()), - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, SYNTHETIC_TOOL_NAME); - assert_eq!(tools[0].input_schema, schema); - - let tc = tool_choice.expect("tool_choice should be set"); - assert_eq!(tc["type"], "tool"); - assert_eq!(tc["name"], SYNTHETIC_TOOL_NAME); - - // System should not be modified - assert!(system.is_none()); - } - - #[test] - fn tool_choice_forces_tool_use_detects_forced_modes() { - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "any"}) - ))); - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "tool", "name": "json_output"}) - ))); - - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "auto"}) - ))); - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "none"}) - ))); - assert!(!tool_choice_forces_tool_use(None)); - } - - #[test] - fn response_format_json_schema_appends_to_existing_tools() { - let schema = serde_json::json!({"type": "object"}); - let mut request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: false, - }); - request.tools = Some(vec![ToolDefinition { - name: "existing_tool".to_string(), - description: "An existing tool".to_string(), - parameters: serde_json::json!({}), - }]); - - let mut tools: Option> = - Some(translate_tools(request.tools.as_ref().unwrap())); - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, "existing_tool"); - assert_eq!(tools[1].name, SYNTHETIC_TOOL_NAME); - } - - #[test] - fn response_format_json_object_appends_to_string_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(serde_json::Value::String("You are helpful.".to_string())); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - - // Tools should not be modified - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - } - - #[test] - fn response_format_json_object_sets_system_when_none() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_json_object_appends_to_array_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(system_with_cache_control("You are helpful.")); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let arr = sys.as_array().expect("should be an array"); - let text = arr[0]["text"].as_str().expect("should have text"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_text_is_noop() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::Text, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - assert!(system.is_none()); - } - - // --- merge_provider_options ---------------------------------------------- - - #[test] - fn merge_provider_options_passes_through_unknown_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "top_k": 40, - "custom_field": "value" - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - assert_eq!(body["top_k"], 40); - assert_eq!(body["custom_field"], "value"); - } - - #[test] - fn merge_provider_options_skips_known_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: Vec::new(), - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000}, - "auto_cache": false, - "beta_headers": ["some-header"], - "top_k": 40 - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - // Known keys should not be merged (they are handled separately) - assert!(body.get("auto_cache").is_none()); - assert!(body.get("beta_headers").is_none()); - // thinking is handled by the ApiRequest struct directly, should not be - // double-merged - assert!(body["thinking"].is_null()); - // Unknown keys should be merged - assert_eq!(body["top_k"], 40); - } - - // --- content_part_to_api (documents / audio) ----------------------------- - - #[test] - fn document_url_translates_to_url_source() { - let part = ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "url"); - assert_eq!(result["source"]["url"], "https://example.com/doc.pdf"); - } - - #[test] - fn document_base64_data_translates_to_base64_source() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0x25, 0x50, 0x44, 0x46]), - media_type: Some("application/pdf".to_string()), - file_name: Some("test.pdf".to_string()), - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "base64"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - assert!(result["source"]["data"].as_str().is_some()); - } - - #[test] - fn document_base64_defaults_to_pdf_mime() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - } - - #[test] - fn audio_produces_text_fallback() { - let part = ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - }); - let result = content_part_to_api(&part).expect("should produce JSON"); - assert_eq!(result["type"], "text"); - assert_eq!( - result["text"], - "[Audio content not supported by this provider]" - ); - } - - // --- end-to-end encode (formerly build_api_request) ---------------------- - - #[test] - fn build_request_omits_whitespace_only_system_prompt() { - let request = Request { - messages: vec![Message::system(" \n\t"), Message::user("Hello")], - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert!( - encoded.body.get("system").is_none(), - "whitespace-only system prompts should be omitted" - ); - } - - #[test] - fn build_request_maps_reasoning_effort_to_output_config() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!( - encoded.body["output_config"], - serde_json::json!({"effort": "medium"}) - ); - } - - #[test] - fn build_request_disables_prompt_cache_when_model_feature_is_false() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = false -"#, - ); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["system"], - serde_json::json!("Use the cache if supported.") - ); - let beta = header_value(&encoded, "anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), - "cache beta header must not be sent when the model disables prompt cache" - ); - } - - #[test] - fn build_request_without_injected_catalog_does_not_use_builtin_model_metadata() { - let request = Request { - model: "claude-sonnet-4-5".to_string(), - messages: vec![ - Message::system("Do not infer cache support from built-ins."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!( - encoded.body["system"], - serde_json::json!("Do not infer cache support from built-ins.") - ); - let beta = header_value(&encoded, "anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), - "cache beta header must require injected model metadata" - ); - } - - #[test] - fn build_request_enables_prompt_cache_when_model_feature_is_true() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = true -"#, - ); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["system"][0]["cache_control"]["type"], - "ephemeral" - ); - let beta = - header_value(&encoded, "anthropic-beta").expect("cache beta header should be present"); - assert!(beta.contains(CACHE_BETA_HEADER)); - } - - #[test] - fn build_request_uses_adaptive_thinking_for_injected_effort_model_without_forced_tools() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - ); - let request = Request { - model: "test-claude".to_string(), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert_eq!( - encoded.body["thinking"], - serde_json::json!({"type": "adaptive"}) - ); - } - - #[test] - fn build_request_omits_thinking_for_opus_4_7_json_schema() { - let request = Request { - model: "claude-opus-4-7".to_string(), - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"] - })), - strict: true, - }), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("json schema response format should force synthetic tool"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], SYNTHETIC_TOOL_NAME); - assert!( - encoded.body.get("thinking").is_none(), - "forced tool calls must omit thinking" - ); - assert!( - encoded.body.get("output_config").is_none(), - "forced tool calls must omit output_config effort" - ); - } - - #[test] - fn build_request_omits_thinking_for_explicit_named_tool_choice() { - let request = Request { - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Named { - tool_name: "json_output".to_string(), - }), - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "adaptive"} - } - })), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("named tool choice should be translated"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], "json_output"); - assert!( - encoded.body.get("thinking").is_none(), - "forced named tool choice must omit explicit thinking" - ); - } - - #[test] - fn build_request_omits_effort_for_required_tool_choice() { - let request = Request { - model: "claude-opus-4-7".to_string(), - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Required), - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let tool_choice = encoded - .body - .get("tool_choice") - .expect("required tool choice should be translated"); - assert_eq!(tool_choice["type"], "any"); - assert!( - encoded.body.get("output_config").is_none(), - "required tool choice must omit output_config effort" - ); - } - - #[test] - fn build_request_omits_output_config_when_no_reasoning_effort() { - let request = make_base_request(); - let encoded = encode_direct(&request, None, false); - assert!(encoded.body.get("output_config").is_none()); - } - - #[test] - fn build_request_sets_speed() { - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - assert_eq!(encoded.body["speed"], "fast"); - } - - #[test] - fn build_request_serializes_absent_stop_sequences_as_empty_array() { - let request = make_base_request(); - let encoded = encode_direct(&request, None, false); - assert_eq!(encoded.body["stop_sequences"], serde_json::json!([])); - } - - #[test] - fn build_request_injects_fast_mode_beta_header() { - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let encoded = encode_direct(&request, None, false); - let beta = header_value(&encoded, "anthropic-beta") - .expect("anthropic-beta header should be present"); - assert!( - beta.contains(FAST_MODE_BETA_HEADER), - "beta header should contain fast-mode header, got: {beta}" - ); - } - - #[test] - fn build_request_falls_back_to_thinking_budget_for_non_effort_model() { - let catalog = catalog_with_anthropic_model(""); - let request = Request { - model: "test-claude".to_string(), - max_tokens: Some(16_000), - reasoning_effort: Some(ReasoningEffort::XHigh), - ..make_base_request() - }; - - let encoded = encode_direct(&request, Some(&catalog), false); - assert!( - encoded.body.get("output_config").is_none(), - "non-effort models must not receive output_config" - ); - let thinking = encoded - .body - .get("thinking") - .expect("thinking must be set for fallback path"); - assert_eq!(thinking["type"], "enabled"); - assert_eq!(thinking["budget_tokens"], 14_000); - } - - // --- count_tokens encoding ----------------------------------------------- - - #[test] - fn count_request_omits_generation_only_fields_for_reasoning_effort() { - let catalog = catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - ); - let request = Request { - model: "test-claude".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - temperature: Some(0.2), - top_p: Some(0.9), - metadata: Some(std::collections::HashMap::from([( - "trace".to_string(), - "abc".to_string(), - )])), - ..make_base_request() - }; - - // The full request carries generation-only fields... - let full = encode_direct(&request, Some(&catalog), false); - assert!(full.body.get("output_config").is_some()); - - // ...but the count request strips them. - let count = encode_count_direct(&request, Some(&catalog)); - assert!(count.body.get("output_config").is_none()); - assert!(count.body.get("max_tokens").is_none()); - assert!(count.body.get("temperature").is_none()); - assert!(count.body.get("top_p").is_none()); - assert!(count.body.get("metadata").is_none()); - assert!(count.body.get("stream").is_none()); - } - - #[test] - fn count_request_includes_explicit_thinking_when_translated_request_has_it() { - let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 1024} - } - })), - ..make_base_request() - }; - - let count = encode_count_direct(&request, None); - assert_eq!(count.body["thinking"]["type"], "enabled"); - assert_eq!(count.body["thinking"]["budget_tokens"], 1024); - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs deleted file mode 100644 index 9cd0e8fa0..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/mod.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! The Anthropic Messages (`/messages`) codec. -//! -//! Serves Anthropic direct today, and (via route config + `CodecParams`) -//! Kimi-over-anthropic; the Bedrock and OpenRouter-skin routes pair the same -//! codec with different transports later. Pure translation: no HTTP, auth, or -//! base URL — the adapter shell owns those. -//! -//! HTTP error bodies use the shared `decode_error` default (anthropic uses the -//! standard `error_from_status_code` + `parse_error_body` path); streaming -//! `error` events are mapped inside the decoder (`on_event` → `Err`). - -mod decode; -mod encode; -mod stream; -mod wire; - -pub(crate) use encode::anthropic_option; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Synthetic tool injected to coerce structured (`JsonSchema`) output. Shared -/// across encode (injection), decode (extraction), and stream (rewrite). -pub(super) const SYNTHETIC_TOOL_NAME: &str = "json_output"; - -/// Codec for the Anthropic Messages wire dialect. -pub(crate) struct AnthropicMessages; - -impl Codec for AnthropicMessages { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new( - ctx.provider_name, - decode::uses_json_schema_format(ctx.request), - rate_limit, - )) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs deleted file mode 100644 index bb8d79027..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs +++ /dev/null @@ -1,653 +0,0 @@ -//! Streaming decoder: Anthropic SSE events → canonical `StreamEvent`s. -//! -//! Byte reading and SSE block framing live in the transport; this decoder is -//! fed framed `RawEvent`s (`event:` type + `data:` JSON). Anthropic never -//! synthesizes a finish on byte-stream end — `message_stop` is the finisher — -//! so `finish()` returns nothing. - -use super::SYNTHETIC_TOOL_NAME; -use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error}; -use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// The type of the current content block being streamed. -#[derive(Clone)] -enum ContentBlockKind { - Text, - ToolUse { id: String, name: String }, - Thinking { signature: Option }, -} - -/// Accumulated state across SSE events during streaming. -pub(super) struct SseAccumulator { - id: String, - model: String, - /// Configured provider name stamped into the final `Response.provider`. - provider: String, - /// When true, synthetic-tool events are rewritten to text events. - json_schema_mode: bool, - content_parts: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - current_block: Option, - current_text: String, - current_thinking: String, - current_tool_args: String, - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new( - provider: &str, - json_schema_mode: bool, - rate_limit: Option, - ) -> Self { - Self { - id: String::new(), - model: String::new(), - provider: provider.to_string(), - json_schema_mode, - content_parts: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - current_block: None, - current_text: String::new(), - current_thinking: String::new(), - current_tool_args: String::new(), - rate_limit, - } - } - - fn take_response(&mut self) -> Response { - Response { - id: std::mem::take(&mut self.id), - model: std::mem::take(&mut self.model), - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: std::mem::take(&mut self.content_parts), - name: None, - tool_call_id: None, - }, - finish_reason: std::mem::replace(&mut self.finish_reason, FinishReason::Stop), - usage: std::mem::take(&mut self.usage), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.take(), - cost_usd: None, - cost_source: None, - } - } - - fn process_event(&mut self, event_type: &str, data: &serde_json::Value) -> Vec { - match event_type { - "message_start" => self.handle_message_start(data), - "content_block_start" => self.handle_content_block_start(data), - "content_block_delta" => self.handle_content_block_delta(data), - "content_block_stop" => self.handle_content_block_stop(data), - "message_delta" => { - self.handle_message_delta(data); - vec![] - } - "message_stop" => self.handle_message_stop(), - _ => vec![], - } - } - - fn handle_message_start(&mut self, data: &serde_json::Value) -> Vec { - if let Some(message) = data.get("message") { - if let Some(id) = message.get("id").and_then(serde_json::Value::as_str) { - self.id = id.to_string(); - } - if let Some(model) = message.get("model").and_then(serde_json::Value::as_str) { - self.model = model.to_string(); - } - if let Some(usage) = message.get("usage") { - self.usage.input_tokens = usage - .get("input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_read_tokens = usage - .get("cache_read_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_write_tokens = usage - .get("cache_creation_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - // `StreamStart` is the driver's; this handler only captures metadata. - vec![] - } - - fn handle_content_block_start(&mut self, data: &serde_json::Value) -> Vec { - let block_type = data - .get("content_block") - .and_then(|b| b.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - let text_id = Some(block_text_id(data)); - - match block_type { - "text" => { - self.current_block = Some(ContentBlockKind::Text); - self.current_text.clear(); - vec![StreamEvent::TextStart { text_id }] - } - "tool_use" => { - let content_block = data.get("content_block"); - let id = content_block - .and_then(|b| b.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let name = content_block - .and_then(|b| b.get("name")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - self.current_block = Some(ContentBlockKind::ToolUse { - id: id.clone(), - name: name.clone(), - }); - self.current_tool_args.clear(); - vec![StreamEvent::ToolCallStart { - tool_call: ToolCall::new(id, name, serde_json::json!({})), - }] - } - "thinking" => { - let signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.current_block = Some(ContentBlockKind::Thinking { signature }); - self.current_thinking.clear(); - vec![StreamEvent::ReasoningStart] - } - _ => vec![], - } - } - - fn handle_content_block_delta(&mut self, data: &serde_json::Value) -> Vec { - let delta = data.get("delta"); - let delta_type = delta - .and_then(|d| d.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - match delta_type { - "text_delta" => { - let text = delta - .and_then(|d| d.get("text")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_text.push_str(text); - - vec![StreamEvent::TextDelta { - delta: text.to_string(), - text_id: Some(block_text_id(data)), - }] - } - "input_json_delta" => { - let partial_json = delta - .and_then(|d| d.get("partial_json")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_tool_args.push_str(partial_json); - - if let Some(ContentBlockKind::ToolUse { id, name }) = &self.current_block { - vec![StreamEvent::ToolCallDelta { - tool_call: ToolCall::new( - id.clone(), - name.clone(), - serde_json::json!(partial_json), - ), - }] - } else { - vec![] - } - } - "thinking_delta" => { - let thinking = delta - .and_then(|d| d.get("thinking")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_thinking.push_str(thinking); - vec![StreamEvent::ReasoningDelta { - delta: thinking.to_string(), - }] - } - "signature_delta" => { - let signature = delta - .and_then(|d| d.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - if let Some(ContentBlockKind::Thinking { - signature: ref mut sig, - }) = self.current_block - { - *sig = signature; - } - vec![] - } - _ => vec![], - } - } - - fn handle_content_block_stop(&mut self, data: &serde_json::Value) -> Vec { - let current_block = self.current_block.take(); - match current_block { - Some(ContentBlockKind::Text) => { - let text = std::mem::take(&mut self.current_text); - self.content_parts.push(ContentPart::text(text)); - - vec![StreamEvent::TextEnd { - text_id: Some(block_text_id(data)), - }] - } - Some(ContentBlockKind::ToolUse { id, name }) => { - let raw_args = std::mem::take(&mut self.current_tool_args); - let arguments = parse_tool_arguments_or_empty(&raw_args); - let mut tool_call = ToolCall::new(id, name, arguments); - tool_call.raw_arguments = Some(raw_args); - self.content_parts - .push(ContentPart::ToolCall(tool_call.clone())); - vec![StreamEvent::ToolCallEnd { tool_call }] - } - Some(ContentBlockKind::Thinking { signature }) => { - let thinking_text = std::mem::take(&mut self.current_thinking); - // Prefer signature from content_block_stop if available, fall - // back to one captured at content_block_start. - let stop_signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.content_parts.push(ContentPart::Thinking(ThinkingData { - text: thinking_text, - signature: stop_signature.or(signature), - redacted: false, - })); - vec![StreamEvent::ReasoningEnd] - } - None => vec![], - } - } - - fn handle_message_delta(&mut self, data: &serde_json::Value) { - if let Some(delta) = data.get("delta") { - let stop_reason = delta.get("stop_reason").and_then(serde_json::Value::as_str); - self.finish_reason = map_finish_reason(stop_reason); - } - if let Some(usage) = data.get("usage") { - self.usage.output_tokens = usage - .get("output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - - fn handle_message_stop(&mut self) -> Vec { - let response = self.take_response(); - vec![StreamEvent::Finish { - finish_reason: response.finish_reason.clone(), - usage: response.usage.clone(), - response: Box::new(response), - }] - } -} - -/// The `text_id` for a content-block event: `block_`. -fn block_text_id(data: &serde_json::Value) -> String { - let index = data - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - format!("block_{index}") -} - -/// Extract the `stop_details` from a refusal `message_delta`, if present. -fn refusal_stop_details(data: &serde_json::Value) -> Option<&serde_json::Value> { - data.get("delta") - .and_then(|delta| delta.get("stop_details")) -} - -/// Whether a `message_delta` event carries a refusal stop reason. -fn is_refusal_message_delta(event_type: &str, data: &serde_json::Value) -> bool { - event_type == "message_delta" - && data - .get("delta") - .and_then(|delta| delta.get("stop_reason")) - .and_then(serde_json::Value::as_str) - == Some("refusal") -} - -/// Wrap a refusal stream event in the same raw shape the non-streaming -/// refusal error carries (`stop_reason` + `stop_details` + the event). -fn refusal_stream_raw(data: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "stop_reason": "refusal", - "stop_details": refusal_stop_details(data) - .cloned() - .unwrap_or(serde_json::Value::Null), - "stream_event": data, - }) -} - -/// Map an Anthropic `error` stream event to a provider error. -fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name: &str) -> Error { - let error = data.get("error").unwrap_or(data); - let message = error - .get("message") - .and_then(serde_json::Value::as_str) - .or_else(|| data.get("message").and_then(serde_json::Value::as_str)) - .unwrap_or("Unknown Anthropic stream error") - .to_string(); - let error_code = error - .get("type") - .and_then(serde_json::Value::as_str) - .map(String::from); - - // overloaded_error, api_error, and unknown stream errors are transient. - let kind = error_code - .as_deref() - .and_then(error::kind_from_error_code) - .unwrap_or(ProviderErrorKind::Server); - - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider_name.to_string(), - status_code: None, - error_code, - retry_after: None, - raw: Some(data.clone()), - }), - } -} - -/// Rewrite a streaming event for `JsonSchema` mode: synthetic-tool events -/// become text events, and the Finish event's content + finish_reason are -/// adjusted. -fn convert_stream_event_for_json_schema(event: StreamEvent) -> StreamEvent { - match event { - StreamEvent::ToolCallStart { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextStart { text_id: None } - } - StreamEvent::ToolCallDelta { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - let delta = match tool_call.arguments { - serde_json::Value::String(s) => s, - other => other.to_string(), - }; - StreamEvent::TextDelta { - delta, - text_id: None, - } - } - StreamEvent::ToolCallEnd { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextEnd { text_id: None } - } - StreamEvent::Finish { - mut response, - usage, - .. - } => { - response.message.content = - convert_synthetic_tool_to_text(std::mem::take(&mut response.message.content)); - response.finish_reason = FinishReason::Stop; - StreamEvent::Finish { - finish_reason: FinishReason::Stop, - usage, - response, - } - } - other => other, - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - let event_type = ev.event.unwrap_or(""); - let data: serde_json::Value = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("failed to parse SSE data: {e}"), e))?; - - if event_type == "error" { - return Err(stream_error_event_to_provider_error(&data, &self.provider)); - } - - // A refusal (Claude Fable 5) arrives as a `message_delta` stop reason; - // surface it as an error instead of letting `message_stop` emit a - // normal Finish. - if is_refusal_message_delta(event_type, &data) { - return Err(refusal_error( - &self.provider, - &self.model, - refusal_stream_raw(&data), - refusal_stop_details(&data), - )); - } - - let events = self.process_event(event_type, &data); - if self.json_schema_mode { - Ok(events - .into_iter() - .map(convert_stream_event_for_json_schema) - .collect()) - } else { - Ok(events) - } - } - - fn finish(&mut self) -> Vec { - // Anthropic relies on `message_stop` to finish; nothing to synthesize. - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn new_accumulator(provider: &str, json_schema_mode: bool) -> SseAccumulator { - SseAccumulator::new(provider, json_schema_mode, None) - } - - #[test] - fn stream_token_counts_leaves_reasoning_zero_and_output_full() { - let mut acc = new_accumulator("anthropic", false); - acc.content_parts.push(ContentPart::Thinking(ThinkingData { - text: "summary text".to_string(), - signature: Some(String::new()), - redacted: false, - })); - acc.content_parts.push(ContentPart::text("answer")); - acc.usage = TokenCounts { - input_tokens: 50, - output_tokens: 1200, - reasoning_tokens: 0, - cache_read_tokens: 9000, - cache_write_tokens: 1000, - }; - - let events = acc.handle_message_stop(); - let StreamEvent::Finish { - usage, response, .. - } = &events[0] - else { - panic!("expected finish event"); - }; - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - assert_eq!(response.usage, *usage); - } - - #[test] - fn stream_error_event_overloaded_becomes_retryable_server_error() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Overloaded" - } - }); - let raw = data.to_string(); - - let err = acc - .on_event(RawEvent { - event: Some("error"), - data: &raw, - }) - .unwrap_err(); - - assert!(err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert_eq!(detail.provider, "anthropic"); - assert_eq!(detail.message, "Overloaded"); - assert_eq!(detail.error_code.as_deref(), Some("overloaded_error")); - assert_eq!(detail.raw.as_ref(), Some(&data)); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn stream_error_event_invalid_request_remains_non_retryable() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "invalid_request_error", - "message": "max_tokens is required" - } - }); - let raw = data.to_string(); - - let err = acc - .on_event(RawEvent { - event: Some("error"), - data: &raw, - }) - .unwrap_err(); - - assert!(!err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::InvalidRequest); - assert_eq!(detail.error_code.as_deref(), Some("invalid_request_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn unknown_sse_events_remain_ignored() { - let mut acc = new_accumulator("anthropic", false); - let data = serde_json::json!({ - "type": "content_block_delta", - "delta": { "type": "text_delta", "text": "ignored" } - }); - let raw = data.to_string(); - - let events = acc - .on_event(RawEvent { - event: Some("some_future_event"), - data: &raw, - }) - .unwrap(); - - assert!(events.is_empty()); - } - - #[test] - fn convert_stream_event_converts_tool_start_for_synthetic() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::TextStart { .. })); - } - - #[test] - fn convert_stream_event_preserves_real_tool_start() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", "real_tool", serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::ToolCallStart { .. })); - } - - #[test] - fn convert_stream_event_converts_tool_delta_for_synthetic() { - let event = StreamEvent::ToolCallDelta { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!("{\"name\"")), - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::TextDelta { delta, .. } => { - assert_eq!(delta, "{\"name\""); - } - other => panic!("expected TextDelta, got {other:?}"), - } - } - - #[test] - fn convert_stream_event_converts_finish_reason() { - let response = Box::new(Response { - id: "test".to_string(), - model: "claude".to_string(), - provider: "anthropic".to_string(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"data": "value"}), - ))], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }); - let event = StreamEvent::Finish { - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - response, - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(finish_reason, FinishReason::Stop); - assert_eq!(response.finish_reason, FinishReason::Stop); - // Content should be converted from tool call to text - assert!(matches!(&response.message.content[0], ContentPart::Text(_))); - } - other => panic!("expected Finish, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs deleted file mode 100644 index 70fb0ede6..000000000 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/wire.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Serde types mirroring the Anthropic Messages wire shapes. - -use crate::codec::cache::CacheControl; - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub messages: Vec, - pub max_tokens: i64, - /// System prompt: either a plain string or an array of content blocks - /// (with optional `cache_control` annotations for prompt caching). - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - /// Always serialized, even when empty (pinned by wire tests). - pub stop_sequences: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - /// Extended thinking configuration (e.g. `{"type": "enabled", - /// "budget_tokens": 10000}`). - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub speed: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub stream: bool, -} - -#[derive(serde::Serialize)] -pub(super) struct CountTokensRequest { - pub model: String, - pub messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub thinking: Option, -} - -impl From for CountTokensRequest { - fn from(request: ApiRequest) -> Self { - Self { - model: request.model, - messages: request.messages, - system: request.system, - tools: request.tools, - tool_choice: request.tool_choice, - thinking: request.thinking, - } - } -} - -/// Anthropic messages use structured content blocks, not plain strings. -#[derive(serde::Serialize)] -pub(super) struct ApiMessage { - pub role: String, - pub content: Vec, -} - -/// Anthropic tool definition format. -#[derive(serde::Serialize)] -pub(super) struct ApiToolDef { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: String, - pub content: Vec, - pub stop_reason: Option, - #[serde(default)] - pub stop_details: Option, - pub usage: ApiUsage, -} - -#[derive(serde::Deserialize)] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct ApiUsage { - pub input_tokens: i64, - pub output_tokens: i64, - #[serde(default)] - pub cache_read_input_tokens: Option, - #[serde(default)] - pub cache_creation_input_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CountTokensResponse { - pub input_tokens: i64, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_serialization_includes_cache_control() { - let tool = ApiToolDef { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: Some(CacheControl::ephemeral()), - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert_eq!(json["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_serialization_omits_cache_control_when_none() { - let tool = ApiToolDef { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: None, - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert!(json.get("cache_control").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs deleted file mode 100644 index f9cf0f87e..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/decode.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! Response decoding: Converse body → canonical `Response`. - -use serde_json::Value; - -use crate::codec::CodecCtx; -use crate::error::{Error, error_from_status_code}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Map a non-2xx Bedrock runtime response to an `Error`, pulling the human -/// reason out of AWS's error envelope. Bedrock uses several shapes for the -/// same field — top-level `message` (SigV4 path) and `Message` (API-key -/// path), occasionally nested `error.message` — and tags the type in -/// `__type`. The generic codec parser only reads `error.message`, so without -/// this these surface as "Unknown error". -pub(super) fn bedrock_error( - status: u16, - body: &str, - provider: &str, - retry_after: Option, -) -> Error { - let raw: Option = serde_json::from_str(body).ok(); - let message = raw - .as_ref() - .and_then(extract_error_message) - .unwrap_or_else(|| { - if body.trim().is_empty() { - "Unknown error".to_string() - } else { - body.to_string() - } - }); - // `__type` is often an ARN-ish `prefix#ThrottlingException`; keep the tail. - let code = raw - .as_ref() - .and_then(|v| { - v.get("__type") - .or_else(|| v.get("code")) - .and_then(Value::as_str) - }) - .map(|t| t.rsplit('#').next().unwrap_or(t).to_string()); - error_from_status_code( - status, - message, - provider.to_string(), - code, - raw, - retry_after, - ) -} - -fn extract_error_message(v: &Value) -> Option { - v.get("message") - .and_then(Value::as_str) - .or_else(|| v.get("Message").and_then(Value::as_str)) - .or_else(|| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(Value::as_str) - }) - .map(String::from) -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse converse response: {e}"), e))?; - - let content_parts = raw - .pointer("/output/message/content") - .and_then(Value::as_array) - .map(|blocks| blocks.iter().filter_map(decode_content_block).collect()) - .unwrap_or_default(); - - let finish_reason = map_stop_reason(raw.get("stopReason").and_then(Value::as_str)); - let usage = token_counts_from_usage(raw.get("usage")); - - Ok(Response { - // Converse responses carry no id; synthesize one like the gemini - // codec does so downstream consumers always see a non-empty id. - id: uuid::Uuid::new_v4().to_string(), - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -/// Decode one Converse content block into a canonical part. Unknown block -/// kinds are skipped (the union grows: `citationsContent`, `searchResult`, -/// `video`, ...). -pub(super) fn decode_content_block(block: &Value) -> Option { - if let Some(text) = block.get("text").and_then(Value::as_str) { - if text.is_empty() { - return None; - } - return Some(ContentPart::text(text)); - } - if let Some(tool_use) = block.get("toolUse") { - let id = tool_use.get("toolUseId").and_then(Value::as_str)?; - let name = tool_use.get("name").and_then(Value::as_str)?; - // A no-argument tool call is canonically `{}`, not null (so it - // re-encodes to a valid Converse `toolUse.input` object). - let input = match tool_use.get("input") { - Some(Value::Null) | None => Value::Object(serde_json::Map::new()), - Some(value) => value.clone(), - }; - return Some(ContentPart::ToolCall(ToolCall::new(id, name, input))); - } - if let Some(reasoning) = block.get("reasoningContent") { - if let Some(text_block) = reasoning.get("reasoningText") { - return Some(ContentPart::Thinking(ThinkingData { - text: text_block - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - signature: text_block - .get("signature") - .and_then(Value::as_str) - .map(str::to_string), - redacted: false, - })); - } - if let Some(redacted) = reasoning.get("redactedContent").and_then(Value::as_str) { - return Some(ContentPart::Thinking(ThinkingData { - text: redacted.to_string(), - signature: None, - redacted: true, - })); - } - } - None -} - -/// Map a Converse `stopReason` onto the canonical finish vocabulary. -pub(super) fn map_stop_reason(reason: Option<&str>) -> FinishReason { - match reason { - None | Some("end_turn" | "stop_sequence") => FinishReason::Stop, - Some("max_tokens" | "model_context_window_exceeded") => FinishReason::Length, - Some("tool_use") => FinishReason::ToolCalls, - // `refusal` is the Claude 5 blocking-classifier stop, passed through - // by Bedrock for Fable-class models. - Some("guardrail_intervened" | "content_filtered" | "refusal") => { - FinishReason::ContentFilter - } - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Converse usage maps directly onto the disjoint buckets: `inputTokens` -/// already excludes cached tokens (documented), so no subtraction applies. -pub(super) fn token_counts_from_usage(usage: Option<&Value>) -> TokenCounts { - let Some(usage) = usage else { - return TokenCounts::default(); - }; - let count = |key: &str| usage.get(key).and_then(Value::as_i64).unwrap_or(0); - TokenCounts { - input_tokens: count("inputTokens"), - output_tokens: count("outputTokens"), - reasoning_tokens: 0, - cache_read_tokens: count("cacheReadInputTokens"), - cache_write_tokens: count("cacheWriteInputTokens"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn stop_reasons_map_to_canonical_vocabulary() { - assert_eq!(map_stop_reason(Some("end_turn")), FinishReason::Stop); - assert_eq!(map_stop_reason(Some("stop_sequence")), FinishReason::Stop); - assert_eq!(map_stop_reason(Some("max_tokens")), FinishReason::Length); - assert_eq!( - map_stop_reason(Some("model_context_window_exceeded")), - FinishReason::Length - ); - assert_eq!(map_stop_reason(Some("tool_use")), FinishReason::ToolCalls); - assert_eq!( - map_stop_reason(Some("guardrail_intervened")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("content_filtered")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("refusal")), - FinishReason::ContentFilter - ); - assert_eq!( - map_stop_reason(Some("malformed_tool_use")), - FinishReason::Other("malformed_tool_use".to_string()) - ); - assert_eq!(map_stop_reason(None), FinishReason::Stop); - } - - #[test] - fn usage_maps_without_subtraction() { - let usage = serde_json::json!({ - "inputTokens": 30, - "outputTokens": 628, - "totalTokens": 658, - "cacheReadInputTokens": 1024, - "cacheWriteInputTokens": 512, - }); - let counts = token_counts_from_usage(Some(&usage)); - assert_eq!(counts.input_tokens, 30); - assert_eq!(counts.output_tokens, 628); - assert_eq!(counts.cache_read_tokens, 1024); - assert_eq!(counts.cache_write_tokens, 512); - assert_eq!(counts.reasoning_tokens, 0); - } - - #[test] - fn bedrock_error_extracts_aws_message_shapes() { - // SigV4 path: top-level lowercase `message`. - let sigv4 = bedrock_error( - 403, - r#"{"message":"Model access is denied due to IAM ..."}"#, - "bedrock", - None, - ); - assert!( - sigv4.to_string().contains("Model access is denied"), - "{sigv4}" - ); - - // API-key path: top-level capitalized `Message`. - let api_key = bedrock_error( - 403, - r#"{"Message":"Authentication failed: Please make sure your API Key is valid."}"#, - "bedrock", - None, - ); - assert!( - api_key.to_string().contains("Authentication failed"), - "{api_key}" - ); - - // `__type` becomes the error code (tail after `#`). - let typed = bedrock_error( - 429, - r#"{"__type":"com.amazon.coral.service#ThrottlingException","message":"slow down"}"#, - "bedrock", - None, - ); - let Error::Provider { detail, .. } = &typed else { - panic!("expected provider error: {typed}"); - }; - assert_eq!(detail.error_code.as_deref(), Some("ThrottlingException")); - - // Garbage body falls back rather than panicking. - let opaque = bedrock_error(500, "not json", "bedrock", None); - assert!(opaque.to_string().contains("not json"), "{opaque}"); - } - - #[test] - fn unknown_content_blocks_are_skipped() { - assert!(decode_content_block(&serde_json::json!({"citationsContent": {}})).is_none()); - assert!(decode_content_block(&serde_json::json!({"text": ""})).is_none()); - } - - #[test] - fn tool_use_names_are_preserved_verbatim() { - let block = serde_json::json!({ - "toolUse": { - "toolUseId": "tool-1", - "name": "search???", - "input": {} - } - }); - let Some(ContentPart::ToolCall(tool_call)) = decode_content_block(&block) else { - panic!("expected tool call"); - }; - assert_eq!(tool_call.name, "search???"); - } - - #[test] - fn reasoning_text_block_round_trips_signature() { - let block = serde_json::json!({ - "reasoningContent": { - "reasoningText": { "text": "thinking...", "signature": "sig-1" } - } - }); - let Some(ContentPart::Thinking(thinking)) = decode_content_block(&block) else { - panic!("expected thinking part"); - }; - assert_eq!(thinking.text, "thinking..."); - assert_eq!(thinking.signature.as_deref(), Some("sig-1")); - assert!(!thinking.redacted); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs deleted file mode 100644 index bb1a4ba3b..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/encode.rs +++ /dev/null @@ -1,765 +0,0 @@ -//! Request encoding: canonical `Request` → Converse envelope. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use serde_json::{Map, Value, json}; - -use super::sanitize; -use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt, merge_named_provider_options}; -use crate::error::Error; -use crate::types::{ContentPart, Message, Request, Role, ToolChoice}; - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result { - let request = ctx.request; - if request.response_format.is_some() { - return Err(Error::Configuration { - message: format!( - "provider '{}' does not support response_format yet (Bedrock Converse \ - structured output is a named follow-up)", - ctx.provider_name - ), - source: None, - }); - } - - let caching = supports_prompt_cache(ctx); - let (system, conversation) = extract_system_prompt(&request.messages); - - let mut body = Map::new(); - - if let Some(system) = system { - let mut blocks = vec![json!({ "text": system })]; - if caching { - blocks.push(cache_point()); - } - body.insert("system".to_string(), Value::Array(blocks)); - } - - let mut messages = Vec::new(); - for message in conversation { - if let Some(value) = encode_message(message) { - messages.push(value); - } - } - if caching { - apply_cache_point_to_conversation_prefix(&mut messages); - } - body.insert("messages".to_string(), Value::Array(messages)); - - // Models with `sampling_params = false` reject classic sampling knobs - // (Claude Fable 5 pins temperature on Bedrock too). - let (temperature, top_p) = if ctx - .model - .is_none_or(fabro_model::Model::supports_sampling_params) - { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - let mut inference = Map::new(); - if let Some(max_tokens) = request.max_tokens { - inference.insert("maxTokens".to_string(), json!(max_tokens)); - } - if let Some(temperature) = temperature { - inference.insert("temperature".to_string(), json!(temperature)); - } - if let Some(top_p) = top_p { - inference.insert("topP".to_string(), json!(top_p)); - } - if let Some(stop) = &request.stop_sequences { - if !stop.is_empty() { - inference.insert("stopSequences".to_string(), json!(stop)); - } - } - if !inference.is_empty() { - body.insert("inferenceConfig".to_string(), Value::Object(inference)); - } - - if let Some(tool_config) = encode_tool_config(request, caching) { - body.insert("toolConfig".to_string(), tool_config); - } - - let mut body = Value::Object(body); - merge_provider_options( - &mut body, - request.provider_options.as_ref(), - ctx.provider_name, - ); - - let action = if stream { - "converse-stream" - } else { - "converse" - }; - Ok(EncodedRequest { - body, - endpoint: format!("/model/{}/{action}", ctx.deployment_id), - headers: Vec::new(), - }) -} - -fn supports_prompt_cache(ctx: &CodecCtx<'_>) -> bool { - ctx.model.is_some_and(|m| m.features.prompt_cache) -} - -fn cache_point() -> Value { - json!({ "cachePoint": { "type": "default" } }) -} - -/// Encode one conversation message. Tool-role messages carry their results in -/// user-role messages (Converse has no tool role). Returns `None` when no -/// block survives translation. -fn encode_message(message: &Message) -> Option { - let role = match message.role { - Role::Assistant => "assistant", - // Tool results ride in user messages on the Converse wire. - _ => "user", - }; - - let mut blocks: Vec = message - .content - .iter() - .filter_map(encode_content_part) - .collect(); - - // Tool-role messages whose result lives on the message rather than in a - // ToolResult part. - if blocks.is_empty() && message.role == Role::Tool { - if let Some(tool_call_id) = &message.tool_call_id { - let text = message.text(); - blocks.push(tool_result_block( - tool_call_id, - json!([{ "text": text }]), - false, - )); - } - } - - if blocks.is_empty() { - return None; - } - Some(json!({ "role": role, "content": blocks })) -} - -fn encode_content_part(part: &ContentPart) -> Option { - match part { - ContentPart::Text(text) => { - if text.is_empty() { - None - } else { - Some(json!({ "text": text })) - } - } - // Converse has no URL sources; the adapter's attachment resolution - // inlines file-backed parts ahead of encoding, and URL-only parts are - // dropped (the established drop-don't-fail attachment contract). - ContentPart::Image(image) => { - let bytes = image.data.as_ref()?; - Some(json!({ - "image": { - "format": media_format(image.media_type.as_deref(), "png"), - "source": { "bytes": BASE64.encode(bytes) }, - } - })) - } - ContentPart::Document(document) => { - let bytes = document.data.as_ref()?; - Some(json!({ - "document": { - "format": media_format(document.media_type.as_deref(), "pdf"), - "name": document.file_name.as_deref().unwrap_or("document"), - "source": { "bytes": BASE64.encode(bytes) }, - } - })) - } - ContentPart::ToolCall(tool_call) => { - // Converse requires `toolUse.input` to be a JSON object document. - // A no-argument tool call carries `Null` (the stream decoder gets - // no input fragments to parse), which Bedrock rejects as - // "toolUse.input is empty". Coerce any non-object to `{}` so the - // wire is always valid, regardless of where the call originated. - let input = match &tool_call.arguments { - Value::Object(_) => tool_call.arguments.clone(), - _ => json!({}), - }; - Some(tool_use_block(&tool_call.id, &tool_call.name, input)) - } - ContentPart::ToolResult(result) => { - let content = match &result.content { - Value::String(text) => json!([{ "text": text }]), - other => json!([{ "json": other }]), - }; - Some(tool_result_block( - &result.tool_call_id, - content, - result.is_error, - )) - } - ContentPart::Thinking(thinking) => { - if thinking.redacted { - Some(json!({ - "reasoningContent": { "redactedContent": thinking.text } - })) - } else { - let mut text_block = Map::new(); - text_block.insert("text".to_string(), json!(thinking.text)); - if let Some(signature) = &thinking.signature { - // Echoed back unmodified — Bedrock validates it. - text_block.insert("signature".to_string(), json!(signature)); - } - Some(json!({ - "reasoningContent": { "reasoningText": Value::Object(text_block) } - })) - } - } - // Audio input and opaque foreign parts have no Converse encoding. - ContentPart::Audio(_) | ContentPart::Other { .. } => None, - } -} - -/// Build a `toolUse` block. All tool blocks must be constructed through -/// [`tool_use_block`] and [`tool_result_block`] so identifier sanitization -/// keeps `toolUse` and `toolResult` paired on the wire. -fn tool_use_block(id: &str, name: &str, input: Value) -> Value { - let mut block = Map::new(); - block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); - block.insert("name".to_string(), json!(sanitize::tool_name(name))); - block.insert("input".to_string(), input); - json!({ "toolUse": Value::Object(block) }) -} - -/// Build a `toolResult` block; see [`tool_use_block`] for the pairing contract. -fn tool_result_block(id: &str, content: Value, is_error: bool) -> Value { - let mut block = Map::new(); - block.insert("toolUseId".to_string(), json!(sanitize::tool_use_id(id))); - block.insert("content".to_string(), content); - if is_error { - block.insert("status".to_string(), json!("error")); - } - json!({ "toolResult": Value::Object(block) }) -} - -/// Convert common MIME types into Bedrock's media `format` enum values. -fn media_format<'a>(media_type: Option<&str>, default: &'a str) -> &'a str { - match media_type { - Some("image/png") => "png", - Some("image/jpeg" | "image/jpg") => "jpeg", - Some("image/gif") => "gif", - Some("image/webp") => "webp", - Some("application/pdf") => "pdf", - Some("text/plain") => "txt", - Some("text/markdown") => "md", - Some("text/html") => "html", - Some("text/csv") => "csv", - Some( - "application/msword" - | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) => "docx", - Some( - "application/vnd.ms-excel" - | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) => "xlsx", - _ => default, - } -} - -fn encode_tool_config(request: &Request, caching: bool) -> Option { - let tools = request.tools.as_ref()?; - if tools.is_empty() { - return None; - } - // `tool_choice: none` is rejected at the adapter's validate_request; - // defensively drop the toolConfig if it slips through. - if request.tool_choice == Some(ToolChoice::None) { - return None; - } - - let mut entries: Vec = tools - .iter() - .map(|tool| { - json!({ - "toolSpec": { - "name": tool.name, - "description": tool.description, - "inputSchema": { "json": tool_input_schema(&tool.parameters) }, - } - }) - }) - .collect(); - if caching { - entries.push(cache_point()); - } - - let mut config = Map::new(); - config.insert("tools".to_string(), Value::Array(entries)); - match &request.tool_choice { - Some(ToolChoice::Required) => { - config.insert("toolChoice".to_string(), json!({ "any": {} })); - } - Some(ToolChoice::Named { tool_name }) => { - config.insert( - "toolChoice".to_string(), - json!({ "tool": { "name": tool_name } }), - ); - } - // Auto is the wire default; ToolChoice::None dropped the config above. - Some(ToolChoice::Auto | ToolChoice::None) | None => {} - } - Some(Value::Object(config)) -} - -/// Normalize a tool's JSON-Schema for Bedrock's `toolSpec.inputSchema.json`. -/// Converse strictly validates the schema and requires a top-level `type`; -/// some model families (e.g. DeepSeek) reject a typeless schema that Claude -/// tolerates. Tools may arrive with a loose schema (no top-level `type`, or a -/// bare `{}` for a no-argument tool), so default the type to `object`. -fn tool_input_schema(parameters: &Value) -> Value { - match parameters { - Value::Object(map) => { - let mut map = map.clone(); - map.entry("type").or_insert_with(|| json!("object")); - Value::Object(map) - } - // A non-object schema is not a valid tool input schema; substitute the - // empty-object schema Bedrock accepts. - _ => json!({ "type": "object", "properties": {} }), - } -} - -/// Mirror the anthropic codec's conversation-prefix cache placement: a -/// `cachePoint` at the end of the second-to-last user message, so the prior -/// turns stay cached while the newest turn streams. -fn apply_cache_point_to_conversation_prefix(messages: &mut [Value]) { - let mut previous_user = None; - let mut last_user = None; - for (index, message) in messages.iter().enumerate() { - if message.get("role").and_then(Value::as_str) == Some("user") { - previous_user = last_user; - last_user = Some(index); - } - } - - let Some(target) = previous_user else { - return; - }; - if let Some(content) = messages[target] - .get_mut("content") - .and_then(Value::as_array_mut) - { - content.push(cache_point()); - } -} - -/// Merge `provider_options.` keys into the top level of the -/// body (the same adapter-name-keyed contract as the openai_compatible -/// codec). This is the passthrough for `additionalModelRequestFields`, -/// `guardrailConfig`, `serviceTier`, and other Converse extensions. -fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, provider_name: &str) { - merge_named_provider_options(body, provider_options, provider_name, &[]); -} - -#[cfg(test)] -mod tests { - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ProviderId}; - use serde_json::json; - - use super::*; - use crate::codec::CodecParams; - use crate::types::{ - ResponseFormat, ResponseFormatType, ThinkingData, ToolCall, ToolDefinition, ToolResult, - }; - - fn base_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.5), - top_p: None, - max_tokens: Some(256), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn encode_with(request: &Request) -> EncodedRequest { - let params = CodecParams::default(); - let ctx = CodecCtx { - request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - encode(&ctx, false).unwrap() - } - - #[test] - fn endpoint_carries_model_and_action() { - let request = base_request("claude"); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - assert_eq!( - encode(&ctx, false).unwrap().endpoint, - "/model/us.anthropic.claude-sonnet-4-6/converse" - ); - assert_eq!( - encode(&ctx, true).unwrap().endpoint, - "/model/us.anthropic.claude-sonnet-4-6/converse-stream" - ); - } - - #[test] - fn system_messages_become_top_level_system_blocks() { - let mut request = base_request("claude"); - request.messages = vec![Message::system("Be brief"), Message::user("Hi")]; - let encoded = encode_with(&request); - assert_eq!(encoded.body["system"][0]["text"], "Be brief"); - assert_eq!(encoded.body["messages"][0]["role"], "user"); - assert_eq!(encoded.body["messages"][0]["content"][0]["text"], "Hi"); - } - - #[test] - fn inference_config_uses_camel_case() { - let encoded = encode_with(&base_request("claude")); - assert_eq!(encoded.body["inferenceConfig"]["maxTokens"], 256); - assert_eq!(encoded.body["inferenceConfig"]["temperature"], 0.5); - } - - #[test] - fn tools_encode_as_tool_specs_with_choice() { - let mut request = base_request("claude"); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search things", - json!({"type": "object"}), - )]); - request.tool_choice = Some(ToolChoice::named("search")); - let encoded = encode_with(&request); - let spec = &encoded.body["toolConfig"]["tools"][0]["toolSpec"]; - assert_eq!(spec["name"], "search"); - assert_eq!(spec["inputSchema"]["json"]["type"], "object"); - assert_eq!( - encoded.body["toolConfig"]["toolChoice"]["tool"]["name"], - "search" - ); - } - - #[test] - fn typeless_tool_schema_gains_object_type() { - // Bedrock rejects a tool inputSchema without a top-level `type` (some - // model families validate strictly); the encoder must default it. - let mut request = base_request("claude"); - request.tools = Some(vec![ - ToolDefinition::function("no_type", "schema without a type", json!({})), - ToolDefinition::function( - "props_only", - "properties but no top-level type", - json!({"properties": {"q": {"type": "string"}}}), - ), - ]); - let encoded = encode_with(&request); - let tools = &encoded.body["toolConfig"]["tools"]; - assert_eq!( - tools[0]["toolSpec"]["inputSchema"]["json"]["type"], - "object" - ); - assert_eq!( - tools[1]["toolSpec"]["inputSchema"]["json"]["type"], - "object" - ); - // An existing nested schema is preserved, not clobbered. - assert_eq!( - tools[1]["toolSpec"]["inputSchema"]["json"]["properties"]["q"]["type"], - "string" - ); - } - - #[test] - fn tool_results_ride_in_user_messages() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult { - tool_call_id: "tool-1".to_string(), - content: json!("42"), - is_error: false, - image_data: None, - image_media_type: None, - })], - name: None, - tool_call_id: Some("tool-1".to_string()), - }]; - let encoded = encode_with(&request); - let message = &encoded.body["messages"][0]; - assert_eq!(message["role"], "user"); - assert_eq!(message["content"][0]["toolResult"]["toolUseId"], "tool-1"); - assert_eq!( - message["content"][0]["toolResult"]["content"][0]["text"], - "42" - ); - } - - #[test] - fn no_argument_tool_call_encodes_empty_object_input() { - // A no-arg tool call decodes to `Null` arguments; Bedrock rejects a - // null/empty `toolUse.input`, so the encoder must emit `{}`. - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "TaskList", - Value::Null, - ))], - name: None, - tool_call_id: None, - }]; - let encoded = encode_with(&request); - let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; - assert_eq!(tool_use["toolUseId"], "tool-1"); - assert_eq!(tool_use["name"], "TaskList"); - assert_eq!(tool_use["input"], json!({})); - } - - #[test] - fn historical_tool_names_are_sanitized_on_the_wire() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "search???", - json!({}), - ))], - name: None, - tool_call_id: None, - }]; - - let encoded = encode_with(&request); - let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"]; - assert_eq!(tool_use["name"], sanitize::tool_name("search???")); - } - - #[test] - fn sanitized_tool_use_ids_remain_paired() { - for id in ["bad id!".to_string(), "x".repeat(100)] { - let mut request = base_request("claude"); - request.messages = vec![ - Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - &id, - "search", - json!({}), - ))], - name: None, - tool_call_id: None, - }, - Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult::success( - &id, - json!("done"), - ))], - name: None, - tool_call_id: Some(id.clone()), - }, - ]; - - let encoded = encode_with(&request); - let tool_use_id = &encoded.body["messages"][0]["content"][0]["toolUse"]["toolUseId"]; - let tool_result_id = - &encoded.body["messages"][1]["content"][0]["toolResult"]["toolUseId"]; - assert_eq!(tool_use_id, tool_result_id); - assert!(tool_use_id.as_str().is_some_and(|value| value.len() <= 64)); - } - } - - #[test] - fn tool_role_fallback_sanitizes_the_tool_use_id() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Tool, - content: vec![], - name: None, - tool_call_id: Some("bad id!".to_string()), - }]; - - let encoded = encode_with(&request); - assert_eq!( - encoded.body["messages"][0]["content"][0]["toolResult"]["toolUseId"], - sanitize::tool_use_id("bad id!") - ); - } - - #[test] - fn overlength_tool_names_encode_within_the_bedrock_limit() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "tool-1", - "x".repeat(100), - json!({}), - ))], - name: None, - tool_call_id: None, - }]; - - let encoded = encode_with(&request); - let name = encoded.body["messages"][0]["content"][0]["toolUse"]["name"] - .as_str() - .unwrap(); - assert_eq!(name.len(), 64); - } - - #[test] - fn tool_definition_names_remain_unsanitized() { - let mut request = base_request("claude"); - request.tools = Some(vec![ToolDefinition::function( - "weird.name", - "Deliberately invalid for Bedrock", - json!({"type": "object"}), - )]); - - let encoded = encode_with(&request); - assert_eq!( - encoded.body["toolConfig"]["tools"][0]["toolSpec"]["name"], - "weird.name" - ); - } - - #[test] - fn thinking_parts_restructure_into_reasoning_text_blocks() { - let mut request = base_request("claude"); - request.messages = vec![Message { - role: Role::Assistant, - content: vec![ContentPart::Thinking(ThinkingData { - text: "prior thoughts".to_string(), - signature: Some("sig-1".to_string()), - redacted: false, - })], - name: None, - tool_call_id: None, - }]; - let encoded = encode_with(&request); - let block = &encoded.body["messages"][0]["content"][0]["reasoningContent"]["reasoningText"]; - assert_eq!(block["text"], "prior thoughts"); - assert_eq!(block["signature"], "sig-1"); - } - - #[test] - fn media_format_maps_common_mime_types_to_bedrock_formats() { - assert_eq!(media_format(Some("image/jpeg"), "png"), "jpeg"); - assert_eq!(media_format(Some("text/plain"), "pdf"), "txt"); - assert_eq!(media_format(Some("text/markdown"), "pdf"), "md"); - assert_eq!(media_format(Some("application/octet-stream"), "pdf"), "pdf"); - } - - #[test] - fn provider_options_merge_top_level() { - let mut request = base_request("claude"); - request.provider_options = Some(json!({ - "bedrock": { - "additionalModelRequestFields": {"top_k": 200}, - "serviceTier": {"type": "flex"} - } - })); - let encoded = encode_with(&request); - assert_eq!(encoded.body["additionalModelRequestFields"]["top_k"], 200); - assert_eq!(encoded.body["serviceTier"]["type"], "flex"); - } - - #[test] - fn response_format_is_rejected() { - let mut request = base_request("claude"); - request.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(json!({"type": "object"})), - strict: false, - }); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "m", - model: None, - params: ¶ms, - }; - assert!(encode(&ctx, false).is_err()); - } - - #[test] - fn sampling_params_false_drops_temperature_and_top_p() { - let settings: LlmCatalogSettings = toml::from_str( - r#" -[providers.bedrock] -adapter = "bedrock" -enabled = true -base_url = "https://bedrock-runtime.us-east-1.amazonaws.com" - -[models."pinned-model"] -provider = "bedrock" -display_name = "Pinned" -family = "claude-5" -default = true - -[models."pinned-model".limits] -context_window = 100000 - -[models."pinned-model".features] -tools = true -vision = false -reasoning = true -sampling_params = false -"#, - ) - .unwrap(); - let catalog = Catalog::from_settings(&settings).unwrap(); - - let mut request = base_request("pinned-model"); - request.top_p = Some(0.9); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "pinned-model", - model: catalog.get_on_provider(&ProviderId::new("bedrock"), "pinned-model"), - params: ¶ms, - }; - let encoded = encode(&ctx, false).unwrap(); - - let inference = &encoded.body["inferenceConfig"]; - assert!(inference.get("temperature").is_none()); - assert!(inference.get("topP").is_none()); - assert_eq!(inference["maxTokens"], 256); - } - - #[test] - fn cache_points_follow_the_anthropic_placement() { - let mut messages = vec![ - json!({"role": "user", "content": [{"text": "turn 1"}]}), - json!({"role": "assistant", "content": [{"text": "reply 1"}]}), - json!({"role": "user", "content": [{"text": "turn 2"}]}), - ]; - apply_cache_point_to_conversation_prefix(&mut messages); - // Second-to-last user message gains the cachePoint. - assert!(messages[0]["content"][1].get("cachePoint").is_some()); - assert_eq!(messages[2]["content"].as_array().unwrap().len(), 1); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs deleted file mode 100644 index c55c0a3b3..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! The Amazon Bedrock Converse codec. -//! -//! Pure translation: no HTTP, auth, signing, or event-stream framing — the -//! Bedrock adapter shell owns those. Converse is Bedrock's model-agnostic -//! envelope (AWS translates it to each hosted family's native dialect -//! server-side), which is what makes this one codec serve Claude, Nova, -//! Llama, Mistral, DeepSeek, Qwen, Kimi, GLM, MiniMax, Nemotron, and -//! gpt-oss alike. The codec fully forms its endpoints (model-in-path, -//! `/converse` vs `/converse-stream`), mirrors the anthropic codec's prompt -//! cache placement with `cachePoint` blocks, and round-trips -//! `reasoningContent` thinking signatures unmodified. - -mod decode; -mod encode; -mod sanitize; -mod stream; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the Bedrock Converse wire dialect. -pub(crate) struct BedrockConverse; - -impl Codec for BedrockConverse { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - encode::encode(ctx, stream) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::ConverseStreamDecoder::new(ctx, rate_limit)) - } - - /// Bedrock error bodies are AWS-shaped (top-level `message`/`Message`, - /// `__type`), which the default parser misses — extract them so failures - /// surface the real reason instead of "Unknown error". - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - decode::bedrock_error(status, body, ctx.provider_name, retry_after) - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs deleted file mode 100644 index 5ba139fc2..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/sanitize.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Bedrock Converse tool identifier sanitization. -//! -//! Tool names must match `[a-zA-Z0-9_-]+`; tool-use IDs additionally allow -//! `.` and `:`. Both are limited to 64 characters. These helpers rewrite only -//! the Bedrock wire view: the canonical transcript retains provider output -//! verbatim. The encoder routes every tool block through its -//! `tool_use_block`/`tool_result_block` constructors so `toolUse` and -//! `toolResult` blocks remain paired. - -use sha2::{Digest, Sha256}; - -const MAX_LENGTH: usize = 64; -const HASH_HEX_LENGTH: usize = 16; -const PREFIX_LENGTH: usize = MAX_LENGTH - 1 - HASH_HEX_LENGTH; - -pub(super) fn tool_name(name: &str) -> String { - sanitize(name, "unknown_tool", is_tool_name_char) -} - -pub(super) fn tool_use_id(id: &str) -> String { - sanitize(id, "unknown_tool_use_id", is_tool_use_id_char) -} - -fn sanitize(value: &str, empty_fallback: &'static str, is_allowed: fn(char) -> bool) -> String { - if value.is_empty() { - return empty_fallback.to_string(); - } - - let sanitized: String = value - .chars() - .map(|character| { - if is_allowed(character) { - character - } else { - '_' - } - }) - .collect(); - - if sanitized.len() <= MAX_LENGTH { - sanitized - } else { - truncate_with_hash(&sanitized, value) - } -} - -fn is_tool_name_char(character: char) -> bool { - character.is_ascii_alphanumeric() || matches!(character, '_' | '-') -} - -fn is_tool_use_id_char(character: char) -> bool { - is_tool_name_char(character) || matches!(character, '.' | ':') -} - -fn truncate_with_hash(sanitized: &str, original: &str) -> String { - debug_assert!(sanitized.is_ascii()); - let digest = Sha256::digest(original.as_bytes()); - let digest_hex = format!("{digest:x}"); - format!( - "{}-{}", - &sanitized[..PREFIX_LENGTH], - &digest_hex[..HASH_HEX_LENGTH] - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_values_pass_through_unchanged() { - for name in ["search", "TaskList", "a-b_c9"] { - assert_eq!(tool_name(name), name); - } - - let max_length = "a".repeat(64); - assert_eq!(tool_name(&max_length), max_length); - - let id = "functions.read_file:4"; - assert_eq!(tool_use_id(id), id); - assert_eq!(tool_name(id), "functions_read_file_4"); - } - - #[test] - fn invalid_characters_are_replaced() { - assert_eq!(tool_name("search???"), "search___"); - assert_eq!(tool_name("bad name"), "bad_name"); - assert_eq!(tool_use_id("bad id!"), "bad_id_"); - } - - #[test] - fn non_ascii_characters_become_single_underscores() { - let sanitized = tool_name("before🙂after"); - assert_eq!(sanitized, "before_after"); - assert!(sanitized.is_ascii()); - } - - #[test] - fn empty_values_use_nonempty_fallbacks() { - assert_eq!(tool_name(""), "unknown_tool"); - assert_eq!(tool_use_id(""), "unknown_tool_use_id"); - } - - #[test] - fn overlength_values_use_deterministic_hash_suffixes() { - let boundary = "a".repeat(65); - let first = tool_name(&boundary); - let second = tool_name(&boundary); - assert_eq!(first, second); - assert_eq!(first.len(), 64); - assert!( - first - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - ); - - let shared_prefix = "x".repeat(99); - let left = tool_name(&format!("{shared_prefix}a")); - let right = tool_name(&format!("{shared_prefix}b")); - assert_ne!(left, right); - } -} diff --git a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs deleted file mode 100644 index 0d413cabf..000000000 --- a/lib/components/fabro-llm/src/codec/bedrock_converse/stream.rs +++ /dev/null @@ -1,527 +0,0 @@ -//! Streaming decoder: ConverseStream events → canonical `StreamEvent`s. -//! -//! Event names arrive in the transport's `RawEvent::event` (the frame's -//! `:event-type` header); payloads are the event JSON. The documented -//! sequence is `messageStart` → per content block (`contentBlockStart` -//! [tool use only] → `contentBlockDelta`* → `contentBlockStop`) → -//! `messageStop{stopReason}` → `metadata{usage}`. Usage arrives ONLY in the -//! terminal `metadata` event, which is also where the final `Finish` is -//! synthesized. - -use std::collections::BTreeMap; - -use serde_json::Value; - -use super::decode::{map_stop_reason, token_counts_from_usage}; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// Per-content-block accumulation state, keyed by `contentBlockIndex`. -enum BlockState { - Text(String), - Reasoning { - text: String, - signature: Option, - redacted: Option, - }, - ToolUse { - id: String, - name: String, - input: String, - }, -} - -/// Accumulated state while decoding one ConverseStream response. -pub(super) struct ConverseStreamDecoder { - provider_name: String, - model: String, - blocks: BTreeMap, - /// Completed blocks in arrival order, for the final response message. - parts: Vec, - finish_reason: FinishReason, - usage: TokenCounts, - text_started: bool, - finished: bool, - rate_limit: Option, -} - -impl ConverseStreamDecoder { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - provider_name: ctx.provider_name.to_string(), - model: ctx.request.model.clone(), - blocks: BTreeMap::new(), - parts: Vec::new(), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - text_started: false, - finished: false, - rate_limit, - } - } - - fn block_index(payload: &Value) -> u64 { - payload - .get("contentBlockIndex") - .and_then(Value::as_u64) - .unwrap_or(0) - } - - fn on_block_start(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - if let Some(tool_use) = payload.pointer("/start/toolUse") { - let id = tool_use - .get("toolUseId") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let name = tool_use - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let started = ToolCall::new(&id, &name, Value::Null); - self.blocks.insert(index, BlockState::ToolUse { - id, - name, - input: String::new(), - }); - return vec![StreamEvent::ToolCallStart { tool_call: started }]; - } - Vec::new() - } - - fn on_block_delta(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - let Some(delta) = payload.get("delta") else { - return Vec::new(); - }; - - if let Some(text) = delta.get("text").and_then(Value::as_str) { - if text.is_empty() { - return Vec::new(); - } - let mut events = Vec::new(); - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - match self - .blocks - .entry(index) - .or_insert_with(|| BlockState::Text(String::new())) - { - BlockState::Text(buffer) => buffer.push_str(text), - // A text delta against a non-text block: tolerate by ignoring - // the mismatch rather than corrupting tool/reasoning state. - _ => return events, - } - events.push(StreamEvent::text_delta(text, None)); - return events; - } - - if let Some(input) = delta.pointer("/toolUse/input").and_then(Value::as_str) { - if let Some(BlockState::ToolUse { - id, - name, - input: buffer, - }) = self.blocks.get_mut(&index) - { - buffer.push_str(input); - let partial = ToolCall::new(id.as_str(), name.as_str(), Value::Null); - return vec![StreamEvent::ToolCallDelta { tool_call: partial }]; - } - return Vec::new(); - } - - if let Some(reasoning) = delta.get("reasoningContent") { - let entry = self - .blocks - .entry(index) - .or_insert_with(|| BlockState::Reasoning { - text: String::new(), - signature: None, - redacted: None, - }); - let BlockState::Reasoning { - text, - signature, - redacted, - } = entry - else { - return Vec::new(); - }; - let mut events = Vec::new(); - if text.is_empty() && signature.is_none() && redacted.is_none() { - events.push(StreamEvent::ReasoningStart); - } - // Streaming reasoning deltas carry text/signature as FLAT union - // members (unlike the nested request-side reasoningText block). - if let Some(fragment) = reasoning.get("text").and_then(Value::as_str) { - text.push_str(fragment); - events.push(StreamEvent::ReasoningDelta { - delta: fragment.to_string(), - }); - } - if let Some(sig) = reasoning.get("signature").and_then(Value::as_str) { - *signature = Some(sig.to_string()); - } - if let Some(blob) = reasoning.get("redactedContent").and_then(Value::as_str) { - *redacted = Some(blob.to_string()); - } - return events; - } - - Vec::new() - } - - fn on_block_stop(&mut self, payload: &Value) -> Vec { - let index = Self::block_index(payload); - let Some(block) = self.blocks.remove(&index) else { - return Vec::new(); - }; - match block { - BlockState::Text(text) => { - let mut events = Vec::new(); - if self.text_started { - self.text_started = false; - events.push(StreamEvent::TextEnd { text_id: None }); - } - if !text.is_empty() { - self.parts.push(ContentPart::text(&text)); - } - events - } - BlockState::Reasoning { - text, - signature, - redacted, - } => { - let part = if let Some(blob) = redacted { - ThinkingData { - text: blob, - signature: None, - redacted: true, - } - } else { - ThinkingData { - text, - signature, - redacted: false, - } - }; - self.parts.push(ContentPart::Thinking(part)); - vec![StreamEvent::ReasoningEnd] - } - BlockState::ToolUse { id, name, input } => { - // A no-argument tool call streams no input fragments, leaving - // the buffer empty; canonically that is an empty object, not - // null (matching the anthropic/openai codecs, and what Bedrock - // wants back on re-encode). - let arguments = parse_tool_arguments_or_empty(&input); - let mut tool_call = ToolCall::new(&id, &name, arguments); - tool_call.raw_arguments = Some(input); - self.parts.push(ContentPart::ToolCall(tool_call.clone())); - vec![StreamEvent::ToolCallEnd { tool_call }] - } - } - } - - /// Build the final `Finish` from accumulated state. - fn finish_event(&mut self) -> StreamEvent { - self.finished = true; - // Flush any blocks that never saw a contentBlockStop. - let dangling: Vec = self.blocks.keys().copied().collect(); - for index in dangling { - let _ = self.on_block_stop(&serde_json::json!({ "contentBlockIndex": index })); - } - - let response = Response { - id: uuid::Uuid::new_v4().to_string(), - model: self.model.clone(), - provider: self.provider_name.clone(), - message: Message { - role: Role::Assistant, - content: std::mem::take(&mut self.parts), - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - StreamEvent::finish(self.finish_reason.clone(), self.usage.clone(), response) - } -} - -impl StreamDecoder for ConverseStreamDecoder { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - let Some(event_type) = ev.event else { - return Ok(Vec::new()); - }; - let payload: Value = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("converse stream event json: {e}"), e))?; - - Ok(match event_type { - "contentBlockStart" => self.on_block_start(&payload), - "contentBlockDelta" => self.on_block_delta(&payload), - "contentBlockStop" => self.on_block_stop(&payload), - "messageStop" => { - self.finish_reason = - map_stop_reason(payload.get("stopReason").and_then(Value::as_str)); - Vec::new() - } - "metadata" => { - self.usage = token_counts_from_usage(payload.get("usage")); - vec![self.finish_event()] - } - // `messageStart` carries nothing this decoder needs — the driving - // loop owns `StreamStart` — and unknown event types are tolerated - // because the union grows. - _ => Vec::new(), - }) - } - - /// Byte-stream end: `metadata` is the documented terminus, but if the - /// stream ends without one, synthesize the `Finish` from accumulated - /// state so callers still receive a response (mirrors the gemini - /// decoder's unconditional synthesis). - fn finish(&mut self) -> Vec { - if self.finished { - return Vec::new(); - } - vec![self.finish_event()] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::{Message as RequestMessage, Request}; - - fn decoder() -> ConverseStreamDecoder { - let request = Request { - model: "us.anthropic.claude-sonnet-4-6".to_string(), - messages: vec![RequestMessage::user("hi")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "bedrock", - deployment_id: "us.anthropic.claude-sonnet-4-6", - model: None, - params: ¶ms, - }; - ConverseStreamDecoder::new(&ctx, None) - } - - fn feed(decoder: &mut ConverseStreamDecoder, event: &str, data: &str) -> Vec { - decoder - .on_event(RawEvent { - event: Some(event), - data, - }) - .unwrap() - } - - #[test] - fn text_happy_path_finishes_on_metadata() { - let mut d = decoder(); - // `StreamStart` belongs to the driving loop, not the decoder. - assert!(feed(&mut d, "messageStart", r#"{"role":"assistant"}"#).is_empty()); - let events = feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#, - ); - assert!(matches!(events[0], StreamEvent::TextStart { .. })); - assert!(matches!(events[1], StreamEvent::TextDelta { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - assert!(matches!(stop[0], StreamEvent::TextEnd { .. })); - assert!(feed(&mut d, "messageStop", r#"{"stopReason":"end_turn"}"#).is_empty()); - - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":12,"outputTokens":5,"totalTokens":17}}"#, - ); - let StreamEvent::Finish { - finish_reason, - usage, - response, - } = &finish[0] - else { - panic!("expected Finish"); - }; - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(usage.input_tokens, 12); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.provider, "bedrock"); - // Byte-stream end after metadata adds nothing. - assert!(d.finish().is_empty()); - } - - #[test] - fn no_argument_tool_call_decodes_empty_object_not_null() { - // A no-arg tool call (e.g. TaskList) streams no input fragments; the - // arguments must be `{}` so it re-encodes to a valid Converse input. - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"TaskList"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.arguments, serde_json::json!({})); - assert!(!tool_call.arguments.is_null()); - } - - #[test] - fn streamed_tool_use_names_are_preserved_verbatim() { - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search???"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.name, "search???"); - } - - #[test] - fn tool_use_accumulates_string_input_fragments() { - let mut d = decoder(); - feed(&mut d, "messageStart", r#"{"role":"assistant"}"#); - let start = feed( - &mut d, - "contentBlockStart", - r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search"}},"contentBlockIndex":0}"#, - ); - assert!(matches!(start[0], StreamEvent::ToolCallStart { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"toolUse":{"input":"{\"que"}},"contentBlockIndex":0}"#, - ); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"toolUse":{"input":"ry\":\"foo\"}"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else { - panic!("expected ToolCallEnd"); - }; - assert_eq!(tool_call.id, "tool-1"); - assert_eq!(tool_call.arguments["query"], "foo"); - - feed(&mut d, "messageStop", r#"{"stopReason":"tool_use"}"#); - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ); - let StreamEvent::Finish { finish_reason, .. } = &finish[0] else { - panic!("expected Finish"); - }; - assert_eq!(*finish_reason, FinishReason::ToolCalls); - } - - #[test] - fn reasoning_deltas_round_trip_signature() { - let mut d = decoder(); - let events = feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"reasoningContent":{"text":"thinking"}},"contentBlockIndex":0}"#, - ); - assert!(matches!(events[0], StreamEvent::ReasoningStart)); - assert!(matches!(events[1], StreamEvent::ReasoningDelta { .. })); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"reasoningContent":{"signature":"sig-9"}},"contentBlockIndex":0}"#, - ); - let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#); - assert!(matches!(stop[0], StreamEvent::ReasoningEnd)); - - let finish = feed( - &mut d, - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ); - let StreamEvent::Finish { response, .. } = &finish[0] else { - panic!("expected Finish"); - }; - let ContentPart::Thinking(thinking) = &response.message.content[0] else { - panic!("expected thinking part"); - }; - assert_eq!(thinking.text, "thinking"); - assert_eq!(thinking.signature.as_deref(), Some("sig-9")); - } - - #[test] - fn stream_end_without_metadata_synthesizes_finish() { - let mut d = decoder(); - feed( - &mut d, - "contentBlockDelta", - r#"{"delta":{"text":"partial"},"contentBlockIndex":0}"#, - ); - let events = d.finish(); - let StreamEvent::Finish { response, .. } = &events[0] else { - panic!("expected synthesized Finish"); - }; - assert_eq!(response.text(), "partial"); - // Synthesis happens once. - assert!(d.finish().is_empty()); - } - - #[test] - fn unknown_events_are_tolerated() { - let mut d = decoder(); - assert!(feed(&mut d, "futureEventKind", r#"{"anything":1}"#).is_empty()); - assert!( - d.on_event(RawEvent { - event: None, - data: "{}", - }) - .unwrap() - .is_empty() - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/cache.rs b/lib/components/fabro-llm/src/codec/cache.rs deleted file mode 100644 index ed1afe8ab..000000000 --- a/lib/components/fabro-llm/src/codec/cache.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Shared prompt-cache policy: whether a request opts into explicit -//! Anthropic-style caching and where the conversation breakpoint lands. -//! Dialect codecs apply these decisions to their own wire shapes. - -/// Anthropic-style `cache_control` annotation. -#[derive(serde::Serialize, Clone)] -pub(crate) struct CacheControl { - #[serde(rename = "type")] - pub kind: String, -} - -impl CacheControl { - pub(crate) fn ephemeral() -> Self { - Self { - kind: "ephemeral".to_string(), - } - } -} - -/// Whether automatic prompt caching applies to this request: the -/// `provider_options..auto_cache` opt-out defaults to enabled. -pub(crate) fn auto_cache_enabled( - provider_options: Option<&serde_json::Value>, - namespace: &str, -) -> bool { - provider_options - .and_then(|opts| opts.get(namespace)) - .and_then(|ns| ns.get("auto_cache")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(true) -} - -/// Index of the message carrying the conversation-prefix breakpoint: the -/// second-to-last user turn, so each iteration of an agent loop reuses the -/// prefix cached by the previous one. `user_turns[i]` is true when message -/// `i` advances the user side of the conversation (plain user messages, plus -/// tool results on dialects where they are separate messages). `None` until -/// the conversation has at least two user turns. -pub(crate) fn conversation_breakpoint_index(user_turns: &[bool]) -> Option { - let indices: Vec = user_turns - .iter() - .enumerate() - .filter_map(|(i, &is_user)| is_user.then_some(i)) - .collect(); - indices.len().checked_sub(2).map(|nth| indices[nth]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn auto_cache_enabled_by_default() { - assert!(auto_cache_enabled(None, "anthropic")); - } - - #[test] - fn auto_cache_enabled_when_true() { - let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn auto_cache_disabled_when_false() { - let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); - assert!(!auto_cache_enabled(Some(&opts), "openrouter")); - } - - #[test] - fn auto_cache_enabled_when_key_missing() { - let opts = serde_json::json!({"anthropic": {}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn auto_cache_reads_only_its_own_namespace() { - let opts = serde_json::json!({"openrouter": {"auto_cache": false}}); - assert!(auto_cache_enabled(Some(&opts), "anthropic")); - } - - #[test] - fn conversation_breakpoint_none_below_two_user_turns() { - assert_eq!(conversation_breakpoint_index(&[]), None); - assert_eq!(conversation_breakpoint_index(&[true]), None); - assert_eq!(conversation_breakpoint_index(&[true, false, false]), None); - } - - #[test] - fn conversation_breakpoint_with_exactly_two_user_turns() { - assert_eq!(conversation_breakpoint_index(&[true, false, true]), Some(0)); - } - - #[test] - fn conversation_breakpoint_targets_second_to_last_user_turn() { - let turns = [true, false, true, false, true]; - assert_eq!(conversation_breakpoint_index(&turns), Some(2)); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs deleted file mode 100644 index c0bfdd5bc..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/decode.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Response decoding: Gemini `generateContent` body → canonical `Response`, -//! plus the gRPC-status error mapping behind the codec's `decode_error`. - -use serde::Deserialize; - -use super::wire::{ApiResponse, CountTokensResponse, UsageMetadata}; -use crate::codec::CodecCtx; -use crate::error::{ - Error, ProviderErrorDetail, ProviderErrorKind, error_from_grpc_status, error_from_status_code, -}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Map Gemini's finish reason, inferring `ToolCalls` from content when needed. -pub(super) fn map_finish_reason(reason: Option<&str>, has_function_calls: bool) -> FinishReason { - if has_function_calls { - return FinishReason::ToolCalls; - } - match reason { - Some("STOP") | None => FinishReason::Stop, - Some("MAX_TOKENS") => FinishReason::Length, - Some("SAFETY" | "RECITATION") => FinishReason::ContentFilter, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -pub(super) fn parse_part(part: &serde_json::Value) -> Option { - if let Some(text) = part.get("text").and_then(serde_json::Value::as_str) { - let is_thought = part - .get("thought") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if is_thought { - return Some(ContentPart::Thinking(ThinkingData { - text: text.to_string(), - signature: None, - redacted: false, - })); - } - return Some(ContentPart::text(text)); - } - if let Some(fc) = part.get("functionCall") { - let name = fc.get("name")?.as_str()?.to_string(); - let args = fc - .get("args") - .cloned() - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let mut tc = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); - // Preserve thought_signature for Gemini 3 models (sibling of functionCall in - // the part) - if let Some(sig) = part.get("thoughtSignature") { - tc.provider_metadata = Some(serde_json::json!({"thoughtSignature": sig})); - } - return Some(ContentPart::ToolCall(tc)); - } - None -} - -/// Check if any parts contain function calls. -pub(super) fn parts_have_function_calls(parts: &[serde_json::Value]) -> bool { - parts.iter().any(|p| p.get("functionCall").is_some()) -} - -/// Convert `UsageMetadata` from the Gemini API into a unified `TokenCounts`. -pub(super) fn parse_usage(metadata: Option<&UsageMetadata>) -> TokenCounts { - metadata.map_or_else(TokenCounts::default, |u| { - let cache_read_tokens = u.cached_content_token_count.unwrap_or(0); - let reasoning_tokens = u.thoughts_token_count.unwrap_or(0); - let tool_use_prompt_tokens = u.tool_use_prompt_token_count.unwrap_or(0); - TokenCounts { - input_tokens: u - .prompt_token_count - .unwrap_or(0) - .saturating_sub(cache_read_tokens) - + tool_use_prompt_tokens, - output_tokens: u.candidates_token_count.unwrap_or(0), - reasoning_tokens, - cache_read_tokens, - ..TokenCounts::default() - } - }) -} - -/// Map a Gemini error response using gRPC status when available, falling back -/// to HTTP status. -pub(super) fn gemini_error( - status_code: u16, - msg: String, - provider: &str, - grpc_status: Option, - raw: Option, - retry_after: Option, -) -> Error { - match grpc_status { - Some(grpc_code) => error_from_grpc_status( - &grpc_code, - msg, - provider.to_string(), - Some(grpc_code.clone()), - raw, - retry_after, - ), - None => error_from_status_code( - status_code, - msg, - provider.to_string(), - None, - raw, - retry_after, - ), - } -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse Gemini response: {e}"), e))?; - let api_resp = ApiResponse::deserialize(&raw) - .map_err(|e| Error::network(format!("failed to parse Gemini response: {e}"), e))?; - - let candidate = api_resp - .candidates - .as_ref() - .and_then(|c| c.first()) - .ok_or_else(|| Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new( - "no candidates in Gemini response", - ctx.provider_name, - )), - })?; - - let raw_parts = candidate.content.as_ref().and_then(|c| c.parts.as_ref()); - - let content_parts: Vec = raw_parts - .map(|parts| parts.iter().filter_map(parse_part).collect()) - .unwrap_or_default(); - - // Gemini has no dedicated tool_calls finish reason; infer from parts - let has_tool_calls = raw_parts.is_some_and(|p| parts_have_function_calls(p)); - let finish_reason = map_finish_reason(candidate.finish_reason.as_deref(), has_tool_calls); - - let usage = parse_usage(api_resp.usage_metadata.as_ref()); - - Ok(Response { - id: uuid::Uuid::new_v4().to_string(), - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: CountTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse Gemini token count: {e}"), - source: None, - })?; - Ok(response.total_tokens) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn token_counts_disjoint_with_cache_thoughts_and_tool_use() { - let body = serde_json::json!({ - "promptTokenCount": 200, - "cachedContentTokenCount": 180, - "candidatesTokenCount": 200, - "thoughtsTokenCount": 300, - "toolUsePromptTokenCount": 400 - }); - let meta: UsageMetadata = serde_json::from_value(body).unwrap(); - let usage = parse_usage(Some(&meta)); - - assert_eq!(usage.input_tokens, 420); - assert_eq!(usage.cache_read_tokens, 180); - assert_eq!(usage.output_tokens, 200); - assert_eq!(usage.reasoning_tokens, 300); - assert_eq!(usage.cache_write_tokens, 0); - assert_eq!(usage.total_tokens(), 1100); - } - - #[test] - fn gemini_error_uses_grpc_status_when_available() { - let err = gemini_error( - 400, - "model not found".into(), - "gemini", - Some("NOT_FOUND".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = gemini_error( - 400, - "bad args".into(), - "gemini", - Some("INVALID_ARGUMENT".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = gemini_error( - 429, - "rate limited".into(), - "gemini", - Some("RESOURCE_EXHAUSTED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - - let err = gemini_error( - 401, - "bad key".into(), - "gemini", - Some("UNAUTHENTICATED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - - let err = gemini_error( - 403, - "denied".into(), - "gemini", - Some("PERMISSION_DENIED".into()), - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let err = gemini_error( - 504, - "timeout".into(), - "gemini", - Some("DEADLINE_EXCEEDED".into()), - None, - None, - ); - assert!(matches!(err, Error::RequestTimeout { .. })); - } - - #[test] - fn gemini_error_falls_back_to_http_status_without_grpc() { - let err = gemini_error(429, "rate limited".into(), "gemini", None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - - let err = gemini_error(500, "internal".into(), "gemini", None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - } - - #[test] - fn parse_part_handles_thought_text() { - let part = serde_json::json!({"text": "Let me think about this...", "thought": true}); - let result = parse_part(&part).expect("should parse thought part"); - match result { - ContentPart::Thinking(td) => { - assert_eq!(td.text, "Let me think about this..."); - assert!(td.signature.is_none()); - assert!(!td.redacted); - } - other => panic!("expected Thinking, got {other:?}"), - } - } - - #[test] - fn parse_part_text_without_thought_flag() { - let part = serde_json::json!({"text": "Hello world"}); - let result = parse_part(&part).expect("should parse text part"); - match result { - ContentPart::Text(text) => assert_eq!(text, "Hello world"), - other => panic!("expected Text, got {other:?}"), - } - } - - #[test] - fn parse_part_function_call() { - let part = serde_json::json!({ - "functionCall": { - "name": "get_weather", - "args": {"location": "NYC"} - } - }); - let result = parse_part(&part).expect("should parse function call"); - match result { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "get_weather"); - assert_eq!(tc.arguments, serde_json::json!({"location": "NYC"})); - assert!(tc.provider_metadata.is_none()); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_part_function_call_with_thought_signature() { - let part = serde_json::json!({ - "functionCall": { - "name": "get_weather", - "args": {"location": "NYC"} - }, - "thoughtSignature": "abc123sig" - }); - let result = parse_part(&part).expect("should parse function call with thought signature"); - match result { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "get_weather"); - let meta = tc - .provider_metadata - .expect("provider_metadata should be set"); - assert_eq!(meta["thoughtSignature"], "abc123sig"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_part_thought_false_is_regular_text() { - let part = serde_json::json!({"text": "Regular text", "thought": false}); - let result = parse_part(&part).expect("should parse text part"); - match result { - ContentPart::Text(text) => assert_eq!(text, "Regular text"), - other => panic!("expected Text, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs b/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs deleted file mode 100644 index d85ab691e..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/encode.rs +++ /dev/null @@ -1,656 +0,0 @@ -//! Request encoding: canonical request → Gemini `generateContent` body + -//! fully-formed endpoint (model-in-path, `?alt=sse` for streaming). -//! -//! Pure and sync. File-backed Image/Audio/Document attachments are resolved -//! to inline data by `attachments::resolve` in the adapter *before* encode -//! runs, so the content translation here never touches the filesystem. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::wire::{ - ApiRequest, Content, GeminiFunctionDecl, GeminiToolGroup, GenerationOptions, SystemInstruction, -}; -use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt}; -use crate::types::{ - ContentPart, Message, ResponseFormat, ResponseFormatType, Role, ToolChoice, ToolDefinition, -}; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - let endpoint = if stream { - format!( - "/models/{}:streamGenerateContent?alt=sse", - ctx.deployment_id - ) - } else { - format!("/models/{}:generateContent", ctx.deployment_id) - }; - EncodedRequest { - body: build_body(ctx), - endpoint, - headers: Vec::new(), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - EncodedRequest { - body: serde_json::json!({ "generateContentRequest": build_body(ctx) }), - endpoint: format!("/models/{}:countTokens", ctx.deployment_id), - headers: Vec::new(), - } -} - -/// Build the Gemini API request body from the canonical request. -/// -/// Returns a `serde_json::Value` so that `provider_options.gemini` fields can -/// be merged into the request before sending. -pub(super) fn build_body(ctx: &CodecCtx<'_>) -> serde_json::Value { - let request = ctx.request; - let (system_text, other_messages) = extract_system_prompt(&request.messages); - - let system_instruction = system_text.map(|text| SystemInstruction { - parts: vec![serde_json::json!({"text": text})], - }); - - let contents = translate_messages(&other_messages); - - let (response_mime_type, response_schema) = request - .response_format - .as_ref() - .map_or((None, None), translate_response_format); - - let generation_config = GenerationOptions { - temperature: request.temperature, - max_output_tokens: request.max_tokens, - top_p: request.top_p, - stop_sequences: request.stop_sequences.clone(), - response_mime_type, - response_schema, - }; - - let api_tools = request.tools.as_ref().map(|t| translate_tools(t)); - let tool_config = request.tool_choice.as_ref().map(translate_tool_choice); - - let api_request = ApiRequest { - contents, - system_instruction, - generation_config: Some(generation_config), - tools: api_tools, - tool_config, - }; - - let mut body = serde_json::to_value(&api_request).unwrap_or_default(); - merge_provider_options(&mut body, request.provider_options.as_ref()); - apply_default_safety_settings(&mut body); - body -} - -// --- Content / message / tool translation ------------------------------------ - -/// Build a mapping from tool call ID to function name by scanning assistant -/// messages. -/// -/// Gemini uses function names (not call IDs) in `functionResponse`. Since the -/// decoder generates synthetic UUIDs as tool call IDs, we need this mapping to -/// recover the original function name when sending tool results back. -fn build_tool_call_id_to_name(messages: &[&Message]) -> std::collections::HashMap { - let mut map = std::collections::HashMap::new(); - for msg in messages { - if msg.role == Role::Assistant { - for part in &msg.content { - if let ContentPart::ToolCall(tc) = part { - map.insert(tc.id.clone(), tc.name.clone()); - } - } - } - } - map -} - -/// Encode a media attachment part: URL-backed attachments become `fileData`, -/// inline bytes become base64 `inlineData`. -fn media_part( - url: Option<&str>, - data: Option<&[u8]>, - media_type: Option<&str>, - default_mime: &str, -) -> Option { - let mime = media_type.unwrap_or(default_mime); - match url { - Some(url) => Some(serde_json::json!({ - "fileData": {"mimeType": mime, "fileUri": url} - })), - None => data.map(|data| { - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"inlineData": {"mimeType": mime, "data": b64}}) - }), - } -} - -/// Translate unified messages to Gemini content format. Sync: file-backed -/// attachments are already resolved to inline data upstream. -pub(super) fn translate_messages(messages: &[&Message]) -> Vec { - let id_to_name = build_tool_call_id_to_name(messages); - let mut contents: Vec = Vec::new(); - - for msg in messages { - let role = match msg.role { - Role::Assistant => "model", - Role::User | Role::Tool => "user", - Role::System | Role::Developer => continue, - }; - - let mut parts = Vec::new(); - for part in &msg.content { - let maybe_part = match part { - ContentPart::Text(text) => Some(serde_json::json!({"text": text})), - ContentPart::ToolCall(tc) => { - let mut part_json = serde_json::json!({ - "functionCall": { - "name": tc.name, - "args": tc.arguments, - } - }); - // Re-attach thought_signature as sibling of functionCall - if let Some(sig) = tc - .provider_metadata - .as_ref() - .and_then(|m| m.get("thoughtSignature")) - { - part_json["thoughtSignature"] = sig.clone(); - } - Some(part_json) - } - ContentPart::Image(img) => media_part( - img.url.as_deref(), - img.data.as_deref(), - img.media_type.as_deref(), - "image/png", - ), - ContentPart::Audio(audio) => media_part( - audio.url.as_deref(), - audio.data.as_deref(), - audio.media_type.as_deref(), - "audio/wav", - ), - ContentPart::Document(doc) => media_part( - doc.url.as_deref(), - doc.data.as_deref(), - doc.media_type.as_deref(), - "application/pdf", - ), - ContentPart::ToolResult(tr) => { - // Gemini's functionResponse uses the function *name*, not the call ID. - // Look up the original function name from the tool call mapping. - let function_name = id_to_name - .get(&tr.tool_call_id) - .cloned() - .unwrap_or_else(|| tr.tool_call_id.clone()); - let response = tr.content.as_str().map_or_else( - || { - if tr.content.is_object() { - tr.content.clone() - } else { - serde_json::json!({"result": tr.content.to_string()}) - } - }, - |s| serde_json::json!({"result": s}), - ); - Some(serde_json::json!({ - "functionResponse": { - "name": function_name, - "response": response, - } - })) - } - _ => None, - }; - if let Some(part_json) = maybe_part { - parts.push(part_json); - } - } - - if parts.is_empty() { - continue; - } - - contents.push(Content { - role: role.to_string(), - parts, - }); - } - - contents -} - -/// Translate unified tool definitions to Gemini's format. -fn translate_tools(tools: &[ToolDefinition]) -> Vec { - vec![GeminiToolGroup { - function_declarations: tools - .iter() - .map(|t| GeminiFunctionDecl { - name: t.name.clone(), - description: t.description.clone(), - parameters: t.parameters.clone(), - }) - .collect(), - }] -} - -/// Translate unified `ToolChoice` to Gemini's `toolConfig`. -fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!({ - "functionCallingConfig": {"mode": "AUTO"} - }), - ToolChoice::None => serde_json::json!({ - "functionCallingConfig": {"mode": "NONE"} - }), - ToolChoice::Required => serde_json::json!({ - "functionCallingConfig": {"mode": "ANY"} - }), - ToolChoice::Named { tool_name } => serde_json::json!({ - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [tool_name], - } - }), - } -} - -/// Translate unified `ResponseFormat` to Gemini generation config fields. -/// -/// Returns `(response_mime_type, response_schema)`. -fn translate_response_format( - format: &ResponseFormat, -) -> (Option, Option) { - match format.kind { - ResponseFormatType::Text => (None, None), - ResponseFormatType::JsonObject => (Some("application/json".to_string()), None), - ResponseFormatType::JsonSchema => ( - Some("application/json".to_string()), - format.json_schema.clone(), - ), - } -} - -/// Merge `provider_options.gemini` fields into the serialized API request body. -/// -/// Known fields like `safety_settings` and `cached_content` are set directly. -/// Any other fields are merged at the top level, allowing pass-through of -/// Gemini-specific options not covered by the unified schema. -fn merge_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, -) { - let Some(gemini_opts) = provider_options.and_then(|opts| opts.get("gemini")) else { - return; - }; - let Some(body_map) = body.as_object_mut() else { - return; - }; - let Some(gemini_map) = gemini_opts.as_object() else { - return; - }; - - for (key, value) in gemini_map { - body_map.insert(key.clone(), value.clone()); - } -} - -/// Apply default safety settings if none were provided via provider_options. -fn apply_default_safety_settings(body: &mut serde_json::Value) { - if body.get("safety_settings").is_some() { - return; - } - if let Some(body_map) = body.as_object_mut() { - body_map.insert( - "safety_settings".to_string(), - serde_json::json!([{ - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - }]), - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::{AudioData, DocumentData, Request, ToolCall}; - - fn minimal_request() -> Request { - Request { - model: "gemini-2.0-flash".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Build the request body the way the adapter's encode path does (no - /// catalog: the wire model id is the request model). - fn body_for(request: &Request) -> serde_json::Value { - let params = CodecParams::default(); - let ctx = CodecCtx { - request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - build_body(&ctx) - } - - #[test] - fn provider_options_none_produces_standard_body() { - let request = minimal_request(); - let body = body_for(&request); - assert!(body.get("safetySettings").is_none()); - assert!(body.get("cachedContent").is_none()); - } - - #[test] - fn encode_endpoints_carry_model_and_streaming_variant() { - let request = minimal_request(); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - - assert_eq!( - encode(&ctx, false).endpoint, - "/models/gemini-2.0-flash:generateContent" - ); - assert_eq!( - encode(&ctx, true).endpoint, - "/models/gemini-2.0-flash:streamGenerateContent?alt=sse" - ); - assert_eq!( - encode_count_tokens(&ctx).endpoint, - "/models/gemini-2.0-flash:countTokens" - ); - } - - #[test] - fn count_tokens_body_uses_only_generate_content_request_top_level() { - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "gemini", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - let count_body = encode_count_tokens(&ctx).body; - - assert!(count_body.get("generateContentRequest").is_some()); - assert!(count_body.get("contents").is_none()); - assert!( - count_body["generateContentRequest"] - .get("contents") - .is_some() - ); - assert!(count_body["generateContentRequest"].get("tools").is_some()); - } - - #[test] - fn provider_options_gemini_safety_settings_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "safetySettings": [ - {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"} - ] - } - })); - - let body = body_for(&request); - let safety = body - .get("safetySettings") - .expect("safetySettings should be present"); - let arr = safety.as_array().expect("should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["category"], "HARM_CATEGORY_HARASSMENT"); - } - - #[test] - fn provider_options_gemini_cached_content_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "cachedContent": "projects/my-project/cachedContents/abc123" - } - })); - - let body = body_for(&request); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("projects/my-project/cachedContents/abc123") - ); - } - - #[test] - fn provider_options_gemini_multiple_fields_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "safetySettings": [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_LOW_AND_ABOVE"}], - "cachedContent": "cache-id", - "customField": "custom-value" - } - })); - - let body = body_for(&request); - assert!(body.get("safetySettings").is_some()); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("cache-id") - ); - assert_eq!( - body.get("customField").and_then(serde_json::Value::as_str), - Some("custom-value") - ); - } - - #[test] - fn provider_options_other_provider_ignored() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "anthropic": { - "auto_cache": false - } - })); - - let body = body_for(&request); - assert!(body.get("auto_cache").is_none()); - } - - #[test] - fn provider_options_gemini_preserves_standard_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.5); - request.max_tokens = Some(100); - request.provider_options = Some(serde_json::json!({ - "gemini": { - "cachedContent": "cache-id" - } - })); - - let body = body_for(&request); - let gen_config = body - .get("generationConfig") - .expect("generationConfig should exist"); - assert_eq!( - gen_config - .get("temperature") - .and_then(serde_json::Value::as_f64), - Some(0.5) - ); - assert_eq!( - gen_config - .get("maxOutputTokens") - .and_then(serde_json::Value::as_i64), - Some(100) - ); - assert_eq!( - body.get("cachedContent") - .and_then(serde_json::Value::as_str), - Some("cache-id") - ); - } - - #[test] - fn merge_provider_options_with_non_object_gemini_value() { - let mut body = serde_json::json!({"contents": []}); - let opts = serde_json::json!({"gemini": "not-an-object"}); - merge_provider_options(&mut body, Some(&opts)); - // Should not crash and body should be unchanged - assert!(body.get("contents").is_some()); - } - - #[test] - fn audio_url_translates_to_file_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: Some("audio/wav".to_string()), - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - let part = &contents[0].parts[0]; - assert_eq!(part["fileData"]["mimeType"], "audio/wav"); - assert_eq!(part["fileData"]["fileUri"], "https://example.com/audio.wav"); - } - - #[test] - fn audio_base64_translates_to_inline_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: None, - data: Some(vec![0xFF, 0xFB, 0x90]), - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["inlineData"]["mimeType"], "audio/wav"); - assert!(part["inlineData"]["data"].as_str().is_some()); - } - - #[test] - fn document_url_translates_to_file_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("doc.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["fileData"]["mimeType"], "application/pdf"); - assert_eq!(part["fileData"]["fileUri"], "https://example.com/doc.pdf"); - } - - #[test] - fn document_base64_translates_to_inline_data() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0x25, 0x50, 0x44, 0x46]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - let part = &contents[0].parts[0]; - assert_eq!(part["inlineData"]["mimeType"], "application/pdf"); - assert!(part["inlineData"]["data"].as_str().is_some()); - } - - #[test] - fn translate_messages_function_call_includes_thought_signature() { - let mut tc = ToolCall::new( - "call-1", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - tc.provider_metadata = Some(serde_json::json!({"thoughtSignature": "sig456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - - let part = &contents[0].parts[0]; - assert!(part.get("functionCall").is_some()); - assert_eq!(part["thoughtSignature"], "sig456"); - } - - #[test] - fn translate_messages_function_call_without_thought_signature() { - let tc = ToolCall::new( - "call-1", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let contents = translate_messages(&[&msg]); - assert_eq!(contents.len(), 1); - - let part = &contents[0].parts[0]; - assert!(part.get("functionCall").is_some()); - assert!(part.get("thoughtSignature").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs b/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs deleted file mode 100644 index dab98c4c9..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! The Gemini `generateContent` codec. -//! -//! Pure translation: no HTTP, auth, or base URL — the adapter shell owns -//! those. The codec is distinctive in two ways: it fully forms its endpoints -//! (model-in-path plus `?alt=sse` for streaming), and it overrides -//! `decode_error` to map Gemini's gRPC status codes out of error bodies -//! (falling back to the HTTP status). Tool-call ids are synthetic UUIDs — -//! Gemini keys `functionResponse` on the function *name*, recovered via an -//! id→name map built from the request's assistant turns. - -mod decode; -mod encode; -mod stream; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder, parse_error_body}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the Gemini `generateContent` wire dialect. -pub(crate) struct GeminiGenerate; - -impl Codec for GeminiGenerate { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new(ctx, rate_limit)) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } - - /// Gemini errors carry a gRPC status in the body's `status` field; map it - /// when present, falling back to the HTTP status code. - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - let (msg, code, raw) = parse_error_body(body, "status"); - decode::gemini_error(status, msg, ctx.provider_name, code, raw, retry_after) - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs b/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs deleted file mode 100644 index 51c39eb99..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/stream.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Streaming decoder: Gemini SSE chunks → canonical `StreamEvent`s. -//! -//! Byte reading and line framing live in the transport; this decoder is fed -//! framed `RawEvent`s carrying bare `data:` payloads (Gemini uses data-only -//! SSE — no event types, no `[DONE]` sentinel). Gemini has no terminal wire -//! event, so `finish()` synthesizes the `Finish` from accumulated state -//! unconditionally at byte-stream end. - -use super::decode::{map_finish_reason, parse_usage}; -use super::wire::ApiResponse; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::types::{ - ContentPart, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, TokenCounts, - ToolCall, -}; - -/// Accumulated state across SSE chunks during streaming. -pub(super) struct SseAccumulator { - /// Requested model, stamped into the synthesized final `Response`. - model: String, - /// Configured provider name stamped into the final `Response.provider`. - provider: String, - /// Whether we have emitted a `TextStart` event. - text_started: bool, - /// Whether we are currently inside a reasoning (thought) segment. - reasoning_started: bool, - /// Accumulated thinking text across all chunks. - accumulated_thinking: String, - /// Accumulated text across all chunks. - accumulated_text: String, - /// Accumulated tool calls across all chunks. - accumulated_tool_calls: Vec, - /// The `text_id` used for `TextStart`/`TextDelta`/`TextEnd`. - text_id: String, - /// Latest usage metadata (updated per chunk; final chunk has totals). - usage: TokenCounts, - /// The finish reason string from the candidate, if received. - finish_reason_str: Option, - /// Whether we have emitted the `Finish` event. - finished: bool, - /// Rate limit info parsed from HTTP response headers. - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - text_started: false, - reasoning_started: false, - accumulated_thinking: String::new(), - accumulated_text: String::new(), - accumulated_tool_calls: Vec::new(), - text_id: uuid::Uuid::new_v4().to_string(), - usage: TokenCounts::default(), - finish_reason_str: None, - finished: false, - rate_limit, - } - } - - /// Extract stream events from a parsed SSE chunk. - fn process_chunk(&mut self, chunk: &ApiResponse) -> Vec { - let mut events = Vec::new(); - - let parts = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.content.as_ref()) - .and_then(|c| c.parts.as_ref()); - - if let Some(parts) = parts { - for part in parts { - let is_thought = part - .get("thought") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - if let Some(text) = part.get("text").and_then(serde_json::Value::as_str) { - if is_thought { - if !self.reasoning_started { - self.reasoning_started = true; - events.push(StreamEvent::ReasoningStart); - } - self.accumulated_thinking.push_str(text); - events.push(StreamEvent::ReasoningDelta { - delta: text.to_string(), - }); - } else { - // Transition from reasoning to text: close reasoning segment. - if self.reasoning_started { - self.reasoning_started = false; - events.push(StreamEvent::ReasoningEnd); - } - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { - text_id: Some(self.text_id.clone()), - }); - } - self.accumulated_text.push_str(text); - events.push(StreamEvent::text_delta(text, Some(self.text_id.clone()))); - } - } else if let Some(fc) = part.get("functionCall") { - let name = fc - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let args = fc - .get("args") - .cloned() - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let mut tool_call = ToolCall::new(uuid::Uuid::new_v4().to_string(), name, args); - // Preserve thought_signature for Gemini 3 models (sibling of - // functionCall) - if let Some(sig) = part.get("thoughtSignature") { - tool_call.provider_metadata = - Some(serde_json::json!({"thoughtSignature": sig})); - } - - // Gemini delivers function calls as complete objects in a single - // chunk. - events.push(StreamEvent::ToolCallStart { - tool_call: tool_call.clone(), - }); - events.push(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }); - self.accumulated_tool_calls.push(tool_call); - } - } - } - - // If a finish reason is present on this chunk's candidate, emit TextEnd. - let has_finish_reason = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.finish_reason.as_ref()) - .is_some(); - - if has_finish_reason { - if self.reasoning_started { - self.reasoning_started = false; - events.push(StreamEvent::ReasoningEnd); - } - if self.text_started { - events.push(StreamEvent::TextEnd { - text_id: Some(self.text_id.clone()), - }); - } - } - - events - } - - /// Build the final `Finish` event from accumulated state. - fn build_finish_event(&self) -> StreamEvent { - let has_tool_calls = !self.accumulated_tool_calls.is_empty(); - let finish_reason = map_finish_reason(self.finish_reason_str.as_deref(), has_tool_calls); - - let mut content_parts: Vec = Vec::new(); - if !self.accumulated_thinking.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: self.accumulated_thinking.clone(), - signature: None, - redacted: false, - })); - } - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(&self.accumulated_text)); - } - for tc in &self.accumulated_tool_calls { - content_parts.push(ContentPart::ToolCall(tc.clone())); - } - - let response = Response { - id: uuid::Uuid::new_v4().to_string(), - model: self.model.clone(), - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - - StreamEvent::finish(finish_reason, self.usage.clone(), response) - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - // Parse the JSON chunk. - let chunk: ApiResponse = serde_json::from_str(ev.data).map_err(|e| { - Error::stream_error(format!("failed to parse Gemini SSE chunk: {e}"), e) - })?; - - let events = self.process_chunk(&chunk); - - // Track usage from every chunk; the final one will have the totals. - if let Some(ref usage_meta) = chunk.usage_metadata { - self.usage = parse_usage(Some(usage_meta)); - } - - // Extract finish reason from the candidate if present. - let candidate_finish = chunk - .candidates - .as_ref() - .and_then(|c| c.first()) - .and_then(|c| c.finish_reason.clone()); - if let Some(reason) = candidate_finish { - self.finish_reason_str = Some(reason); - } - - Ok(events) - } - - fn finish(&mut self) -> Vec { - // Gemini has no terminal wire event: synthesize the Finish from - // accumulated state, exactly once, at byte-stream end. - if self.finished { - return Vec::new(); - } - self.finished = true; - vec![self.build_finish_event()] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::FinishReason; - - /// Build an accumulator without threading a `CodecCtx`/`Request`: the test - /// module sees the private fields, so the few that matter are set - /// directly. - fn empty_accumulator() -> SseAccumulator { - SseAccumulator { - model: "gemini-2.0-flash".to_string(), - provider: "gemini".to_string(), - text_started: false, - reasoning_started: false, - accumulated_thinking: String::new(), - accumulated_text: String::new(), - accumulated_tool_calls: Vec::new(), - text_id: "text-1".to_string(), - usage: TokenCounts::default(), - finish_reason_str: None, - finished: false, - rate_limit: None, - } - } - - fn on_data(acc: &mut SseAccumulator, data: &str) -> Result, Error> { - acc.on_event(RawEvent { event: None, data }) - } - - #[test] - fn first_chunk_opens_text_without_a_decoder_level_stream_start() { - let mut acc = empty_accumulator(); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}]}"#, - ) - .expect("chunk should parse"); - - // `StreamStart` is the driving loop's, so the decoder's first event - // is the content itself. - assert!(matches!(events[0], StreamEvent::TextStart { .. })); - assert!(matches!(events[1], StreamEvent::TextDelta { .. })); - } - - #[test] - fn text_deltas_accumulate_with_stable_text_id() { - let mut acc = empty_accumulator(); - - let first = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}"#, - ) - .expect("first chunk should parse"); - let second = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}"#, - ) - .expect("second chunk should parse"); - - assert!( - matches!(&first[0], StreamEvent::TextStart { text_id: Some(id) } if id == "text-1") - ); - assert!( - matches!(&first[1], StreamEvent::TextDelta { delta, text_id: Some(id) } if delta == "Hel" && id == "text-1") - ); - // Second chunk: no duplicate TextStart. - assert_eq!(second.len(), 1); - assert!(matches!(&second[0], StreamEvent::TextDelta { delta, .. } if delta == "lo")); - assert_eq!(acc.accumulated_text, "Hello"); - } - - #[test] - fn thought_then_text_transitions_reasoning_to_text() { - let mut acc = empty_accumulator(); - - let thought = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Pondering...","thought":true}]}}]}"#, - ) - .expect("thought chunk should parse"); - let text = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Answer"}]}}]}"#, - ) - .expect("text chunk should parse"); - - assert!(matches!(thought[0], StreamEvent::ReasoningStart)); - assert!( - matches!(&thought[1], StreamEvent::ReasoningDelta { delta } if delta == "Pondering...") - ); - // Transition closes the reasoning segment before text begins. - assert!(matches!(text[0], StreamEvent::ReasoningEnd)); - assert!(matches!(text[1], StreamEvent::TextStart { .. })); - assert!(matches!(&text[2], StreamEvent::TextDelta { delta, .. } if delta == "Answer")); - assert_eq!(acc.accumulated_thinking, "Pondering..."); - } - - #[test] - fn function_call_emits_start_and_end_in_one_chunk() { - let mut acc = empty_accumulator(); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"NYC"}},"thoughtSignature":"sig1"}]}}]}"#, - ) - .expect("function call chunk should parse"); - - assert_eq!(events.len(), 2); - let (start_tc, end_tc) = match (&events[0], &events[1]) { - ( - StreamEvent::ToolCallStart { tool_call: start }, - StreamEvent::ToolCallEnd { tool_call: end }, - ) => (start, end), - other => panic!("expected ToolCallStart + ToolCallEnd, got {other:?}"), - }; - assert_eq!(start_tc.name, "get_weather"); - assert_eq!(start_tc.id, end_tc.id); - assert_eq!( - start_tc.provider_metadata.as_ref().unwrap()["thoughtSignature"], - "sig1" - ); - assert_eq!(acc.accumulated_tool_calls.len(), 1); - } - - #[test] - fn finish_reason_chunk_emits_text_end_and_records_reason() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}]}"#, - ) - .expect("text chunk should parse"); - - let events = on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}"#, - ) - .expect("finish chunk should parse"); - - assert!(matches!(&events[0], StreamEvent::TextEnd { text_id: Some(id) } if id == "text-1")); - assert_eq!(acc.finish_reason_str.as_deref(), Some("STOP")); - assert_eq!(acc.usage.input_tokens, 10); - assert_eq!(acc.usage.output_tokens, 5); - } - - #[test] - fn finish_synthesizes_final_response_exactly_once() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert_eq!(events.len(), 1); - match &events[0] { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.provider, "gemini"); - assert_eq!(response.model, "gemini-2.0-flash"); - } - other => panic!("expected Finish, got {other:?}"), - } - - // A second finish() (defensive) synthesizes nothing. - assert!(acc.finish().is_empty()); - } - - #[test] - fn finish_without_any_finish_reason_still_synthesizes() { - // Gemini has no terminal wire event; byte-stream end must produce a - // Finish even when no chunk carried a finishReason. - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert_eq!(events.len(), 1); - assert!( - matches!(&events[0], StreamEvent::Finish { finish_reason, .. } - if *finish_reason == FinishReason::Stop) - ); - } - - #[test] - fn finish_infers_tool_calls_finish_reason() { - let mut acc = empty_accumulator(); - on_data( - &mut acc, - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"search","args":{}}}]},"finishReason":"STOP"}]}"#, - ) - .expect("chunk should parse"); - - let events = acc.finish(); - assert!( - matches!(&events[0], StreamEvent::Finish { finish_reason, .. } - if *finish_reason == FinishReason::ToolCalls) - ); - } - - #[test] - fn malformed_chunk_yields_stream_error() { - let mut acc = empty_accumulator(); - let err = on_data(&mut acc, "not json").expect_err("bad chunk should error"); - assert!(matches!(err, Error::Stream { .. })); - } -} diff --git a/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs b/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs deleted file mode 100644 index d091d1464..000000000 --- a/lib/components/fabro-llm/src/codec/gemini_generate/wire.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Serde types mirroring the Gemini `generateContent` wire shapes. - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ApiRequest { - pub contents: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub system_instruction: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub generation_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_config: Option, -} - -#[derive(serde::Serialize)] -pub(super) struct Content { - pub role: String, - pub parts: Vec, -} - -#[derive(serde::Serialize)] -pub(super) struct SystemInstruction { - pub parts: Vec, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct GenerationOptions { - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop_sequences: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_mime_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_schema: Option, -} - -/// Gemini groups function declarations under a `tools` array. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct GeminiToolGroup { - pub function_declarations: Vec, -} - -#[derive(serde::Serialize)] -pub(super) struct GeminiFunctionDecl { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ApiResponse { - pub candidates: Option>, - pub usage_metadata: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct Candidate { - pub content: Option, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CandidateContent { - pub parts: Option>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct UsageMetadata { - pub prompt_token_count: Option, - pub candidates_token_count: Option, - pub thoughts_token_count: Option, - pub cached_content_token_count: Option, - pub tool_use_prompt_token_count: Option, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct CountTokensResponse { - pub total_tokens: i64, -} diff --git a/lib/components/fabro-llm/src/codec/mod.rs b/lib/components/fabro-llm/src/codec/mod.rs deleted file mode 100644 index 0e90c471f..000000000 --- a/lib/components/fabro-llm/src/codec/mod.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! The codec seam: pure, sync translation between the canonical core -//! (`Request`/`Response`/`StreamEvent`) and a provider wire dialect. -//! -//! A codec knows *what the bytes say*. It does NOT know how they travel -//! (auth, base URL, retries, streaming transport) — that's the adapter/ -//! transport layer. Everything a codec varies on arrives as data in -//! [`CodecCtx`] / [`CodecParams`]; codecs hold no per-request state. -//! -//! The trait is intentionally complete (count-tokens + error mapping have -//! defaults) so the per-dialect codecs that follow only ever *override* -//! methods, never extend the contract. - -pub(crate) mod anthropic_messages; -pub(crate) mod bedrock_converse; -pub(crate) mod cache; -pub(crate) mod gemini_generate; -pub(crate) mod openai_compatible; -pub(crate) mod openai_responses; - -use fabro_model::Model; - -use crate::error::{Error, error_from_status_code}; -use crate::types::{Message, RateLimitInfo, Request, Response, Role, StreamEvent}; - -/// Parse a streamed/generated tool-argument JSON string, defaulting malformed -/// or absent arguments to the canonical no-argument object. -pub(crate) fn parse_tool_arguments_or_empty(raw_arguments: &str) -> serde_json::Value { - serde_json::from_str(raw_arguments).unwrap_or_else(|_| serde_json::json!({})) -} - -/// Split an inclusive provider token total into disjoint base and detail -/// buckets. Provider detail counts are advisory and occasionally exceed their -/// parent total, so bound both values while preserving the nonnegative total. -pub(crate) fn split_inclusive_token_total(total: i64, detail: i64) -> (i64, i64) { - let total = total.max(0); - let detail = detail.clamp(0, total); - (total - detail, detail) -} - -/// Merge `provider_options.` fields into an encoded request -/// body. Used by codecs whose provider-options namespace is adapter-name keyed -/// rather than a single fixed provider. `known_keys` are control keys the -/// codec consumed itself (e.g. `auto_cache`); they are not re-merged into the -/// body. -pub(crate) fn merge_named_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, - provider_name: &str, - known_keys: &[&str], -) { - let Some(opts) = provider_options.and_then(|opts| opts.get(provider_name)) else { - return; - }; - let Some(body_map) = body.as_object_mut() else { - return; - }; - let Some(opts_map) = opts.as_object() else { - return; - }; - - for (key, value) in opts_map { - if known_keys.contains(&key.as_str()) { - continue; - } - body_map.insert(key.clone(), value.clone()); - } -} - -/// Per-request context. Borrowed — the codec reads what it needs and returns. -pub(crate) struct CodecCtx<'a> { - /// The canonical request being translated. Decoders read it too - /// (e.g. tool-argument parsing keys off the request's tool definitions; - /// the stream model fallback uses `request.model`). - pub request: &'a Request, - /// Identity stamped into `Response.provider`, and the `provider_options` - /// namespace key for the openai_compatible codec (moonshot/zai/…). - pub provider_name: &'a str, - /// The model id to send on the wire — catalog `api_id`, resolved by the - /// route (today `api_id == id` everywhere). - pub deployment_id: &'a str, - /// Model row for capability lookups (prompt_cache, reasoning levels, - /// max_output). `None` when no catalog is injected. - pub model: Option<&'a Model>, - /// Per-route dialect data (model/version placement, …). Defaulted to - /// today's direct-route values; Bedrock/OpenRouter add variants later. - pub params: &'a CodecParams, -} - -/// Per-route dialect knobs, expressed as data so one codec can serve several -/// routes. The default is inert ("nothing special"); a route that needs a -/// dialect quirk sets the relevant field. Grows as codecs need it — #459 adds -/// `ModelPlacement` for Bedrock. Inert for codecs that don't read a given -/// field. -#[derive(Debug, Default, Clone)] -pub(crate) struct CodecParams { - /// Where/whether to place the Anthropic API version. Direct Anthropic uses - /// `Header("2023-06-01")`; Kimi-over-anthropic uses `None`; the Bedrock - /// redo will add a body-field variant. Inert for non-anthropic codecs. - pub anthropic_version: AnthropicVersion, - /// Whether to emit Anthropic beta headers (prompt-caching / fast-mode / - /// 1M-context). True on the direct route, false for Kimi-over-anthropic. - pub anthropic_beta: bool, - /// Codex-endpoint dialect for the openai_responses codec: omit the - /// sampling params (`temperature`/`top_p`/`max_output_tokens`) the Codex - /// endpoint rejects and always send `instructions` (empty string when the - /// request has none). The transport-side half of codex mode (forced - /// streaming) is route config, not codec data. - pub openai_codex: bool, -} - -/// Placement of the Anthropic API version on the wire. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) enum AnthropicVersion { - /// No version sent (Kimi-over-anthropic; also the inert default). - #[default] - None, - /// `anthropic-version` request header (direct Anthropic). - Header(&'static str), - // BodyField(&'static str) arrives with the Bedrock redo (#459). -} - -/// What [`Codec::encode`] produces. The transport applies `endpoint` + -/// `headers` on top of the route's base URL and auth; the codec never touches -/// HTTP. -pub(crate) struct EncodedRequest { - /// Request body. - pub body: serde_json::Value, - /// Path appended to the route base URL, fully formed by the codec - /// (incl. model-in-path and `?alt=sse` for gemini). e.g. - /// `/chat/completions`. - pub endpoint: String, - /// Dialect headers as data (e.g. `anthropic-version`, beta headers). - /// NOT auth or `content-type` — those are the transport's job. Empty for - /// the openai_compatible codec. - pub headers: Vec<(String, String)>, -} - -/// One framed item off the byte stream, handed to a [`StreamDecoder`]. -pub(crate) struct RawEvent<'a> { - /// SSE `event:` type — `Some` when the framing carries one (anthropic, - /// openai responses); `None` for the data-only framing - /// openai_compatible/gemini use. - pub event: Option<&'a str>, - /// The `data:` payload, or a bare JSON line. The sentinel `[DONE]` is - /// passed through verbatim for the decoder to recognize. - pub data: &'a str, -} - -/// Stateless translator for one wire dialect. -pub(crate) trait Codec: Send + Sync { - /// Canonical request (`ctx.request`) → wire request. `stream` selects the - /// streaming shape (`stream: true` in the body, gemini's - /// `:streamGenerateContent` endpoint). Fallible: attachment/parameter - /// encoding can reject. - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result; - - /// Wire response body → canonical `Response` (content parts, finish - /// reason, usage). Each dialect's finish-reason map and usage arithmetic - /// live here. Stamps `ctx.provider_name` into `Response.provider` and the - /// transport-parsed `rate_limit` into the response. - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result; - - /// A fresh stateful decoder for one streaming response. `rate_limit` is the - /// transport-parsed header value to embed in the synthesized `Finish`. - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box; - - /// The third route, if the dialect has one (`/messages/count_tokens`, - /// `/responses/input_tokens`, `:countTokens`). `None` = the dialect has no - /// such route. Whether a given *deployment* may use it is a separate - /// route-level gate (Kimi-over-anthropic) decided before this is called. - fn encode_count_tokens(&self, _ctx: &CodecCtx<'_>) -> Option> { - None - } - - /// Parse the token count out of a count-tokens response. Only called when - /// [`Codec::encode_count_tokens`] returned `Some`; the default guards the - /// invariant for codecs without a count route. - fn decode_count_tokens(&self, _body: &str) -> Result { - Err(Error::Configuration { - message: "codec has no count_tokens route".to_string(), - source: None, - }) - } - - /// Map a non-2xx response to an `Error`. `retry_after` is the - /// transport-parsed `retry-after` header value in seconds (header parsing - /// is the transport's job, like `rate_limit` on the decode methods). - /// Default = shared HTTP-status mapping, which openai_compatible and - /// anthropic use as-is; a codec overrides when its dialect's error bodies - /// need more (e.g. gemini's gRPC status). - fn decode_error( - &self, - status: u16, - body: &str, - ctx: &CodecCtx<'_>, - retry_after: Option, - ) -> Error { - let (message, code, raw) = parse_error_body(body, "type"); - error_from_status_code( - status, - message, - ctx.provider_name.to_string(), - code, - raw, - retry_after, - ) - } -} - -/// Stateful per-stream decoder, driven by the shared transport loop. -/// `'static` because it is boxed into the stream's unfold state. -pub(crate) trait StreamDecoder: Send + 'static { - /// One framed event → zero or more canonical `StreamEvent`s. Returns - /// `Err` for dialect error events (anthropic `error`, openai - /// `response.failed`), which the transport yields as a stream error. - /// - /// Decoders must **not** emit [`StreamEvent::StreamStart`]. The driving - /// loop emits exactly one, immediately before handing over the first - /// framed event, so `StreamStart` means the same thing for every - /// provider: the provider is responding, whatever it turns out to say. - /// Leaving it to decoders made it depend on each dialect's opening frame - /// — anthropic and bedrock keyed it on `message_start`/`messageStart`, - /// and `openai_compatible` had no such frame and so emitted it never. - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error>; - - /// Byte-stream-end hook. Semantics are per-decoder, not shared: - /// anthropic — return nothing (`message_stop` already finished it); - /// openai_compatible — synthesize `Finish` iff content started (minimax); - /// gemini — synthesize `Finish` unconditionally if not yet finished. - fn finish(&mut self) -> Vec; -} - -// --- Dialect-neutral translation helpers -// --------------------------------------- - -/// Parse an error response body, extracting the message and error code. -/// -/// `error_code_field` is the JSON field name for the error code (e.g. "type" or -/// "status"). -#[must_use] -pub(crate) fn parse_error_body( - body: &str, - error_code_field: &str, -) -> (String, Option, Option) { - serde_json::from_str::(body).map_or_else( - |_| (body.to_string(), None, None), - |v| { - let message = v - .get("error") - .and_then(|e| e.get("message")) - .and_then(serde_json::Value::as_str) - // Codex endpoint returns {"detail": "..."} instead of {"error": {"message": "..."}} - .or_else(|| v.get("detail").and_then(serde_json::Value::as_str)) - .unwrap_or("Unknown error") - .to_string(); - let error_code = v - .get("error") - .and_then(|e| e.get(error_code_field)) - .and_then(serde_json::Value::as_str) - .map(String::from); - (message, error_code, Some(v)) - }, - ) -} - -/// Extract system and developer messages from a message list. -/// -/// Returns the joined system prompt and the remaining messages. -/// Per spec, Developer role messages are merged with system messages -/// for Anthropic and Gemini. -#[must_use] -pub(crate) fn extract_system_prompt(messages: &[Message]) -> (Option, Vec<&Message>) { - let mut system_parts = Vec::new(); - let mut other = Vec::new(); - for msg in messages { - if msg.role == Role::System || msg.role == Role::Developer { - let text = msg.text(); - if !text.trim().is_empty() { - system_parts.push(text); - } - } else { - other.push(msg); - } - } - let system = if system_parts.is_empty() { - None - } else { - Some(system_parts.join("\n")) - }; - (system, other) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::ContentPart; - - // --- parse_error_body --- - - #[test] - fn parse_error_body_valid_json() { - let body = r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "rate limited"); - assert_eq!(code.as_deref(), Some("rate_limit_error")); - assert!(raw.is_some()); - } - - #[test] - fn parse_error_body_missing_error_field() { - let body = r#"{"status":"fail"}"#; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "Unknown error"); - assert_eq!(code, None); - assert!(raw.is_some()); - } - - #[test] - fn parse_error_body_not_json() { - let body = "Internal Server Error"; - let (msg, code, raw) = parse_error_body(body, "type"); - assert_eq!(msg, "Internal Server Error"); - assert_eq!(code, None); - assert!(raw.is_none()); - } - - #[test] - fn parse_error_body_different_code_field() { - let body = r#"{"error":{"message":"bad","status":"INVALID_ARGUMENT"}}"#; - let (msg, code, _) = parse_error_body(body, "status"); - assert_eq!(msg, "bad"); - assert_eq!(code.as_deref(), Some("INVALID_ARGUMENT")); - } - - #[test] - fn parse_error_body_no_message() { - let body = r#"{"error":{"type":"server_error"}}"#; - let (msg, code, _) = parse_error_body(body, "type"); - assert_eq!(msg, "Unknown error"); - assert_eq!(code.as_deref(), Some("server_error")); - } - - // --- extract_system_prompt --- - - #[test] - fn extract_system_prompt_no_system() { - let msgs = vec![Message::user("hello")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_system_only() { - let msgs = vec![Message::system("Be helpful"), Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("Be helpful")); - assert_eq!(other.len(), 1); - assert_eq!(other[0].role, Role::User); - } - - #[test] - fn extract_system_prompt_multiple_system() { - let msgs = vec![ - Message::system("Rule 1"), - Message::system("Rule 2"), - Message::user("hi"), - ]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("Rule 1\nRule 2")); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_developer_role() { - let dev = Message { - role: Role::Developer, - content: vec![ContentPart::text("dev instructions")], - name: None, - tool_call_id: None, - }; - let msgs = vec![dev, Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys.as_deref(), Some("dev instructions")); - assert_eq!(other.len(), 1); - } - - #[test] - fn extract_system_prompt_ignores_whitespace_system_and_developer() { - let dev = Message { - role: Role::Developer, - content: vec![ContentPart::text(" \n\t ")], - name: None, - tool_call_id: None, - }; - let msgs = vec![Message::system(" "), dev, Message::user("hi")]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert_eq!(other.len(), 1); - assert_eq!(other[0].role, Role::User); - } - - #[test] - fn extract_system_prompt_empty() { - let msgs: Vec = vec![]; - let (sys, other) = extract_system_prompt(&msgs); - assert_eq!(sys, None); - assert!(other.is_empty()); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs b/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs deleted file mode 100644 index 6bcae4492..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! The OpenAI Chat Completions (`/chat/completions`) codec. -//! -//! Serves every "OpenAI-compatible" route (moonshot, zai, minimax, venice, -//! inception, ollama, litellm, …). Pure translation: no HTTP, auth, or base -//! URL — the adapter shell owns those. Count-tokens and error mapping use the -//! `Codec` trait defaults (this dialect has no count route and uses the shared -//! HTTP-status error mapping). - -mod request; -mod response; -mod stream; -mod translate; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the OpenAI Chat Completions wire dialect. -pub(crate) struct OpenAiCompatible; - -impl Codec for OpenAiCompatible { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - request::encode(ctx, stream) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - response::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::StreamState::new(ctx, rate_limit)) - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs b/lib/components/fabro-llm/src/codec/openai_compatible/request.rs deleted file mode 100644 index 8db03603f..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/request.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! Request encoding: canonical `Request` → Chat Completions body. - -use super::translate; -use super::wire::{ApiRequest, ChatMessage, StreamOptions}; -use crate::codec::{CodecCtx, EncodedRequest, cache, merge_named_provider_options}; -use crate::error::Error; - -/// Known `provider_options.` keys the codec consumes itself; -/// not re-merged into the body. -const KNOWN_OPTION_KEYS: &[&str] = &["auto_cache"]; - -/// Build the Chat Completions request for `ctx.request`. `stream` toggles the -/// `stream` body field and the `stream_options.include_usage` opt-in that makes -/// providers emit the trailing usage chunk. The body is assembled as a -/// `serde_json::Value` so `provider_options.` fields can be -/// merged in before sending. -/// -/// Returns an error when the request contains a custom tool definition, which -/// the Chat Completions tool envelope cannot represent. -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result { - let request = ctx.request; - let mut chat_messages = translate::translate_messages(&request.messages); - if explicit_cache_breakpoints(ctx) { - apply_cache_breakpoints(&mut chat_messages); - } - let tools = request - .tools - .as_ref() - .map(|t| translate::translate_tools(t)) - .transpose()?; - let tool_choice = request - .tool_choice - .as_ref() - .map(translate::translate_tool_choice); - let response_format = request - .response_format - .as_ref() - .map(translate::translate_response_format); - let (temperature, top_p) = if ctx - .model - .is_none_or(fabro_model::Model::supports_sampling_params) - { - (request.temperature, request.top_p) - } else { - (None, None) - }; - - let api_request = ApiRequest { - model: ctx.deployment_id.to_string(), - messages: chat_messages, - temperature, - max_tokens: request.max_tokens, - top_p, - reasoning_effort: request.reasoning_effort, - stop: request.stop_sequences.clone(), - tools, - tool_choice, - response_format, - stream: stream.then_some(true), - stream_options: stream.then_some(StreamOptions { - include_usage: true, - }), - }; - - let mut body = serde_json::to_value(&api_request).unwrap_or_default(); - merge_provider_options( - &mut body, - request.provider_options.as_ref(), - ctx.provider_name, - ); - - Ok(EncodedRequest { - body, - endpoint: "/chat/completions".to_string(), - headers: Vec::new(), - }) -} - -/// Whether this request opts into Anthropic-style explicit cache breakpoints: -/// the catalog row declares the mechanism and the request hasn't disabled -/// `auto_cache` under this provider's options namespace. -fn explicit_cache_breakpoints(ctx: &CodecCtx<'_>) -> bool { - ctx.model - .is_some_and(|m| m.features.prompt_cache && m.features.cache_control_breakpoints) - && cache::auto_cache_enabled(ctx.request.provider_options.as_ref(), ctx.provider_name) -} - -/// Mark the cacheable prefix: the last system message (upstream, tools and -/// system precede the conversation, so this breakpoint covers them too) and -/// the second-to-last user turn. Tool results count as user turns — they ride -/// in user messages on the upstream Anthropic wire. -fn apply_cache_breakpoints(messages: &mut [ChatMessage]) { - if let Some(system) = messages.iter_mut().rev().find(|m| m.role == "system") { - if let Some(content) = system.content.as_mut() { - content.mark_cache_breakpoint(); - } - } - - let user_turns: Vec = messages - .iter() - .map(|m| m.role == "user" || m.role == "tool") - .collect(); - if let Some(idx) = cache::conversation_breakpoint_index(&user_turns) { - if let Some(content) = messages[idx].content.as_mut() { - content.mark_cache_breakpoint(); - } - } -} - -/// Merge `provider_options.` fields into the serialized API -/// request body. -/// -/// The provider name is configurable (e.g. "groq", "together", "moonshot"), -/// allowing each instance to have its own namespace in `provider_options`. -pub(super) fn merge_provider_options( - body: &mut serde_json::Value, - provider_options: Option<&serde_json::Value>, - provider_name: &str, -) { - merge_named_provider_options(body, provider_options, provider_name, KNOWN_OPTION_KEYS); -} - -#[cfg(test)] -mod tests { - use fabro_model::{Catalog, ProviderId}; - - use super::super::wire::ApiRequest; - use super::*; - use crate::codec::CodecParams; - use crate::types::{Message, ReasoningEffort, Request, ToolDefinition}; - - fn minimal_request() -> Request { - Request { - model: "llama-3.1-70b".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Encode `request` through the codec with `deployment_id == request.model` - /// (the no-catalog case) and return the body. - fn encode_body(request: &Request, provider_name: &str, stream: bool) -> serde_json::Value { - let params = CodecParams::default(); - let deployment_id = request.model.clone(); - let ctx = CodecCtx { - request, - provider_name, - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - encode(&ctx, stream).unwrap().body - } - - #[test] - fn api_request_stream_field_serialization() { - let req = ApiRequest { - model: "test".into(), - messages: vec![], - temperature: None, - max_tokens: None, - top_p: None, - reasoning_effort: None, - stop: None, - tools: None, - tool_choice: None, - response_format: None, - stream: Some(true), - stream_options: None, - }; - let json = serde_json::to_value(&req).unwrap(); - assert_eq!(json["stream"], true); - - let req_no_stream = ApiRequest { - model: "test".into(), - messages: vec![], - temperature: None, - max_tokens: None, - top_p: None, - reasoning_effort: None, - stop: None, - tools: None, - tool_choice: None, - response_format: None, - stream: None, - stream_options: None, - }; - let json_no_stream = serde_json::to_value(&req_no_stream).unwrap(); - assert!(json_no_stream.get("stream").is_none()); - } - - #[test] - fn encode_uses_deployment_id_as_model() { - let request = minimal_request(); - let params = CodecParams::default(); - let deployment_id = "acme/model-large".to_string(); - let ctx = CodecCtx { - request: &request, - provider_name: "acme", - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - let body = encode(&ctx, false).unwrap().body; - assert_eq!(body["model"], "acme/model-large"); - } - - #[test] - fn encode_serializes_reasoning_effort_at_top_level() { - let mut request = minimal_request(); - request.reasoning_effort = Some(ReasoningEffort::High); - - let body = encode_body(&request, "moonshot", false); - - assert_eq!(body["reasoning_effort"], "high"); - } - - #[test] - fn encode_omits_sampling_params_for_models_that_reject_them() { - let model = Catalog::builtin() - .get_on_provider(&ProviderId::new("moonshot"), "kimi-k3") - .unwrap(); - let mut request = minimal_request(); - request.model = model.id.to_string(); - request.temperature = Some(0.7); - request.top_p = Some(0.9); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "moonshot", - deployment_id: model.id.as_str(), - model: Some(model), - params: ¶ms, - }; - - let body = encode(&ctx, false).unwrap().body; - - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - } - - #[test] - fn encode_rejects_custom_tool_definitions() { - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::custom( - "apply_patch", - "Apply a patch", - serde_json::json!({"type": "grammar"}), - )]); - let params = CodecParams::default(); - let deployment_id = request.model.clone(); - let ctx = CodecCtx { - request: &request, - provider_name: "moonshot", - deployment_id: &deployment_id, - model: None, - params: ¶ms, - }; - - let Err(error) = encode(&ctx, false) else { - panic!("custom tool definition should be rejected"); - }; - assert!(matches!( - error, - Error::Configuration { message, source: None } - if message.contains("custom tool definition 'apply_patch'") - )); - } - - #[test] - fn provider_options_none_produces_standard_body() { - let request = minimal_request(); - let body = encode_body(&request, "groq", false); - assert_eq!(body["model"], "llama-3.1-70b"); - assert!(body.get("stream").is_none()); - } - - #[test] - fn provider_options_matching_name_merged() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "groq": { - "frequency_penalty": 0.5, - "presence_penalty": 0.3 - } - })); - let body = encode_body(&request, "groq", false); - assert_eq!(body["frequency_penalty"], 0.5); - assert_eq!(body["presence_penalty"], 0.3); - } - - #[test] - fn provider_options_different_name_ignored() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "together": { - "repetition_penalty": 1.2 - } - })); - let body = encode_body(&request, "groq", false); - assert!(body.get("repetition_penalty").is_none()); - } - - #[test] - fn provider_options_uses_adapter_name() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "together": { - "repetition_penalty": 1.2 - } - })); - let body = encode_body(&request, "together", false); - assert_eq!(body["repetition_penalty"], 1.2); - } - - #[test] - fn provider_options_preserves_standard_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.7); - request.max_tokens = Some(200); - request.provider_options = Some(serde_json::json!({ - "groq": { - "frequency_penalty": 0.5 - } - })); - let body = encode_body(&request, "groq", true); - assert_eq!(body["temperature"], 0.7); - assert_eq!(body["max_tokens"], 200); - assert_eq!(body["stream"], true); - assert_eq!(body["frequency_penalty"], 0.5); - } - - #[test] - fn provider_options_can_override_model() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "groq": { - "model": "custom-model" - } - })); - let body = encode_body(&request, "groq", false); - assert_eq!(body["model"], "custom-model"); - } - - #[test] - fn merge_provider_options_with_non_object_value() { - let mut body = serde_json::json!({"model": "test"}); - let opts = serde_json::json!({"groq": "not-an-object"}); - merge_provider_options(&mut body, Some(&opts), "groq"); - assert_eq!(body["model"], "test"); - } - - #[test] - fn merge_provider_options_consumes_auto_cache_control_key() { - let mut body = serde_json::json!({"model": "test"}); - let opts = serde_json::json!({"groq": {"auto_cache": false, "top_k": 5}}); - merge_provider_options(&mut body, Some(&opts), "groq"); - assert!(body.get("auto_cache").is_none()); - assert_eq!(body["top_k"], 5); - } - - // --- apply_cache_breakpoints --------------------------------------------- - - fn chat_message(role: &str, text: &str) -> ChatMessage { - ChatMessage { - role: role.to_string(), - content: Some(super::super::wire::ChatContent::Text(text.to_string())), - reasoning_content: None, - tool_call_id: None, - tool_calls: None, - } - } - - fn marked(message: &ChatMessage) -> bool { - let json = serde_json::to_value(message).unwrap(); - json["content"].is_array() && json["content"][0]["cache_control"]["type"] == "ephemeral" - } - - #[test] - fn cache_breakpoints_on_first_turn_mark_only_the_system_prompt() { - let mut messages = vec![chat_message("system", "sys"), chat_message("user", "task")]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - assert!(!marked(&messages[1])); - } - - #[test] - fn cache_breakpoints_count_tool_results_as_user_turns() { - let mut messages = vec![ - chat_message("system", "sys"), - chat_message("user", "task"), - chat_message("assistant", "calling a tool"), - chat_message("tool", "tool output"), - chat_message("assistant", "one more"), - chat_message("tool", "more output"), - ]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - // Second-to-last user turn: the first tool result, not the user task. - assert!(!marked(&messages[1])); - assert!(marked(&messages[3])); - assert!(!marked(&messages[5])); - } - - #[test] - fn cache_breakpoints_without_system_mark_only_the_conversation() { - let mut messages = vec![ - chat_message("user", "task"), - chat_message("assistant", "answer"), - chat_message("user", "follow-up"), - ]; - apply_cache_breakpoints(&mut messages); - assert!(marked(&messages[0])); - assert!(!marked(&messages[2])); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/response.rs b/lib/components/fabro-llm/src/codec/openai_compatible/response.rs deleted file mode 100644 index dc2779bf1..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/response.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Response decoding: Chat Completions body → canonical `Response`. - -use super::translate::{self, map_finish_reason}; -use super::wire::{ApiResponse, ApiUsage, ReasoningDetails}; -use crate::codec::CodecCtx; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts, ToolCall, -}; - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let mut api_resp: ApiResponse = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse response: {e}"), e))?; - - let choice = api_resp - .choices - .first_mut() - .ok_or_else(|| Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new( - "no choices in response", - ctx.provider_name, - )), - })?; - - let mut content_parts = Vec::new(); - if let Some(payload) = choice.message.reasoning_details.take() { - content_parts.extend(ReasoningDetails::from_complete_payload(payload).into_content_part()); - } - if let Some(reasoning) = choice.message.reasoning() { - if !reasoning.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: reasoning.to_string(), - signature: None, - redacted: false, - })); - } - } - if let Some(text) = &choice.message.content { - if !text.is_empty() { - content_parts.push(ContentPart::text(text)); - } - } - if let Some(tool_calls) = &choice.message.tool_calls { - let custom_tool_names = translate::custom_tool_names(ctx.request); - for tc in tool_calls { - let arguments = translate::parse_tool_arguments( - &tc.function.name, - &tc.function.arguments, - &custom_tool_names, - ); - let mut tool_call = ToolCall::new(&tc.id, &tc.function.name, arguments); - tool_call.raw_arguments = Some(tc.function.arguments.clone()); - content_parts.push(ContentPart::ToolCall(tool_call)); - } - } - - let finish_reason = map_finish_reason(choice.finish_reason.as_deref()); - - let wire_usage = api_resp.usage.as_ref(); - let usage = wire_usage.map_or_else(TokenCounts::default, ApiUsage::token_counts); - let cost_usd = wire_usage - .and_then(|usage| usage.cost) - .or_else(|| api_resp.cost.as_ref().and_then(|cost| cost.usd)); - let cost_source = translate::authoritative_cost_source(cost_usd); - - Ok(Response { - id: api_resp.id, - model: api_resp.model, - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: serde_json::from_str(body).ok(), - warnings: vec![], - rate_limit, - cost_usd, - cost_source, - }) -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs b/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs deleted file mode 100644 index 3207c2744..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/stream.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! Streaming decoder: Chat Completions SSE chunks → canonical `StreamEvent`s. -//! -//! Byte reading and `data:` framing live in the transport; this decoder is fed -//! already-stripped payloads (including the `[DONE]` sentinel) via `on_event`. - -use super::translate::{map_finish_reason, parse_tool_arguments}; -use super::wire::{AccumulatedToolCall, ReasoningDetails, StreamChunk}; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, - TokenCounts, ToolCall, -}; - -/// Accumulated state while decoding the Chat Completions SSE stream. -pub(super) struct StreamState { - provider_name: String, - model: String, - response_id: String, - response_model: String, - accumulated_text: String, - accumulated_reasoning: String, - reasoning_details: ReasoningDetails, - tool_calls: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - text_started: bool, - custom_tool_names: Vec, - /// True after `finish_events()` has run (guards against duplicates). - finished: bool, - rate_limit: Option, - /// In-band USD cost from the response, surfaced as authoritative on the - /// final response. - cost_usd: Option, -} - -impl StreamState { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - provider_name: ctx.provider_name.to_string(), - model: ctx.request.model.clone(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - accumulated_reasoning: String::new(), - reasoning_details: ReasoningDetails::default(), - tool_calls: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - text_started: false, - custom_tool_names: super::translate::custom_tool_names(ctx.request), - finished: false, - rate_limit, - cost_usd: None, - } - } - - /// Process a parsed SSE chunk and return events to emit, if any. - fn process_chunk(&mut self, mut chunk: StreamChunk) -> Result>, Error> { - // Capture response metadata from the first chunk. - if let Some(id) = &chunk.id { - if self.response_id.is_empty() { - self.response_id.clone_from(id); - } - } - if let Some(model) = &chunk.model { - if self.response_model.is_empty() { - self.response_model.clone_from(model); - } - } - - // Capture usage if present (often in a dedicated chunk). - if let Some(usage) = &chunk.usage { - self.usage = usage.token_counts(); - } - let cost_usd = chunk - .usage - .as_ref() - .and_then(|usage| usage.cost) - .or_else(|| chunk.cost.as_ref().and_then(|cost| cost.usd)); - self.cost_usd = cost_usd.or(self.cost_usd); - - let Some(choices) = chunk.choices.as_mut() else { - return Ok(None); - }; - let Some(choice) = choices.first_mut() else { - return Ok(None); - }; - - let mut events = Vec::new(); - - // Check for finish_reason. - if let Some(reason) = &choice.finish_reason { - self.finish_reason = map_finish_reason(Some(reason.as_str())); - } - - let Some(delta) = choice.delta.as_mut() else { - return Ok(None); - }; - - // Accumulate reasoning/thinking content (Kimi, etc.). - if let Some(reasoning) = delta.reasoning() { - if !reasoning.is_empty() { - self.accumulated_reasoning.push_str(reasoning); - } - } - - // Accumulate structured reasoning detail fragments in wire order. - if let Some(payload) = delta.reasoning_details.take() { - self.reasoning_details.push_stream_payload(payload); - } - - // Handle text content delta. - if let Some(content) = &delta.content { - if !content.is_empty() { - if !self.text_started { - self.text_started = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - self.accumulated_text.push_str(content); - events.push(StreamEvent::text_delta(content, None)); - } - } - - // Handle tool call deltas. - if let Some(tool_calls) = &delta.tool_calls { - for tc in tool_calls { - let index = tc.index; - - // A delta may only continue an already-started tool call or - // open the next slot. Padding a skipped slot would materialize - // a phantom tool call with an empty id and name, which poisons - // the conversation once echoed back to the provider. - if index > self.tool_calls.len() { - return Err(Error::Stream { - message: format!( - "malformed tool call stream from {}: delta for tool_calls[{index}] \ - arrived before tool_calls[{}] was started", - self.provider_name, - self.tool_calls.len() - ), - source: None, - }); - } - if index == self.tool_calls.len() { - self.tool_calls.push(AccumulatedToolCall { - id: String::new(), - name: String::new(), - arguments: String::new(), - started: false, - }); - } - - let accumulated = &mut self.tool_calls[index]; - - // First chunk for this tool call carries id and name. - if let Some(id) = &tc.id { - accumulated.id.clone_from(id); - } - if let Some(func) = &tc.function { - if let Some(name) = &func.name { - accumulated.name.clone_from(name); - } - if let Some(args) = &func.arguments { - accumulated.arguments.push_str(args); - } - } - - let partial_tool_call = - ToolCall::new(&accumulated.id, &accumulated.name, serde_json::json!(null)); - - if accumulated.started { - events.push(StreamEvent::ToolCallDelta { - tool_call: partial_tool_call, - }); - } else { - accumulated.started = true; - events.push(StreamEvent::ToolCallStart { - tool_call: partial_tool_call, - }); - } - } - } - - if events.is_empty() { - Ok(None) - } else { - Ok(Some(events)) - } - } - - /// Generate the final events when `[DONE]` (or end-of-stream) is received. - fn finish_events(&mut self) -> Vec { - self.finished = true; - let mut events = Vec::new(); - - // End text segment if it was started. - if self.text_started { - events.push(StreamEvent::TextEnd { text_id: None }); - } - - let mut content_parts = Vec::new(); - - // Preserve the structured reasoning channel verbatim. - content_parts.extend(std::mem::take(&mut self.reasoning_details).into_content_part()); - - // Include reasoning/thinking content if present (Kimi, etc.). - if !self.accumulated_reasoning.is_empty() { - content_parts.push(ContentPart::Thinking(ThinkingData { - text: std::mem::take(&mut self.accumulated_reasoning), - signature: None, - redacted: false, - })); - } - - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(&self.accumulated_text)); - } - - for accumulated in &self.tool_calls { - let arguments = parse_tool_arguments( - &accumulated.name, - &accumulated.arguments, - &self.custom_tool_names, - ); - let mut tool_call = ToolCall::new(&accumulated.id, &accumulated.name, arguments); - tool_call.raw_arguments = Some(accumulated.arguments.clone()); - - events.push(StreamEvent::ToolCallEnd { - tool_call: tool_call.clone(), - }); - content_parts.push(ContentPart::ToolCall(tool_call)); - } - - // Infer finish reason from tool calls if not explicitly set. - if !self.tool_calls.is_empty() && self.finish_reason == FinishReason::Stop { - self.finish_reason = FinishReason::ToolCalls; - } - - let response_model = if self.response_model.is_empty() { - self.model.clone() - } else { - self.response_model.clone() - }; - - let response = Response { - id: self.response_id.clone(), - model: response_model, - provider: self.provider_name.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: self.cost_usd, - cost_source: super::translate::authoritative_cost_source(self.cost_usd), - }; - - events.push(StreamEvent::finish( - self.finish_reason.clone(), - self.usage.clone(), - response, - )); - - events - } -} - -impl StreamDecoder for StreamState { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - // Chat Completions uses data-only framing; the `event:` field is unused. - if ev.data == "[DONE]" { - return Ok(self.finish_events()); - } - - let chunk: StreamChunk = serde_json::from_str(ev.data) - .map_err(|e| Error::stream_error(format!("failed to parse SSE chunk: {e}"), e))?; - - Ok(self.process_chunk(chunk)?.unwrap_or_default()) - } - - fn finish(&mut self) -> Vec { - // Stream ended without `[DONE]`. Some providers (e.g. Minimax) omit the - // sentinel; emit accumulated finish events if we have content and - // haven't already finished. - if !self.finished && (self.text_started || !self.tool_calls.is_empty()) { - self.finish_events() - } else { - Vec::new() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codec::CodecParams; - use crate::types::Request; - - /// Build a decoder through `StreamState::new` (with a minimal request) for - /// unit tests that drive `process_chunk` / `finish_events`. - fn test_state(provider: &str, model: &str) -> StreamState { - let request = Request { - model: model.to_string(), - messages: Vec::new(), - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: provider, - deployment_id: model, - model: None, - params: ¶ms, - }; - StreamState::new(&ctx, None) - } - - #[test] - fn stream_chunk_minimax_format() { - let json = r#"{"id":"abc","choices":[{"index":0,"delta":{"content":"hello","role":"assistant","name":"MiniMax AI","audio_content":""}}],"created":1772268546,"model":"MiniMax-M2.5","object":"chat.completion.chunk","usage":null,"input_sensitive":false,"output_sensitive":false}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - let delta = choices[0].delta.as_ref().unwrap(); - assert_eq!(delta.content.as_deref(), Some("hello")); - } - - #[test] - fn stream_chunk_text_delta_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - assert_eq!(chunk.id.as_deref(), Some("chatcmpl-1")); - assert_eq!(chunk.model.as_deref(), Some("gpt-4")); - let choices = chunk.choices.unwrap(); - assert_eq!(choices.len(), 1); - let delta = choices[0].delta.as_ref().unwrap(); - assert_eq!(delta.content.as_deref(), Some("Hello")); - assert!(choices[0].finish_reason.is_none()); - } - - #[test] - fn stream_chunk_tool_call_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"ci"}}]},"finish_reason":null}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - let delta = choices[0].delta.as_ref().unwrap(); - let tc = &delta.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tc.index, 0); - assert_eq!(tc.id.as_deref(), Some("call_1")); - let func = tc.function.as_ref().unwrap(); - assert_eq!(func.name.as_deref(), Some("get_weather")); - assert_eq!(func.arguments.as_deref(), Some("{\"ci")); - } - - #[test] - fn stream_chunk_usage_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let usage = chunk.usage.unwrap(); - assert_eq!(usage.prompt_tokens, 10); - assert_eq!(usage.completion_tokens, 20); - } - - #[test] - fn stream_chunk_finish_reason_parsing() { - let json = r#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"delta":{},"finish_reason":"stop"}]}"#; - let chunk: StreamChunk = serde_json::from_str(json).unwrap(); - let choices = chunk.choices.unwrap(); - assert_eq!(choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[test] - fn process_text_chunks() { - let mut state = test_state("test", "model"); - - let chunk1: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ).unwrap(); - let events1 = state.process_chunk(chunk1).unwrap().unwrap(); - assert_eq!(events1.len(), 2); - assert!(matches!(events1[0], StreamEvent::TextStart { .. })); - assert!(matches!(events1[1], StreamEvent::TextDelta { .. })); - - let chunk2: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"content":" world"},"finish_reason":null}]}"#, - ).unwrap(); - let events2 = state.process_chunk(chunk2).unwrap().unwrap(); - assert_eq!(events2.len(), 1); - assert!(matches!(events2[0], StreamEvent::TextDelta { .. })); - - assert_eq!(state.accumulated_text, "Hello world"); - } - - #[test] - fn process_tool_call_chunks() { - let mut state = test_state("test", "model"); - - let chunk1: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"fn1","arguments":"{\"k"}}]},"finish_reason":null}]}"#, - ).unwrap(); - let events1 = state.process_chunk(chunk1).unwrap().unwrap(); - assert_eq!(events1.len(), 1); - assert!(matches!(events1[0], StreamEvent::ToolCallStart { .. })); - - let chunk2: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"m1","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ey\"}"}}]},"finish_reason":null}]}"#, - ).unwrap(); - let events2 = state.process_chunk(chunk2).unwrap().unwrap(); - assert_eq!(events2.len(), 1); - assert!(matches!(events2[0], StreamEvent::ToolCallDelta { .. })); - - assert_eq!(state.tool_calls[0].arguments, r#"{"key"}"#); - } - - #[test] - fn finish_events_text_only() { - let mut state = test_state("test-provider", "test-model"); - state.response_id = "resp-1".into(); - state.response_model = "gpt-4".into(); - state.accumulated_text = "Hello world".into(); - state.text_started = true; - state.usage = TokenCounts { - input_tokens: 5, - output_tokens: 10, - ..TokenCounts::default() - }; - - let events = state.finish_events(); - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::TextEnd { .. })); - match &events[1] { - StreamEvent::Finish { - finish_reason, - usage, - response, - } => { - assert_eq!(*finish_reason, FinishReason::Stop); - assert_eq!(usage.input_tokens, 5); - assert_eq!(usage.output_tokens, 10); - assert_eq!(response.text(), "Hello world"); - assert_eq!(response.id, "resp-1"); - assert_eq!(response.model, "gpt-4"); - assert_eq!(response.provider, "test-provider"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } - - #[test] - fn finish_events_with_tool_calls() { - let mut state = test_state("test", "model"); - state.response_id = "resp-1".into(); - state.tool_calls.push(AccumulatedToolCall { - id: "call_1".into(), - name: "get_weather".into(), - arguments: r#"{"city":"SF"}"#.into(), - started: true, - }); - - let events = state.finish_events(); - assert_eq!(events.len(), 2); - match &events[0] { - StreamEvent::ToolCallEnd { tool_call } => { - assert_eq!(tool_call.id, "call_1"); - assert_eq!(tool_call.name, "get_weather"); - assert_eq!(tool_call.raw_arguments.as_deref(), Some(r#"{"city":"SF"}"#)); - } - other => panic!("Expected ToolCallEnd, got {other:?}"), - } - match &events[1] { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(*finish_reason, FinishReason::ToolCalls); - let calls = response.tool_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "get_weather"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } - - // Reproduces run 01M11JZVT7V507R56BCJJHZB1B: venice (proxying Anthropic) - // numbered tool_calls[].index by content block, so the first tool call - // arrived with index 1 when text preceded it. Padding the skipped slot - // used to materialize a phantom tool call with an empty id and name that - // the provider rejected once echoed back (tool_use.id must match - // '^[a-zA-Z0-9_-]+$'). A gap in the index sequence is indistinguishable - // from lost chunks, so the stream must fail instead. - #[test] - fn sparse_tool_call_index_is_a_stream_error() { - let mut state = test_state("venice", "claude-opus-5"); - - let text_chunk: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"content":"I'll start by reading the state file."},"finish_reason":null}]}"#, - ) - .unwrap(); - state.process_chunk(text_chunk).unwrap(); - - let tool_chunk: StreamChunk = serde_json::from_str( - r#"{"id":"c1","model":"claude-opus-5","choices":[{"delta":{"tool_calls":[{"index":1,"id":"toolu_01EgMidFVtGhitWE22jXQ9Eo","function":{"name":"Read","arguments":"{\"file_path\":\"state.json\"}"}}]},"finish_reason":null}]}"#, - ) - .unwrap(); - let err = state.process_chunk(tool_chunk).unwrap_err(); - - assert!(err.retryable(), "malformed stream should be retryable"); - let message = err.to_string(); - assert!( - message.contains("tool_calls[1]") && message.contains("venice"), - "unexpected error message: {message}" - ); - } - - #[test] - fn uses_request_model_as_fallback() { - let mut state = test_state("test", "fallback-model"); - let events = state.finish_events(); - match &events[0] { - StreamEvent::Finish { response, .. } => { - assert_eq!(response.model, "fallback-model"); - } - other => panic!("Expected Finish, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs b/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs deleted file mode 100644 index b32fac567..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/translate.rs +++ /dev/null @@ -1,486 +0,0 @@ -//! Pure mapping between canonical types and the Chat Completions wire shapes. - -use super::wire::{ChatContent, ChatFunction, ChatMessage, ChatToolCall}; -use crate::error::Error; -use crate::types::{ - ContentPart, CostSource, FinishReason, Message, Request, ResponseFormat, ResponseFormatType, - Role, ToolChoice, ToolDefinition, -}; - -/// In-band cost (OpenRouter) is authoritative billing data; the client's -/// catalog estimate never overwrites it. -pub(super) fn authoritative_cost_source(cost_usd: Option) -> Option { - cost_usd.is_some().then_some(CostSource::Authoritative) -} - -pub(super) fn map_finish_reason(reason: Option<&str>) -> FinishReason { - match reason { - Some("stop") | None => FinishReason::Stop, - Some("length") => FinishReason::Length, - Some("tool_calls") => FinishReason::ToolCalls, - Some("content_filter") => FinishReason::ContentFilter, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Build the content string from a message's parts, including fallback text -/// for unsupported content types (Audio, Document). -fn content_text_with_fallbacks(parts: &[ContentPart]) -> String { - let mut segments: Vec = Vec::new(); - for part in parts { - match part { - ContentPart::Text(text) => segments.push(text.clone()), - ContentPart::Audio(_) => { - segments.push("[Audio content not supported by this provider]".to_string()); - } - ContentPart::Document(doc) => { - let desc = doc.file_name.as_ref().map_or_else( - || "[Document content not supported by this provider]".to_string(), - |name| { - format!("[Document '{name}': content type not supported by this provider]") - }, - ); - segments.push(desc); - } - _ => {} - } - } - segments.join("") -} - -pub(super) fn translate_messages(messages: &[Message]) -> Vec { - messages - .iter() - .flat_map(|msg| { - // Tool messages must be split into one ChatMessage per ToolResult, - // each with its own tool_call_id. The Chat Completions API requires - // every tool_call_id from the assistant to have a matching tool message. - if msg.role == Role::Tool { - return msg - .content - .iter() - .filter_map(|part| { - if let ContentPart::ToolResult(tr) = part { - let output = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - Some(ChatMessage { - role: "tool".to_string(), - content: Some(ChatContent::Text(output)), - reasoning_content: None, - tool_call_id: Some(tr.tool_call_id.clone()), - tool_calls: None, - }) - } else { - None - } - }) - .collect::>(); - } - - let role = match msg.role { - Role::System | Role::Developer => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => unreachable!( - "Role::Tool is handled in the early-return branch above this match" - ), - }; - - let mut tool_calls: Vec = Vec::new(); - if msg.role == Role::Assistant { - for part in &msg.content { - if let ContentPart::ToolCall(tc) = part { - let arguments = tc - .raw_arguments - .clone() - .unwrap_or_else(|| tc.arguments.to_string()); - tool_calls.push(ChatToolCall { - id: tc.id.clone(), - kind: "function".to_string(), - function: ChatFunction { - name: tc.name.clone(), - arguments, - }, - }); - } - } - } - - let text = content_text_with_fallbacks(&msg.content); - let content = if text.is_empty() { - None - } else { - Some(ChatContent::Text(text)) - }; - let tool_calls = if tool_calls.is_empty() { - None - } else { - Some(tool_calls) - }; - - // Extract reasoning/thinking content for assistant messages. - let reasoning_content = if msg.role == Role::Assistant { - let reasoning: String = msg - .content - .iter() - .filter_map(|part| match part { - ContentPart::Thinking(t) if !t.redacted => Some(t.text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - if reasoning.is_empty() { - None - } else { - Some(reasoning) - } - } else { - None - }; - - vec![ChatMessage { - role: role.to_string(), - content, - reasoning_content, - tool_call_id: msg.tool_call_id.clone(), - tool_calls, - }] - }) - .collect() -} - -pub(super) fn translate_tools(tools: &[ToolDefinition]) -> Result, Error> { - tools - .iter() - .map(|t| { - if t.is_custom() { - return Err(Error::Configuration { - message: format!( - "openai_compatible codec does not support custom tool definition '{}'", - t.name - ), - source: None, - }); - } - - Ok(serde_json::json!({ - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.parameters, - } - })) - }) - .collect() -} - -pub(super) fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!("auto"), - ToolChoice::None => serde_json::json!("none"), - ToolChoice::Required => serde_json::json!("required"), - ToolChoice::Named { tool_name } => { - serde_json::json!({"type": "function", "function": {"name": tool_name}}) - } - } -} - -pub(super) fn custom_tool_names(request: &Request) -> Vec { - request - .tools - .as_deref() - .unwrap_or_default() - .iter() - .filter(|tool| tool.is_custom()) - .map(|tool| tool.name.clone()) - .collect() -} - -pub(super) fn parse_tool_arguments( - tool_name: &str, - raw_arguments: &str, - custom_tool_names: &[String], -) -> serde_json::Value { - match serde_json::from_str(raw_arguments) { - Ok(arguments) => arguments, - Err(_) if custom_tool_names.iter().any(|name| name == tool_name) => { - serde_json::Value::String(raw_arguments.to_string()) - } - Err(_) => serde_json::json!({}), - } -} - -/// Translate unified `ResponseFormat` to Chat Completions `response_format`. -pub(super) fn translate_response_format(format: &ResponseFormat) -> serde_json::Value { - match format.kind { - ResponseFormatType::Text => serde_json::json!({"type": "text"}), - ResponseFormatType::JsonObject => serde_json::json!({"type": "json_object"}), - ResponseFormatType::JsonSchema => { - let mut json_schema = serde_json::json!({ - "name": "response", - "strict": format.strict, - }); - if let Some(schema) = &format.json_schema { - json_schema["schema"] = schema.clone(); - } - serde_json::json!({ - "type": "json_schema", - "json_schema": json_schema, - }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::{ - AudioData, ContentPart, DocumentData, Message, Role, ThinkingData, ToolCall, - }; - - #[test] - fn translate_assistant_message_with_tool_calls_only() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!(translated.len(), 1); - assert_eq!(translated[0].role, "assistant"); - assert!(translated[0].content.is_none()); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].id, "call_1"); - assert_eq!(tool_calls[0].kind, "function"); - assert_eq!(tool_calls[0].function.name, "get_weather"); - assert_eq!(tool_calls[0].function.arguments, r#"{"city":"SF"}"#); - } - - #[test] - fn translate_assistant_message_with_text_and_tool_calls() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather"), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "get_weather", - serde_json::json!({"city": "NYC"}), - )), - ], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Let me check the weather") - ); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].function.name, "get_weather"); - } - - #[test] - fn translate_assistant_tool_call_replays_reasoning_content() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "I need the weather tool.".to_string(), - signature: None, - redacted: false, - }), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "get_weather", - serde_json::json!({"city": "NYC"}), - )), - ], - name: None, - tool_call_id: None, - }; - - let translated = translate_messages(&[msg]); - - assert_eq!( - translated[0].reasoning_content.as_deref(), - Some("I need the weather tool.") - ); - assert_eq!(translated[0].tool_calls.as_ref().unwrap().len(), 1); - let json = serde_json::to_value(&translated[0]).unwrap(); - assert_eq!(json["reasoning_content"], "I need the weather tool."); - } - - #[test] - fn translate_assistant_message_with_raw_arguments() { - let mut tc = ToolCall::new("call_3", "search", serde_json::json!({"q": "rust"})); - tc.raw_arguments = Some(r#"{"q": "rust"}"#.to_string()); - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - let tool_calls = translated[0].tool_calls.as_ref().unwrap(); - // Should prefer raw_arguments over serializing arguments - assert_eq!(tool_calls[0].function.arguments, r#"{"q": "rust"}"#); - } - - #[test] - fn translate_tool_message_has_tool_call_id() { - let msg = Message::tool_result( - "call_1", - serde_json::Value::String("72F and sunny".into()), - false, - ); - let translated = translate_messages(&[msg]); - assert_eq!(translated[0].role, "tool"); - assert_eq!(translated[0].tool_call_id.as_deref(), Some("call_1")); - assert!(translated[0].tool_calls.is_none()); - } - - #[test] - fn translate_user_message_has_no_tool_calls() { - let msg = Message::user("Hello"); - let translated = translate_messages(&[msg]); - assert_eq!(translated[0].role, "user"); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Hello") - ); - assert!(translated[0].tool_calls.is_none()); - } - - #[test] - fn assistant_tool_calls_serialize_correctly() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - let json = serde_json::to_value(&translated[0]).unwrap(); - assert!(json.get("content").is_none()); - assert!(json.get("tool_call_id").is_none()); - let tool_calls = json["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["type"], "function"); - assert_eq!(tool_calls[0]["id"], "call_1"); - assert_eq!(tool_calls[0]["function"]["name"], "get_weather"); - } - - #[test] - fn audio_content_produces_text_fallback() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Audio content not supported by this provider]") - ); - } - - #[test] - fn document_content_produces_text_fallback_with_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: Some("report.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Document 'report.pdf': content type not supported by this provider]") - ); - } - - #[test] - fn document_content_produces_text_fallback_without_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("[Document content not supported by this provider]") - ); - } - - #[test] - fn mixed_text_and_audio_content_concatenates() { - let msg = Message { - role: Role::User, - content: vec![ - ContentPart::text("Check this: "), - ContentPart::Audio(AudioData { - url: None, - data: Some(vec![1, 2]), - media_type: None, - }), - ], - name: None, - tool_call_id: None, - }; - let translated = translate_messages(&[msg]); - assert_eq!( - translated[0] - .content - .as_ref() - .and_then(ChatContent::as_text), - Some("Check this: [Audio content not supported by this provider]") - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs b/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs deleted file mode 100644 index 83823d5f7..000000000 --- a/lib/components/fabro-llm/src/codec/openai_compatible/wire.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! Serde types mirroring the OpenAI Chat Completions wire shapes. - -use crate::codec::cache::CacheControl; -use crate::codec::split_inclusive_token_total; -use crate::types::{ContentPart, ReasoningEffort, TokenCounts}; - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub response_format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stream_options: Option, -} - -/// Streaming options. Chat Completions only emits the trailing usage chunk -/// when the request opts in, so without this a streamed response reports zero -/// tokens and costs are estimated at $0. -#[derive(serde::Serialize)] -pub(super) struct StreamOptions { - pub include_usage: bool, -} - -#[derive(serde::Serialize)] -pub(super) struct ChatMessage { - pub role: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub content: Option, - /// Reasoning/thinking content echoed back for providers that require it - /// during tool-call continuations (including Kimi and DeepSeek). - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option>, -} - -/// Message content: plain text, or text parts when a part carries a -/// `cache_control` breakpoint (aggregators fronting Anthropic models forward -/// it upstream). Unmarked messages keep the plain-string form for maximum -/// compatibility with strict Chat Completions servers. -#[derive(serde::Serialize)] -#[serde(untagged)] -pub(super) enum ChatContent { - Text(String), - Parts(Vec), -} - -#[derive(serde::Serialize)] -pub(super) struct ChatTextPart { - #[serde(rename = "type")] - pub kind: String, - pub text: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_control: Option, -} - -impl ChatContent { - /// Plain-text view for assertions. - #[cfg(test)] - pub(super) fn as_text(&self) -> Option<&str> { - match self { - Self::Text(text) => Some(text.as_str()), - Self::Parts(_) => None, - } - } - - /// Mark this content as a prompt-cache breakpoint, converting to parts - /// form so the annotation has somewhere to live. - pub(super) fn mark_cache_breakpoint(&mut self) { - match self { - Self::Text(text) => { - *self = Self::Parts(vec![ChatTextPart { - kind: "text".to_string(), - text: std::mem::take(text), - cache_control: Some(CacheControl::ephemeral()), - }]); - } - Self::Parts(parts) => { - if let Some(last) = parts.last_mut() { - last.cache_control = Some(CacheControl::ephemeral()); - } - } - } - } -} - -#[derive(serde::Serialize)] -pub(super) struct ChatToolCall { - pub id: String, - #[serde(rename = "type")] - pub kind: String, - pub function: ChatFunction, -} - -#[derive(serde::Serialize)] -pub(super) struct ChatFunction { - pub name: String, - pub arguments: String, -} - -// --- Response types (non-streaming) --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: String, - pub choices: Vec, - pub usage: Option, - pub cost: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiCost { - pub usd: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiChoice { - pub message: ApiChoiceMessage, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiChoiceMessage { - pub content: Option, - pub reasoning_content: Option, - /// OpenRouter's normalized spelling for reasoning text. - pub reasoning: Option, - /// Structured reasoning channel (OpenRouter and compatible - /// aggregators). Kept as an untyped value so unknown detail variants - /// cannot fail an otherwise valid completion. - #[serde(default)] - pub reasoning_details: Option, - pub tool_calls: Option>, -} - -impl ApiChoiceMessage { - pub(super) fn reasoning(&self) -> Option<&str> { - self.reasoning_content - .as_deref() - .or(self.reasoning.as_deref()) - } -} - -/// Structured `reasoning_details` entries accumulated in wire order. -/// -/// The entries are preserved verbatim as an opaque content part so -/// encrypted material survives for future provider-aware replay; only known -/// readable members are ever normalized out of them. -#[derive(Default)] -pub(super) struct ReasoningDetails { - entries: Vec, -} - -impl ReasoningDetails { - /// Preserve a complete-response `reasoning_details` payload. - /// - /// Providers document an array of detail objects; a lone object is - /// accepted as a single entry. Complete entries retain their received - /// order and shape; scalars carry nothing replayable and are dropped. - pub(super) fn from_complete_payload(payload: serde_json::Value) -> Self { - let entries = match payload { - serde_json::Value::Array(entries) => entries - .into_iter() - .filter(serde_json::Value::is_object) - .collect(), - payload @ serde_json::Value::Object(_) => vec![payload], - _ => Vec::new(), - }; - Self { entries } - } - - /// Absorb one streamed `reasoning_details` payload. - /// - /// Fragments carrying the same `type` and `index` are coalesced even when - /// other logical details appear between them. Without an index, a fragment - /// continues the most recently seen detail of the same type. First-seen - /// detail order is retained. - pub(super) fn push_stream_payload(&mut self, payload: serde_json::Value) { - let incoming = match payload { - serde_json::Value::Array(entries) => entries, - payload @ serde_json::Value::Object(_) => vec![payload], - _ => Vec::new(), - }; - for entry in incoming { - if !entry.is_object() { - continue; - } - match self - .entries - .iter_mut() - .rev() - .find(|existing| continues_detail(existing, &entry)) - { - Some(existing) => merge_detail_fragment(existing, entry), - _ => self.entries.push(entry), - } - } - } - - /// Opaque content part holding the accumulated entries, or `None` when - /// nothing usable arrived. - pub(super) fn into_content_part(self) -> Option { - (!self.entries.is_empty()).then(|| ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: serde_json::Value::Array(self.entries), - }) - } -} - -/// Text-bearing members whose fragments concatenate across stream chunks. -const DETAIL_TEXT_MEMBERS: [&str; 3] = ["text", "summary", "data"]; - -/// Whether `entry` continues the logical detail already in `last`. -/// -/// Aggregators tag each logical detail with a stable `type` and `index`; -/// fragment streams that omit `index` are matched on `type` alone. -fn continues_detail(last: &serde_json::Value, entry: &serde_json::Value) -> bool { - let (Some(last_type), Some(entry_type)) = ( - last.get("type").and_then(serde_json::Value::as_str), - entry.get("type").and_then(serde_json::Value::as_str), - ) else { - return false; - }; - if last_type != entry_type { - return false; - } - - match ( - last.get("index").and_then(serde_json::Value::as_u64), - entry.get("index").and_then(serde_json::Value::as_u64), - ) { - (Some(last_index), Some(entry_index)) => last_index == entry_index, - _ => true, - } -} - -/// Append `entry`'s text fragments onto `last` and fill in members `last` -/// has not seen yet. -fn merge_detail_fragment(last: &mut serde_json::Value, entry: serde_json::Value) { - let serde_json::Value::Object(entry_members) = entry else { - return; - }; - let Some(last_members) = last.as_object_mut() else { - return; - }; - for (key, value) in entry_members { - match last_members.get_mut(&key) { - Some(serde_json::Value::String(existing)) - if DETAIL_TEXT_MEMBERS.contains(&key.as_str()) => - { - if let Some(fragment) = value.as_str() { - existing.push_str(fragment); - } - } - Some(_) => {} - None => { - last_members.insert(key, value); - } - } - } -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiToolCall { - pub id: String, - pub function: ApiFunction, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiFunction { - pub name: String, - pub arguments: String, -} - -#[derive(serde::Deserialize)] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -pub(super) struct ApiUsage { - pub prompt_tokens: i64, - pub completion_tokens: i64, - /// Tolerant superset: aggregator dialects (OpenRouter) report in-band - /// USD cost and cache/reasoning token detail. Absent on plain providers. - #[serde(default)] - pub cost: Option, - #[serde(default)] - pub prompt_tokens_details: Option, - /// DeepSeek-specific top-level count of prompt tokens served from its - /// automatic context cache. - #[serde(default)] - pub prompt_cache_hit_tokens: Option, - #[serde(default)] - pub completion_tokens_details: Option, - /// Modal reports reasoning tokens directly on `usage` instead of nesting - /// them under `completion_tokens_details`. - #[serde(default)] - pub reasoning_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct PromptTokensDetails { - #[serde(default)] - pub cached_tokens: Option, - /// OpenRouter-specific: explicit-cache write tokens. - #[serde(default)] - pub cache_write_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct CompletionTokensDetails { - #[serde(default)] - pub reasoning_tokens: Option, -} - -impl ApiUsage { - /// Normalize into disjoint [`TokenCounts`] buckets: cached and - /// cache-write detail tokens are subtracted out of `input_tokens`, and - /// reasoning tokens out of `output_tokens`, mirroring the - /// `openai_responses` convention. - /// - /// Nested detail fields win over the flat `prompt_cache_hit_tokens` and - /// `reasoning_tokens` spellings that some providers send instead. - pub(super) fn token_counts(&self) -> TokenCounts { - let cached_detail = self - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .or(self.prompt_cache_hit_tokens) - .unwrap_or(0); - let cache_write_detail = self - .prompt_tokens_details - .as_ref() - .and_then(|d| d.cache_write_tokens) - .unwrap_or(0); - let reasoning_detail = self - .completion_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .or(self.reasoning_tokens) - .unwrap_or(0); - let (uncached_input, cached) = - split_inclusive_token_total(self.prompt_tokens, cached_detail); - let (input_tokens, cache_write) = - split_inclusive_token_total(uncached_input, cache_write_detail); - let (output_tokens, reasoning) = - split_inclusive_token_total(self.completion_tokens, reasoning_detail); - TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens: reasoning, - cache_read_tokens: cached, - cache_write_tokens: cache_write, - } - } -} - -// --- Streaming response types --- - -#[derive(serde::Deserialize)] -pub(super) struct StreamChunk { - pub id: Option, - pub model: Option, - pub choices: Option>, - pub usage: Option, - pub cost: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamChoice { - pub delta: Option, - pub finish_reason: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamDelta { - pub content: Option, - /// Reasoning/thinking content (used by Kimi and other reasoning models). - pub reasoning_content: Option, - /// OpenRouter's normalized spelling for reasoning text. - pub reasoning: Option, - /// Structured reasoning channel, streamed as fragments of the entries - /// the non-streaming response returns whole. - #[serde(default)] - pub reasoning_details: Option, - pub tool_calls: Option>, -} - -impl StreamDelta { - pub(super) fn reasoning(&self) -> Option<&str> { - self.reasoning_content - .as_deref() - .or(self.reasoning.as_deref()) - } -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamToolCall { - pub index: usize, - pub id: Option, - pub function: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct StreamFunction { - pub name: Option, - pub arguments: Option, -} - -// --- Accumulated tool call state for streaming --- - -pub(super) struct AccumulatedToolCall { - pub id: String, - pub name: String, - pub arguments: String, - pub started: bool, -} - -#[cfg(test)] -mod tests { - use super::{ - ApiResponse, ApiUsage, ChatContent, ChatTextPart, ReasoningDetails, StreamChunk, - continues_detail, - }; - use crate::codec::cache::CacheControl; - use crate::types::{ContentPart, TokenCounts}; - - #[test] - fn reasoning_detail_continuation_uses_type_when_either_index_is_missing() { - let indexed = serde_json::json!({"type": "reasoning.text", "index": 0}); - let unindexed = serde_json::json!({"type": "reasoning.text"}); - - assert!(continues_detail(&indexed, &unindexed)); - assert!(continues_detail(&unindexed, &indexed)); - assert!(continues_detail(&unindexed, &unindexed)); - } - - #[test] - fn reasoning_detail_continuation_requires_a_matching_string_type() { - let detail = serde_json::json!({"type": "reasoning.text", "index": 0}); - - assert!(!continues_detail( - &detail, - &serde_json::json!({"type": "reasoning.summary", "index": 0}) - )); - assert!(!continues_detail( - &serde_json::json!({"index": 0}), - &serde_json::json!({"index": 0}) - )); - assert!(!continues_detail( - &serde_json::json!({"type": 7, "index": 0}), - &serde_json::json!({"type": 7, "index": 0}) - )); - } - - #[test] - fn unindexed_reasoning_fragment_continues_the_latest_matching_type() { - let mut details = ReasoningDetails::default(); - details.push_stream_payload(serde_json::json!([ - {"type": "reasoning.text", "text": "first", "index": 0}, - {"type": "reasoning.text", "text": "second", "index": 1}, - ])); - details.push_stream_payload(serde_json::json!([ - {"type": "reasoning.text", "text": " continued"}, - ])); - - let ContentPart::Other { data, .. } = - details.into_content_part().expect("reasoning detail part") - else { - panic!("expected opaque reasoning detail part"); - }; - assert_eq!( - data, - serde_json::json!([ - {"type": "reasoning.text", "text": "first", "index": 0}, - {"type": "reasoning.text", "text": "second continued", "index": 1}, - ]) - ); - } - - #[test] - fn chat_content_text_serializes_as_plain_string() { - let content = ChatContent::Text("Hello".to_string()); - assert_eq!( - serde_json::to_value(&content).unwrap(), - serde_json::json!("Hello") - ); - } - - #[test] - fn mark_cache_breakpoint_converts_text_to_annotated_parts() { - let mut content = ChatContent::Text("Hello".to_string()); - content.mark_cache_breakpoint(); - assert_eq!( - serde_json::to_value(&content).unwrap(), - serde_json::json!([{ - "type": "text", - "text": "Hello", - "cache_control": {"type": "ephemeral"} - }]) - ); - } - - #[test] - fn mark_cache_breakpoint_annotates_last_existing_part() { - let mut content = ChatContent::Parts(vec![ - ChatTextPart { - kind: "text".to_string(), - text: "first".to_string(), - cache_control: None, - }, - ChatTextPart { - kind: "text".to_string(), - text: "second".to_string(), - cache_control: Some(CacheControl::ephemeral()), - }, - ]); - content.mark_cache_breakpoint(); - let json = serde_json::to_value(&content).unwrap(); - assert!(json[0].get("cache_control").is_none()); - assert_eq!(json[1]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn token_counts_bound_detail_to_parent_totals() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 53, - "completion_tokens": 59, - "completion_tokens_details": {"reasoning_tokens": 66} - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 53, - output_tokens: 0, - reasoning_tokens: 59, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_accept_deepseek_cache_hit_field() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 53, - "completion_tokens": 11, - "prompt_cache_hit_tokens": 41 - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 12, - output_tokens: 11, - cache_read_tokens: 41, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_accept_modal_reasoning_tokens_field() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 116, - "completion_tokens": 66, - "reasoning_tokens": 54 - })) - .unwrap(); - - assert_eq!(usage.token_counts(), TokenCounts { - input_tokens: 116, - output_tokens: 12, - reasoning_tokens: 54, - ..TokenCounts::default() - }); - } - - #[test] - fn token_counts_prefer_nested_reasoning_detail_over_top_level() { - let both_spellings: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 10, - "completion_tokens": 66, - "completion_tokens_details": {"reasoning_tokens": 20}, - "reasoning_tokens": 54 - })) - .unwrap(); - assert_eq!(both_spellings.token_counts().reasoning_tokens, 20); - - let empty_detail: ApiUsage = serde_json::from_value(serde_json::json!({ - "prompt_tokens": 10, - "completion_tokens": 66, - "completion_tokens_details": {}, - "reasoning_tokens": 54 - })) - .unwrap(); - assert_eq!(empty_detail.token_counts().reasoning_tokens, 54); - } - - #[test] - fn reasoning_accepts_provider_and_openrouter_spellings() { - let provider_response: ApiResponse = serde_json::from_value(serde_json::json!({ - "id": "response-1", - "model": "reasoning-model", - "choices": [{ - "message": { - "content": null, - "reasoning_content": "provider reasoning" - }, - "finish_reason": "stop" - }] - })) - .unwrap(); - assert_eq!( - provider_response.choices[0].message.reasoning(), - Some("provider reasoning") - ); - - let openrouter_response: ApiResponse = serde_json::from_value(serde_json::json!({ - "id": "response-2", - "model": "reasoning-model", - "choices": [{ - "message": { - "content": null, - "reasoning": "OpenRouter reasoning" - }, - "finish_reason": "stop" - }] - })) - .unwrap(); - assert_eq!( - openrouter_response.choices[0].message.reasoning(), - Some("OpenRouter reasoning") - ); - let openrouter_chunk: StreamChunk = serde_json::from_value(serde_json::json!({ - "id": "response-2", - "model": "reasoning-model", - "choices": [{ - "delta": {"reasoning": "OpenRouter reasoning"}, - "finish_reason": null - }] - })) - .unwrap(); - assert_eq!( - openrouter_chunk.choices.unwrap()[0] - .delta - .as_ref() - .unwrap() - .reasoning(), - Some("OpenRouter reasoning") - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/decode.rs b/lib/components/fabro-llm/src/codec/openai_responses/decode.rs deleted file mode 100644 index 55fbd03d5..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/decode.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Response decoding: OpenAI Responses API body → canonical `Response`. - -use serde::Deserialize; - -use super::wire::{ApiResponse, ApiUsage, InputTokensResponse}; -use crate::codec::{CodecCtx, parse_tool_arguments_or_empty, split_inclusive_token_total}; -use crate::error::Error; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, TokenCounts, ToolCall, -}; - -pub(super) fn token_counts_from_api_usage(usage: Option<&ApiUsage>) -> TokenCounts { - usage.map_or_else(TokenCounts::default, |u| { - let cached_detail = u - .input_tokens_details - .as_ref() - .and_then(|d| d.cached_tokens) - .unwrap_or(0); - let reasoning_detail = u - .output_tokens_details - .as_ref() - .and_then(|d| d.reasoning_tokens) - .unwrap_or(0); - let (input_tokens, cached_tokens) = - split_inclusive_token_total(u.input_tokens, cached_detail); - let (output_tokens, reasoning_tokens) = - split_inclusive_token_total(u.output_tokens, reasoning_detail); - TokenCounts { - input_tokens, - output_tokens, - reasoning_tokens, - cache_read_tokens: cached_tokens, - ..TokenCounts::default() - } - }) -} - -/// Map the Responses API status to a `FinishReason`. -pub(super) fn map_finish_reason(status: Option<&str>, has_tool_calls: bool) -> FinishReason { - if has_tool_calls { - return FinishReason::ToolCalls; - } - match status { - Some("completed") | None => FinishReason::Stop, - Some("incomplete") => FinishReason::Length, - Some("failed") => FinishReason::Error, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -/// Build a `ToolCall` from a `function_call` / `custom_tool_call` output item. -/// The call-id/item-id round-trip rules live here, shared by the blocking and -/// streaming decode paths. -pub(super) fn tool_call_from_item(item: &serde_json::Value, custom: bool) -> ToolCall { - let item_id = item - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let call_id = item - .get("call_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(item_id); - let name = item - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - let mut tc = if custom { - let raw_input = item - .get("input") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let mut tc = ToolCall::new(call_id, name, serde_json::json!(raw_input)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(raw_input.to_string()); - tc - } else { - let args_str = item - .get("arguments") - .and_then(serde_json::Value::as_str) - .unwrap_or("{}"); - let arguments = parse_tool_arguments_or_empty(args_str); - let mut tc = ToolCall::new(call_id, name, arguments); - tc.raw_arguments = Some(args_str.to_string()); - tc - }; - // Preserve item-level ID (fc_xxx) for Responses API round-trip - if !item_id.is_empty() { - tc.provider_metadata = Some(serde_json::json!({"id": item_id})); - } - tc -} - -/// Parse output items from the Responses API into content parts. -pub(super) fn parse_output(output: Vec) -> (Vec, bool) { - let mut parts = Vec::new(); - let mut has_tool_calls = false; - - for item in output { - let item_type = item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - match item_type.as_str() { - "message" => { - // Preserve the full message item for Responses API round-tripping. - // The item's `id` and `status` fields are required so that reasoning - // items preceding it can find their "required following item." - let mut texts = Vec::new(); - if let Some(content) = item.get("content").and_then(|c| c.as_array()) { - for block in content { - if block.get("type").and_then(serde_json::Value::as_str) - == Some("output_text") - { - if let Some(text) = - block.get("text").and_then(serde_json::Value::as_str) - { - texts.push(ContentPart::text(text)); - } - } - } - } - parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: item, - }); - parts.extend(texts); - } - "reasoning" => { - parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - }); - } - "function_call" | "custom_tool_call" => { - let tc = tool_call_from_item(&item, item_type == "custom_tool_call"); - // Skip tool calls with empty names (e.g. model-internal items) - if tc.name.is_empty() { - continue; - } - has_tool_calls = true; - parts.push(ContentPart::ToolCall(tc)); - } - _ => {} - } - } - - (parts, has_tool_calls) -} - -pub(super) fn decode_response( - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, -) -> Result { - let raw: serde_json::Value = serde_json::from_str(body) - .map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?; - let api_resp = ApiResponse::deserialize(&raw) - .map_err(|e| Error::network(format!("failed to parse OpenAI response: {e}"), e))?; - - let (content_parts, has_tool_calls) = parse_output(api_resp.output); - let finish_reason = map_finish_reason(api_resp.status.as_deref(), has_tool_calls); - - let usage = token_counts_from_api_usage(api_resp.usage.as_ref()); - - Ok(Response { - id: api_resp.id, - model: api_resp.model.unwrap_or_else(|| ctx.request.model.clone()), - provider: ctx.provider_name.to_string(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage, - raw: Some(raw), - warnings: vec![], - rate_limit, - cost_usd: None, - cost_source: None, - }) -} - -pub(super) fn decode_count_tokens(body: &str) -> Result { - let response: InputTokensResponse = - serde_json::from_str(body).map_err(|e| Error::Configuration { - message: format!("failed to parse OpenAI input token response: {e}"), - source: None, - })?; - - if response.object != "response.input_tokens" { - return Err(Error::Configuration { - message: format!( - "failed to parse OpenAI input token response: unexpected object '{}'", - response.object - ), - source: None, - }); - } - - Ok(response.input_tokens) -} - -#[cfg(test)] -mod tests { - use super::super::encode; - use super::*; - - #[test] - fn token_counts_bound_detail_to_parent_totals() { - let usage: ApiUsage = serde_json::from_value(serde_json::json!({ - "input_tokens": 53, - "output_tokens": 59, - "input_tokens_details": null, - "output_tokens_details": {"reasoning_tokens": 66} - })) - .unwrap(); - - assert_eq!(token_counts_from_api_usage(Some(&usage)), TokenCounts { - input_tokens: 53, - output_tokens: 0, - reasoning_tokens: 59, - ..TokenCounts::default() - }); - } - - #[test] - fn parse_output_preserves_both_ids_on_function_call() { - let output = vec![serde_json::json!({ - "type": "function_call", - "id": "fc_abc123", - "call_id": "call_xyz789", - "name": "get_weather", - "arguments": "{\"location\":\"NYC\"}" - })]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolCall(tc) => { - // call_id is used as the ToolCall.id (links to tool results) - assert_eq!(tc.id, "call_xyz789"); - // item-level id (fc_xxx) is preserved in provider_metadata - let meta = tc - .provider_metadata - .as_ref() - .expect("provider_metadata should be set"); - assert_eq!(meta["id"], "fc_abc123"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_output_preserves_custom_tool_call_raw_input() { - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let output = vec![serde_json::json!({ - "type": "custom_tool_call", - "id": "ctc_abc123", - "call_id": "call_xyz789", - "name": "apply_patch", - "input": patch, - })]; - - let (parts, has_tool_calls) = parse_output(output); - - assert!(has_tool_calls); - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.id, "call_xyz789"); - assert_eq!(tc.name, "apply_patch"); - assert_eq!(tc.tool_type, "custom"); - assert_eq!(tc.arguments, serde_json::json!(patch)); - assert_eq!(tc.raw_arguments.as_deref(), Some(patch)); - let meta = tc - .provider_metadata - .as_ref() - .expect("provider metadata should preserve item id"); - assert_eq!(meta["id"], "ctc_abc123"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn parse_output_preserves_reasoning_items() { - let output = vec![ - serde_json::json!({ - "type": "reasoning", - "id": "rs_abc123", - "summary": [{"type": "summary_text", "text": "Thinking..."}] - }), - serde_json::json!({ - "type": "function_call", - "id": "fc_def456", - "call_id": "call_789", - "name": "search", - "arguments": "{}" - }), - ]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - assert_eq!(parts.len(), 2); - // First part is the reasoning item - match &parts[0] { - ContentPart::Other { kind, data } => { - assert_eq!(kind, ContentPart::OPENAI_REASONING); - assert_eq!(data["type"], "reasoning"); - assert_eq!(data["id"], "rs_abc123"); - } - other => panic!("expected Other, got {other:?}"), - } - // Second part is the function call - assert!(matches!(&parts[1], ContentPart::ToolCall(_))); - } - - #[test] - fn parse_output_preserves_message_items() { - let output = vec![ - serde_json::json!({ - "type": "reasoning", - "id": "rs_abc", - "summary": [] - }), - serde_json::json!({ - "type": "message", - "id": "msg_xyz", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello"}] - }), - serde_json::json!({ - "type": "function_call", - "id": "fc_123", - "call_id": "call_456", - "name": "search", - "arguments": "{}" - }), - ]; - let (parts, has_tool_calls) = parse_output(output); - assert!(has_tool_calls); - // reasoning + openai_message + text + function_call - assert_eq!(parts.len(), 4); - assert!( - matches!(&parts[0], ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_REASONING) - ); - assert!( - matches!(&parts[1], ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_MESSAGE && data["id"] == "msg_xyz") - ); - assert!(matches!(&parts[2], ContentPart::Text(t) if t == "Hello")); - assert!(matches!(&parts[3], ContentPart::ToolCall(_))); - } - - #[test] - fn parse_output_round_trips_function_call_ids() { - // Simulate a response from the Responses API - let output = vec![serde_json::json!({ - "type": "function_call", - "id": "fc_item1", - "call_id": "call_001", - "name": "search", - "arguments": "{\"q\":\"test\"}" - })]; - let (parts, _) = parse_output(output); - - // Now translate back to input format - let msg = Message { - role: Role::Assistant, - content: parts, - name: None, - tool_call_id: None, - }; - let (_, input) = encode::translate_input(&[msg]); - let fc = &input[0]; - - // The round-tripped function call should have correct IDs - assert_eq!(fc["id"], "fc_item1"); - assert_eq!(fc["call_id"], "call_001"); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/encode.rs b/lib/components/fabro-llm/src/codec/openai_responses/encode.rs deleted file mode 100644 index 8ede38ec3..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/encode.rs +++ /dev/null @@ -1,949 +0,0 @@ -//! Request encoding: canonical request → OpenAI Responses API body. -//! -//! Pure and sync. File-backed image attachments are resolved to inline data by -//! `attachments::resolve` in the adapter *before* encode runs, so the content -//! translation here never touches the filesystem. - -use std::collections::HashSet; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - -use super::wire::ApiRequest; -use crate::codec::{CodecCtx, EncodedRequest}; -use crate::types::{ - ContentPart, Message, ResponseFormat, ResponseFormatType, Role, ToolChoice, ToolDefinition, -}; - -// --- Public entry points ----------------------------------------------------- - -pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { - EncodedRequest { - body: build_body(ctx, stream), - endpoint: "/responses".to_string(), - headers: Vec::new(), - } -} - -pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { - EncodedRequest { - body: filter_input_tokens_request_body(build_body(ctx, false)), - endpoint: "/responses/input_tokens".to_string(), - headers: Vec::new(), - } -} - -/// Serialize the API request and merge any `provider_options.openai` keys into -/// the body (overrides win, matching the long-standing contract). -fn build_body(ctx: &CodecCtx<'_>, stream: bool) -> serde_json::Value { - let api_request = build_api_request(ctx, stream); - let mut body = serde_json::to_value(&api_request).unwrap_or_else(|_| serde_json::json!({})); - - if let Some(openai_opts) = ctx - .request - .provider_options - .as_ref() - .and_then(|opts| opts.get("openai")) - { - if let (Some(base), Some(overrides)) = (body.as_object_mut(), openai_opts.as_object()) { - for (key, value) in overrides { - base.insert(key.clone(), value.clone()); - } - } - } - - body -} - -/// Build an `ApiRequest` from the canonical request. -/// -/// When the route is in codex mode (`ctx.params.openai_codex`), unsupported -/// fields (`temperature`, `max_output_tokens`, `top_p`) are omitted and empty -/// instructions are sent as `""` (required by the Codex endpoint). -fn build_api_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest { - let request = ctx.request; - let codex_mode = ctx.params.openai_codex; - - let (instructions, input) = translate_input(&request.messages); - let api_tools = request.tools.as_ref().map(|t| translate_tools(t)); - let tool_choice = request.tool_choice.as_ref().map(translate_tool_choice); - let reasoning = request - .reasoning_effort - .as_ref() - .map(|effort| serde_json::json!({"effort": <&'static str>::from(*effort)})); - let text = request - .response_format - .as_ref() - .and_then(translate_response_format); - - let include = vec!["reasoning.encrypted_content".to_string()]; - - let instructions = if codex_mode { - Some(instructions.unwrap_or_default()) - } else { - instructions - }; - - ApiRequest { - model: ctx.deployment_id.to_string(), - input, - instructions, - temperature: if codex_mode { - None - } else { - request.temperature - }, - max_output_tokens: if codex_mode { None } else { request.max_tokens }, - top_p: if codex_mode { None } else { request.top_p }, - tools: api_tools, - tool_choice, - reasoning, - text, - stop: request.stop_sequences.clone(), - metadata: request.metadata.clone(), - // store: false means output items are not persisted server-side. - // Request encrypted reasoning content on every turn so reasoning items - // from models that emit them by default can round-trip statelessly. - store: false, - include, - stream, - } -} - -/// Project a full request body down to the fields the -/// `/responses/input_tokens` endpoint accepts. -fn filter_input_tokens_request_body(mut body: serde_json::Value) -> serde_json::Value { - const ALLOWED_FIELDS: &[&str] = &[ - "conversation", - "input", - "instructions", - "model", - "parallel_tool_calls", - "previous_response_id", - "reasoning", - "text", - "tool_choice", - "tools", - "truncation", - ]; - - let Some(obj) = body.as_object_mut() else { - return serde_json::json!({}); - }; - obj.retain(|key, _| ALLOWED_FIELDS.contains(&key.as_str())); - body -} - -// --- Content / message / tool translation ------------------------------------ - -/// Translate unified messages to Responses API `input` array format. Sync: -/// file-backed image attachments are already resolved to inline data upstream. -pub(super) fn translate_input(messages: &[Message]) -> (Option, Vec) { - let mut instructions_parts: Vec = Vec::new(); - let mut input: Vec = Vec::new(); - let mut custom_call_ids: HashSet = HashSet::new(); - - for msg in messages { - match msg.role { - Role::System | Role::Developer => { - instructions_parts.push(msg.text()); - } - Role::User => { - let mut content = Vec::new(); - for part in &msg.content { - let maybe_content = match part { - ContentPart::Text(text) => { - Some(serde_json::json!({"type": "input_text", "text": text})) - } - ContentPart::Image(img) => match &img.url { - Some(url) => { - Some(serde_json::json!({"type": "input_image", "image_url": url})) - } - None => img.data.as_ref().map(|data| { - let mime = img.media_type.as_deref().unwrap_or("image/png"); - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({ - "type": "input_image", - "image_url": format!("data:{mime};base64,{b64}"), - }) - }), - }, - ContentPart::Audio(_) => Some( - serde_json::json!({"type": "input_text", "text": "[Audio content not supported by this provider]"}), - ), - ContentPart::Document(doc) => { - let desc = doc.file_name.as_ref().map_or_else( - || "[Document content not supported by this provider]".to_string(), - |name| format!("[Document '{name}': content type not supported by this provider]"), - ); - Some(serde_json::json!({"type": "input_text", "text": desc})) - } - _ => None, - }; - if let Some(content_part) = maybe_content { - content.push(content_part); - } - } - if !content.is_empty() { - input.push(serde_json::json!({ - "type": "message", - "role": "user", - "content": content, - })); - } - } - Role::Assistant => { - // If we have a preserved opaque message item (with id/status), use - // it instead of constructing a new message from Text parts. This is - // required so that reasoning items can find their "required following - // item" during Responses API round-tripping. - let has_opaque_message = msg.content.iter().any(|p| { - matches!(p, ContentPart::Other { kind, .. } if kind == ContentPart::OPENAI_MESSAGE) - }); - for part in &msg.content { - match part { - ContentPart::Text(text) if !has_opaque_message => { - input.push(serde_json::json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": text}], - })); - } - ContentPart::ToolCall(tc) if !tc.name.is_empty() => { - // Use the item-level ID (fc_xxx) for the `id` field; - // fall back to tc.id if no provider_metadata was stored. - let item_id = tc - .provider_metadata - .as_ref() - .and_then(|m| m.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or(&tc.id); - if tc.tool_type == "custom" { - custom_call_ids.insert(tc.id.clone()); - let raw_input = tc.raw_arguments.as_ref().map_or_else( - || { - tc.arguments.as_str().map_or_else( - || tc.arguments.to_string(), - str::to_string, - ) - }, - Clone::clone, - ); - input.push(serde_json::json!({ - "type": "custom_tool_call", - "id": item_id, - "call_id": tc.id, - "name": tc.name, - "input": raw_input, - })); - } else { - let args = tc - .raw_arguments - .as_ref() - .map_or_else(|| tc.arguments.to_string(), Clone::clone); - input.push(serde_json::json!({ - "type": "function_call", - "id": item_id, - "call_id": tc.id, - "name": tc.name, - "arguments": args, - })); - } - } - ContentPart::Other { data, .. } if part.is_opaque_openai() => { - input.push(data.clone()); - } - _ => {} - } - } - } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult(tr) = part { - let output = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - let is_custom = custom_call_ids.contains(&tr.tool_call_id) - || msg.name.as_deref() == Some("apply_patch"); - let mut item = if is_custom { - serde_json::json!({ - "type": "custom_tool_call_output", - "call_id": tr.tool_call_id, - "output": output, - }) - } else { - serde_json::json!({ - "type": "function_call_output", - "call_id": tr.tool_call_id, - "output": output, - }) - }; - if tr.is_error && !is_custom { - item["status"] = serde_json::json!("incomplete"); - } - input.push(item); - } - } - } - } - } - - let instructions = if instructions_parts.is_empty() { - None - } else { - Some(instructions_parts.join("\n")) - }; - - (instructions, input) -} - -/// Translate unified tool definitions to Responses API tool format. -pub(super) fn translate_tools(tools: &[ToolDefinition]) -> Vec { - tools - .iter() - .map(|t| { - if t.is_custom() { - serde_json::json!({ - "type": "custom", - "name": t.name, - "description": t.description, - "format": t.custom_format().cloned().unwrap_or_else(|| serde_json::json!({})), - }) - } else { - serde_json::json!({ - "type": "function", - "name": t.name, - "description": t.description, - "parameters": t.parameters, - }) - } - }) - .collect() -} - -/// Translate unified `ToolChoice` to Responses API format. -fn translate_tool_choice(choice: &ToolChoice) -> serde_json::Value { - match choice { - ToolChoice::Auto => serde_json::json!("auto"), - ToolChoice::None => serde_json::json!("none"), - ToolChoice::Required => serde_json::json!("required"), - ToolChoice::Named { tool_name } => { - serde_json::json!({"type": "function", "name": tool_name}) - } - } -} - -/// Translate unified `ResponseFormat` to Responses API `text` field. -/// -/// The Responses API uses `"text": {"format": {...}}` for structured output. -fn translate_response_format(format: &ResponseFormat) -> Option { - match format.kind { - ResponseFormatType::Text => None, - ResponseFormatType::JsonObject => { - Some(serde_json::json!({"format": {"type": "json_object"}})) - } - ResponseFormatType::JsonSchema => { - let mut schema_obj = serde_json::json!({ - "type": "json_schema", - "name": "response", - "strict": format.strict, - }); - if let Some(schema) = &format.json_schema { - schema_obj["schema"] = schema.clone(); - } - Some(serde_json::json!({"format": schema_obj})) - } - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use super::*; - use crate::codec::CodecParams; - use crate::types::{AudioData, DocumentData, ReasoningEffort, Request, ToolCall, ToolResult}; - - fn minimal_request() -> Request { - Request { - model: "gpt-4o".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Encode `request` (no catalog: the wire model id is the request model) - /// and return the merged body, mirroring the adapter's encode path. - fn encode_body(request: &Request, stream: bool, codex: bool) -> serde_json::Value { - let params = CodecParams { - openai_codex: codex, - ..CodecParams::default() - }; - let ctx = CodecCtx { - request, - provider_name: "openai", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - encode(&ctx, stream).body - } - - #[test] - fn build_request_body_includes_metadata() { - let mut metadata = HashMap::new(); - metadata.insert("user_id".to_string(), "u123".to_string()); - metadata.insert("session".to_string(), "s456".to_string()); - - let mut request = minimal_request(); - request.metadata = Some(metadata); - - let body = encode_body(&request, false, false); - let meta = body.get("metadata").expect("metadata should be present"); - assert_eq!(meta["user_id"], "u123"); - assert_eq!(meta["session"], "s456"); - } - - #[test] - fn build_request_body_omits_metadata_when_none() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert!(body.get("metadata").is_none()); - } - - #[test] - fn build_request_body_merges_provider_options_openai() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "openai": { - "store": true, - "previous_response_id": "resp_abc123" - } - })); - - let body = encode_body(&request, false, false); - assert_eq!(body["store"], true); - assert_eq!(body["previous_response_id"], "resp_abc123"); - } - - #[test] - fn build_request_body_provider_options_override_fields() { - let mut request = minimal_request(); - request.temperature = Some(0.5); - request.provider_options = Some(serde_json::json!({ - "openai": { - "temperature": 0.9 - } - })); - - let body = encode_body(&request, false, false); - // provider_options should override the base field - assert_eq!(body["temperature"], 0.9); - } - - #[test] - fn build_request_body_ignores_non_openai_provider_options() { - let mut request = minimal_request(); - request.provider_options = Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000} - } - })); - - let body = encode_body(&request, false, false); - // anthropic options should not leak into the OpenAI request - assert!(body.get("thinking").is_none()); - } - - #[test] - fn build_request_body_no_provider_options() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert_eq!(body["model"], "gpt-4o"); - // stream field is omitted when false (skip_serializing_if) - assert!(body.get("stream").is_none()); - } - - #[test] - fn filter_input_tokens_request_body_keeps_only_count_fields() { - let mut metadata = HashMap::new(); - metadata.insert("trace".to_string(), "abc".to_string()); - - let mut request = minimal_request(); - request.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]); - request.reasoning_effort = Some(ReasoningEffort::Low); - request.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({"type": "object"})), - strict: true, - }); - request.temperature = Some(0.2); - request.top_p = Some(0.9); - request.max_tokens = Some(32); - request.stop_sequences = Some(vec!["END".to_string()]); - request.metadata = Some(metadata); - - let body = encode_body(&request, true, false); - let filtered = filter_input_tokens_request_body(body); - - assert_eq!( - filtered, - serde_json::json!({ - "input": [{"type": "message", "content": [{"text": "Hello", "type": "input_text"}], "role": "user"}], - "model": "gpt-4o", - "reasoning": {"effort": "low"}, - "text": {"format": {"name": "response", "schema": {"type": "object"}, "strict": true, "type": "json_schema"}}, - "tools": [{"description": "Search files", "name": "search", "parameters": {"type": "object"}, "type": "function"}] - }) - ); - assert!(filtered.get("store").is_none()); - assert!(filtered.get("include").is_none()); - assert!(filtered.get("stream").is_none()); - assert!(filtered.get("max_output_tokens").is_none()); - assert!(filtered.get("metadata").is_none()); - assert!(filtered.get("temperature").is_none()); - assert!(filtered.get("top_p").is_none()); - assert!(filtered.get("stop").is_none()); - } - - #[test] - fn filter_input_tokens_request_body_preserves_codex_serialization() { - let body = encode_body(&minimal_request(), false, true); - let filtered = filter_input_tokens_request_body(body); - - assert_eq!(filtered["instructions"], ""); - assert!(filtered.get("input").is_some()); - assert!(filtered.get("model").is_some()); - assert!(filtered.get("max_output_tokens").is_none()); - assert!(filtered.get("include").is_none()); - } - - #[test] - fn count_tokens_endpoint_carries_filtered_body() { - let request = minimal_request(); - let params = CodecParams::default(); - let ctx = CodecCtx { - request: &request, - provider_name: "openai", - deployment_id: &request.model, - model: None, - params: ¶ms, - }; - - let encoded = encode_count_tokens(&ctx); - assert_eq!(encoded.endpoint, "/responses/input_tokens"); - assert!(encoded.body.get("store").is_none()); - assert!(encoded.body.get("include").is_none()); - assert_eq!(encoded.body["model"], "gpt-4o"); - } - - #[test] - fn build_request_body_includes_encrypted_reasoning_for_stateless_requests() { - let request = minimal_request(); - - let body = encode_body(&request, false, false); - - assert_eq!( - body["include"], - serde_json::json!(["reasoning.encrypted_content"]) - ); - } - - #[test] - fn build_request_body_emits_custom_apply_patch_tool() { - let mut request = minimal_request(); - request.tools = Some(vec![ - ToolDefinition::custom( - "apply_patch", - "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", - serde_json::json!({ - "type": "grammar", - "syntax": "lark", - "definition": "start: begin_patch hunk+ end_patch", - }), - ), - ToolDefinition::function( - "read_file", - "Read file", - serde_json::json!({ - "type": "object", - "properties": {"file_path": {"type": "string"}}, - "required": ["file_path"], - }), - ), - ]); - - let body = encode_body(&request, false, false); - let tools = body["tools"].as_array().expect("tools should be present"); - let apply_patch = tools - .iter() - .find(|tool| tool["name"] == "apply_patch") - .expect("apply_patch tool should be present"); - let read_file = tools - .iter() - .find(|tool| tool["name"] == "read_file") - .expect("read_file tool should be present"); - - assert_eq!(apply_patch["type"], "custom"); - assert_eq!(apply_patch["format"]["type"], "grammar"); - assert_eq!(apply_patch["format"]["syntax"], "lark"); - assert!(apply_patch.get("parameters").is_none()); - assert_eq!(read_file["type"], "function"); - assert_eq!(read_file["parameters"]["type"], "object"); - } - - #[test] - fn build_request_body_stream_flag() { - let request = minimal_request(); - let body = encode_body(&request, true, false); - assert!(body["stream"].as_bool().unwrap_or(false)); - } - - #[test] - fn build_request_body_metadata_and_provider_options_together() { - let mut metadata = HashMap::new(); - metadata.insert("trace_id".to_string(), "t789".to_string()); - - let mut request = minimal_request(); - request.metadata = Some(metadata); - request.provider_options = Some(serde_json::json!({ - "openai": { - "store": true - } - })); - - let body = encode_body(&request, false, false); - assert_eq!(body["metadata"]["trace_id"], "t789"); - assert_eq!(body["store"], true); - } - - #[test] - fn build_request_body_includes_stop_sequences() { - let mut request = minimal_request(); - request.stop_sequences = Some(vec!["END".to_string(), "STOP".to_string()]); - - let body = encode_body(&request, false, false); - let stop = body.get("stop").expect("stop should be present"); - let arr = stop.as_array().expect("stop should be an array"); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0], "END"); - assert_eq!(arr[1], "STOP"); - } - - #[test] - fn build_request_body_omits_stop_when_none() { - let request = minimal_request(); - let body = encode_body(&request, false, false); - assert!(body.get("stop").is_none()); - } - - #[test] - fn audio_content_produces_text_fallback() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Audio content not supported by this provider]" - ); - } - - #[test] - fn document_content_produces_text_fallback_with_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: Some("report.pdf".to_string()), - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Document 'report.pdf': content type not supported by this provider]" - ); - } - - #[test] - fn document_content_produces_text_fallback_without_filename() { - let msg = Message { - role: Role::User, - content: vec![ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - })], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let content = input[0]["content"] - .as_array() - .expect("content should be array"); - assert_eq!(content[0]["type"], "input_text"); - assert_eq!( - content[0]["text"], - "[Document content not supported by this provider]" - ); - } - - #[test] - fn translate_input_uses_item_id_for_id_field() { - let mut tc = ToolCall::new( - "call_xyz789", - "get_weather", - serde_json::json!({"location": "NYC"}), - ); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_abc123"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let fc = &input[0]; - assert_eq!(fc["type"], "function_call"); - // id field uses the fc_ prefixed item ID - assert_eq!(fc["id"], "fc_abc123"); - // call_id field uses the call_ prefixed call ID - assert_eq!(fc["call_id"], "call_xyz789"); - } - - #[test] - fn translate_input_falls_back_to_tc_id_without_metadata() { - let tc = ToolCall::new("call_xyz789", "get_weather", serde_json::json!({})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - let fc = &input[0]; - // Without provider_metadata, both fields use tc.id - assert_eq!(fc["id"], "call_xyz789"); - assert_eq!(fc["call_id"], "call_xyz789"); - } - - #[test] - fn reasoning_items_round_trip_through_translate_input() { - let reasoning = serde_json::json!({ - "type": "reasoning", - "id": "rs_abc123", - "summary": [{"type": "summary_text", "text": "Thinking..."}] - }); - let mut tc = ToolCall::new("call_789", "search", serde_json::json!({})); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: reasoning, - }, - ContentPart::ToolCall(tc), - ], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 2); - // Reasoning item is emitted first - assert_eq!(input[0]["type"], "reasoning"); - assert_eq!(input[0]["id"], "rs_abc123"); - // Function call follows - assert_eq!(input[1]["type"], "function_call"); - assert_eq!(input[1]["id"], "fc_def456"); - assert_eq!(input[1]["call_id"], "call_789"); - } - - #[test] - fn reasoning_message_function_call_round_trip() { - // Simulates an assistant turn with reasoning + text + tool call. - // The opaque message item (with id/status) must be used instead of - // constructing a new one from Text, so the reasoning item can find - // its "required following item." - let reasoning = serde_json::json!({ - "type": "reasoning", - "id": "rs_xyz789", - "summary": [{"type": "summary_text", "text": "Let me check..."}] - }); - let opaque_message = serde_json::json!({ - "type": "message", - "id": "msg_abc123", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Checking now."}] - }); - let mut tc = ToolCall::new("call_001", "shell", serde_json::json!({"cmd": "ls"})); - tc.provider_metadata = Some(serde_json::json!({"id": "fc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: reasoning, - }, - ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: opaque_message, - }, - ContentPart::text("Checking now."), - ContentPart::ToolCall(tc), - ], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 3); - // Reasoning first - assert_eq!(input[0]["type"], "reasoning"); - assert_eq!(input[0]["id"], "rs_xyz789"); - // Opaque message with id/status (not a reconstructed one) - assert_eq!(input[1]["type"], "message"); - assert_eq!(input[1]["id"], "msg_abc123"); - assert_eq!(input[1]["status"], "completed"); - // Function call last - assert_eq!(input[2]["type"], "function_call"); - assert_eq!(input[2]["id"], "fc_def456"); - } - - #[test] - fn text_without_opaque_message_still_constructs_message() { - // For non-OpenAI turns or turns without preserved message items, - // Text parts should still produce a constructed message. - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::text("Hello")], - name: None, - tool_call_id: None, - }; - let (_, input) = translate_input(&[msg]); - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "message"); - assert_eq!(input[0]["role"], "assistant"); - // No id field on constructed messages - assert!(input[0].get("id").is_none()); - } - - #[test] - fn custom_tool_call_history_round_trips_through_translate_input() { - let patch = "*** Begin Patch\n*** Delete File: stale.txt\n*** End Patch\n"; - let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(patch.to_string()); - tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"})); - - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - - let (_, input) = translate_input(&[msg]); - - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "custom_tool_call"); - assert_eq!(input[0]["id"], "ctc_def456"); - assert_eq!(input[0]["call_id"], "call_001"); - assert_eq!(input[0]["name"], "apply_patch"); - assert_eq!(input[0]["input"], patch); - } - - #[test] - fn custom_tool_result_history_round_trips_through_translate_input() { - let msg = Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(ToolResult::success( - "call_001", - serde_json::json!("Success. Updated the following files:\nA hello.txt\n"), - ))], - name: Some("apply_patch".to_string()), - tool_call_id: Some("call_001".to_string()), - }; - - let (_, input) = translate_input(&[msg]); - - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "custom_tool_call_output"); - assert_eq!(input[0]["call_id"], "call_001"); - assert_eq!( - input[0]["output"], - "Success. Updated the following files:\nA hello.txt\n" - ); - } - - #[test] - fn custom_tool_result_history_uses_prior_custom_call_without_tool_message_name() { - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let mut tc = ToolCall::new("call_001", "apply_patch", serde_json::json!(patch)); - tc.tool_type = "custom".to_string(); - tc.raw_arguments = Some(patch.to_string()); - tc.provider_metadata = Some(serde_json::json!({"id": "ctc_def456"})); - - let assistant_msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tc)], - name: None, - tool_call_id: None, - }; - let tool_msg = Message::tool_result( - "call_001", - serde_json::json!("Success. Updated the following files:\nA hello.txt\n"), - false, - ); - - let (_, input) = translate_input(&[assistant_msg, tool_msg]); - - assert_eq!(input.len(), 2); - assert_eq!(input[1]["type"], "custom_tool_call_output"); - assert_eq!(input[1]["call_id"], "call_001"); - assert_eq!( - input[1]["output"], - "Success. Updated the following files:\nA hello.txt\n" - ); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/mod.rs b/lib/components/fabro-llm/src/codec/openai_responses/mod.rs deleted file mode 100644 index 75491bf1e..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! The OpenAI Responses (`/responses`) codec. -//! -//! Serves OpenAI direct today, in two route flavors that share this codec: -//! the standard route and the Codex route (`CodecParams::openai_codex`, which -//! omits sampling params encode-side; its forced streaming lives in the -//! adapter's route config). Pure translation: no HTTP, auth, or base URL — -//! the adapter shell owns those. -//! -//! HTTP error bodies use the shared `decode_error` default (openai uses the -//! standard `error_from_status_code` + `parse_error_body` path); streaming -//! `error` / `response.failed` events are mapped inside the decoder -//! (`on_event` → `Err`). - -mod decode; -mod encode; -mod stream; -mod wire; - -use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; -use crate::error::Error; -use crate::types::{RateLimitInfo, Response}; - -/// Codec for the OpenAI Responses wire dialect. -pub(crate) struct OpenAiResponses; - -impl Codec for OpenAiResponses { - fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { - Ok(encode::encode(ctx, stream)) - } - - fn decode_response( - &self, - body: &str, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Result { - decode::decode_response(body, ctx, rate_limit) - } - - fn stream_decoder( - &self, - ctx: &CodecCtx<'_>, - rate_limit: Option, - ) -> Box { - Box::new(stream::SseAccumulator::new(ctx, rate_limit)) - } - - fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { - Some(Ok(encode::encode_count_tokens(ctx))) - } - - fn decode_count_tokens(&self, body: &str) -> Result { - decode::decode_count_tokens(body) - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs deleted file mode 100644 index 9bca719ec..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs +++ /dev/null @@ -1,836 +0,0 @@ -//! Streaming decoder: OpenAI Responses SSE events → canonical `StreamEvent`s. -//! -//! Byte reading and SSE block framing live in the transport; this decoder is -//! fed framed `RawEvent`s. The event type is resolved from the SSE `event:` -//! line or the JSON `type` field. The Responses API finishes via -//! `response.completed` / `response.incomplete`; byte-stream end synthesizes -//! nothing, so `finish()` returns an empty list. - -use serde::Deserialize; - -use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item}; -use super::wire::ApiUsage; -use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; -use crate::types::{ - ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts, - ToolCall, -}; - -/// Map an OpenAI stream `error` / `response.failed` payload to a provider -/// error, classifying on `code` falling back to `type`. -fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &str) -> Error { - let classifier = error - .get("code") - .and_then(serde_json::Value::as_str) - .filter(|code| !code.is_empty()) - .or_else(|| { - error - .get("type") - .and_then(serde_json::Value::as_str) - .filter(|error_type| !error_type.is_empty()) - }); - let message = error - .get("message") - .and_then(serde_json::Value::as_str) - .filter(|message| !message.is_empty()) - .map_or_else(|| "OpenAI stream error".to_string(), str::to_string); - - // Unrecognized and absent codes are treated as transient. - let kind = classifier - .and_then(error::kind_from_error_code) - .unwrap_or(ProviderErrorKind::Server); - - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider.to_string(), - status_code: None, - error_code: classifier.map(str::to_string), - retry_after: None, - raw: Some(error.clone()), - }), - } -} - -/// Accumulated state across SSE events during streaming. -pub(super) struct SseAccumulator { - /// Requested model, used as the fallback when the response omits one. - model: String, - /// Configured provider name stamped into responses and error details. - provider: String, - response_id: String, - response_model: String, - accumulated_text: String, - tool_calls: Vec, - /// Raw reasoning output items to preserve for round-tripping. - reasoning_items: Vec, - /// Raw message output items to preserve for round-tripping. - message_items: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - emitted_text_start: bool, - emitted_reasoning_start: bool, - rate_limit: Option, -} - -impl SseAccumulator { - pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> Self { - Self { - model: ctx.request.model.clone(), - provider: ctx.provider_name.to_string(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - tool_calls: Vec::new(), - reasoning_items: Vec::new(), - message_items: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - emitted_text_start: false, - emitted_reasoning_start: false, - rate_limit, - } - } - - /// Process a single SSE event and return the corresponding - /// `StreamEvent`(s). - fn process_sse_event( - &mut self, - event_type: Option<&str>, - data: &str, - ) -> Result, Error> { - let mut events = Vec::new(); - - let json: serde_json::Value = match serde_json::from_str(data) { - Ok(v) => v, - Err(_) => return Ok(events), - }; - - // Resolve event type from the `event:` SSE line or from the JSON `type` - // field. - let resolved_type = event_type - .or_else(|| json.get("type").and_then(serde_json::Value::as_str)) - .unwrap_or_default(); - - match resolved_type { - "error" => { - let error = json.get("error").unwrap_or(&json); - return Err(provider_error_from_openai_error_json(error, &self.provider)); - } - "response.created" => self.handle_response_created(&json), - "response.output_text.delta" => self.handle_text_delta(&json, &mut events), - "response.function_call_arguments.delta" => { - self.handle_tool_call_delta(&json, &mut events, "function"); - } - "response.custom_tool_call_input.delta" => { - self.handle_tool_call_delta(&json, &mut events, "custom"); - } - "response.output_item.done" => self.handle_output_item_done(&json, &mut events), - "response.completed" | "response.incomplete" => { - self.handle_response_completed(&json, &mut events); - } - "response.failed" => { - let error = json - .get("response") - .and_then(|response| response.get("error")) - .unwrap_or(&json); - return Err(provider_error_from_openai_error_json(error, &self.provider)); - } - "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => { - if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) { - if !self.emitted_reasoning_start { - self.emitted_reasoning_start = true; - events.push(StreamEvent::ReasoningStart); - } - events.push(StreamEvent::ReasoningDelta { - delta: delta.to_string(), - }); - } - } - // response.reasoning_summary_part.added and other unrecognized - // events are no-ops - _ => {} - } - - Ok(events) - } - - /// Handle `response.created` by extracting the response ID and model. - fn handle_response_created(&mut self, json: &serde_json::Value) { - if let Some(id) = json - .get("response") - .and_then(|r| r.get("id")) - .and_then(serde_json::Value::as_str) - { - self.response_id = id.to_string(); - } - if let Some(model) = json - .get("response") - .and_then(|r| r.get("model")) - .and_then(serde_json::Value::as_str) - { - self.response_model = model.to_string(); - } - } - - /// Handle `response.output_text.delta` by accumulating text and emitting - /// events. - fn handle_text_delta(&mut self, json: &serde_json::Value, events: &mut Vec) { - if let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) { - if !self.emitted_text_start { - self.emitted_text_start = true; - events.push(StreamEvent::TextStart { text_id: None }); - } - self.accumulated_text.push_str(delta); - events.push(StreamEvent::text_delta(delta, None)); - } - } - - /// Handle `response.function_call_arguments.delta` / - /// `response.custom_tool_call_input.delta` by accumulating args and - /// emitting events. - fn handle_tool_call_delta( - &mut self, - json: &serde_json::Value, - events: &mut Vec, - tool_type: &str, - ) { - let Some(delta) = json.get("delta").and_then(serde_json::Value::as_str) else { - return; - }; - - let call_id = json - .get("call_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let item_id = json - .get("item_id") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let lookup_id = if call_id.is_empty() { item_id } else { call_id }; - - let idx = if let Some(idx) = self.tool_calls.iter().position(|tc| tc.id == lookup_id) { - let tc = &mut self.tool_calls[idx]; - if let Some(raw) = &mut tc.raw_arguments { - raw.push_str(delta); - } - // Custom tool input is its raw string; keep `arguments` in sync as - // it accumulates. - if tool_type == "custom" { - if let serde_json::Value::String(args) = &mut tc.arguments { - args.push_str(delta); - } - } - idx - } else { - let name = json - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let mut tc = ToolCall::new( - lookup_id, - name, - if tool_type == "custom" { - serde_json::json!(delta) - } else { - serde_json::json!({}) - }, - ); - tc.tool_type = tool_type.to_string(); - tc.raw_arguments = Some(delta.to_string()); - // Preserve item-level ID (fc_xxx) for Responses API round-trip - if !item_id.is_empty() && item_id != lookup_id { - tc.provider_metadata = Some(serde_json::json!({"id": item_id})); - } - events.push(StreamEvent::ToolCallStart { - tool_call: tc.clone(), - }); - self.tool_calls.push(tc); - self.tool_calls.len() - 1 - }; - - // The delta event carries the call identity, the arguments - // accumulated so far, and this chunk in `raw_arguments`. - let current = &self.tool_calls[idx]; - let mut tool_call = ToolCall::new(&*current.id, &*current.name, current.arguments.clone()); - tool_call.tool_type = tool_type.to_string(); - tool_call.raw_arguments = Some(delta.to_string()); - tool_call - .provider_metadata - .clone_from(¤t.provider_metadata); - - events.push(StreamEvent::ToolCallDelta { tool_call }); - } - - /// Handle `response.output_item.done` for text and function call items. - fn handle_output_item_done(&mut self, json: &serde_json::Value, events: &mut Vec) { - let item = json.get("item").unwrap_or(json); - let item_type = item.get("type").and_then(serde_json::Value::as_str); - - match item_type { - Some("reasoning") => { - if self.emitted_reasoning_start { - self.emitted_reasoning_start = false; - events.push(StreamEvent::ReasoningEnd); - } - self.reasoning_items.push(item.clone()); - } - Some("message") => { - if self.emitted_text_start { - events.push(StreamEvent::TextEnd { text_id: None }); - self.emitted_text_start = false; - } - self.message_items.push(item.clone()); - } - Some(t @ ("function_call" | "custom_tool_call")) => { - let tc = tool_call_from_item(item, t == "custom_tool_call"); - - if let Some(existing) = self.tool_calls.iter_mut().find(|c| c.id == tc.id) { - existing.name.clone_from(&tc.name); - existing.tool_type.clone_from(&tc.tool_type); - existing.arguments = tc.arguments.clone(); - existing.raw_arguments.clone_from(&tc.raw_arguments); - existing.provider_metadata.clone_from(&tc.provider_metadata); - } else { - self.tool_calls.push(tc.clone()); - } - - events.push(StreamEvent::ToolCallEnd { tool_call: tc }); - } - _ => {} - } - } - - /// Handle `response.completed` / `response.incomplete` by extracting usage - /// and building the final response. - fn handle_response_completed( - &mut self, - json: &serde_json::Value, - events: &mut Vec, - ) { - let response_data = json.get("response").unwrap_or(json); - - if let Some(usage_data) = response_data.get("usage") { - if let Ok(u) = ApiUsage::deserialize(usage_data) { - self.usage = token_counts_from_api_usage(Some(&u)); - } - } - - if let Some(id) = response_data.get("id").and_then(serde_json::Value::as_str) { - self.response_id = id.to_string(); - } - if let Some(model) = response_data - .get("model") - .and_then(serde_json::Value::as_str) - { - self.response_model = model.to_string(); - } - - let status = response_data - .get("status") - .and_then(serde_json::Value::as_str); - let has_tool_calls = !self.tool_calls.is_empty(); - self.finish_reason = map_finish_reason(status, has_tool_calls); - - let mut content_parts = Vec::new(); - // Reasoning items must precede function calls for Responses API - // round-trip - for item in std::mem::take(&mut self.reasoning_items) { - content_parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - }); - } - // Preserve full message output items for Responses API round-tripping - for item in std::mem::take(&mut self.message_items) { - content_parts.push(ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: item, - }); - } - if !self.accumulated_text.is_empty() { - content_parts.push(ContentPart::text(std::mem::take( - &mut self.accumulated_text, - ))); - } - for tc in std::mem::take(&mut self.tool_calls) { - // Skip tool calls with empty names (e.g. model-internal items) - if tc.name.is_empty() { - continue; - } - content_parts.push(ContentPart::ToolCall(tc)); - } - - let model = if self.response_model.is_empty() { - self.model.clone() - } else { - self.response_model.clone() - }; - - let response = Response { - id: self.response_id.clone(), - model, - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: Some(response_data.clone()), - warnings: vec![], - rate_limit: self.rate_limit.clone(), - cost_usd: None, - cost_source: None, - }; - - events.push(StreamEvent::finish( - self.finish_reason.clone(), - self.usage.clone(), - response, - )); - } -} - -impl StreamDecoder for SseAccumulator { - fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { - self.process_sse_event(ev.event, ev.data) - } - - fn finish(&mut self) -> Vec { - // The Responses API finishes via `response.completed`/`.incomplete`; - // nothing is synthesized at byte-stream end. - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build an accumulator without threading a `CodecCtx`/`Request`: the test - /// module sees the private fields, so the few that matter are set - /// directly. - fn empty_accumulator() -> SseAccumulator { - SseAccumulator { - model: String::new(), - provider: "openai".to_string(), - response_id: String::new(), - response_model: String::new(), - accumulated_text: String::new(), - tool_calls: Vec::new(), - reasoning_items: Vec::new(), - message_items: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - emitted_text_start: false, - emitted_reasoning_start: false, - rate_limit: None, - } - } - - fn on_event( - acc: &mut SseAccumulator, - event: Option<&str>, - data: &str, - ) -> Result, Error> { - acc.on_event(RawEvent { event, data }) - } - - #[test] - fn token_counts_disjoint_with_cache_and_reasoning() { - let mut acc = empty_accumulator(); - let body = serde_json::json!({ - "response": { - "id": "resp_test", - "model": "gpt-5", - "output": [], - "status": "completed", - "usage": { - "input_tokens": 200, - "input_tokens_details": { "cached_tokens": 180 }, - "output_tokens": 500, - "output_tokens_details": { "reasoning_tokens": 300 }, - "total_tokens": 700 - } - } - }); - let mut events = Vec::new(); - - acc.handle_response_completed(&body, &mut events); - - assert_eq!(acc.usage.input_tokens, 20); - assert_eq!(acc.usage.cache_read_tokens, 180); - assert_eq!(acc.usage.output_tokens, 200); - assert_eq!(acc.usage.reasoning_tokens, 300); - assert_eq!(acc.usage.cache_write_tokens, 0); - assert_eq!(acc.usage.total_tokens(), 700); - } - - #[test] - fn custom_tool_call_streaming_delta_accumulates_raw_input() { - let mut acc = empty_accumulator(); - let first = r#"{ - "type": "response.custom_tool_call_input.delta", - "item_id": "ctc_abc", - "call_id": "call_001", - "delta": "*** Begin" - }"#; - let second = r#"{ - "type": "response.custom_tool_call_input.delta", - "item_id": "ctc_abc", - "call_id": "call_001", - "delta": " Patch\n" - }"#; - - let first_events = on_event( - &mut acc, - Some("response.custom_tool_call_input.delta"), - first, - ) - .expect("first custom delta should parse"); - let second_events = on_event( - &mut acc, - Some("response.custom_tool_call_input.delta"), - second, - ) - .expect("second custom delta should parse"); - - assert!(matches!( - first_events.iter().find(|event| matches!(event, StreamEvent::ToolCallStart { .. })), - Some(StreamEvent::ToolCallStart { tool_call }) - if tool_call.id == "call_001" && tool_call.tool_type == "custom" - )); - assert!(matches!( - second_events.last(), - Some(StreamEvent::ToolCallDelta { tool_call }) - if tool_call.raw_arguments.as_deref() == Some(" Patch\n") - && tool_call.tool_type == "custom" - )); - assert_eq!( - acc.tool_calls[0].raw_arguments.as_deref(), - Some("*** Begin Patch\n") - ); - } - - #[test] - fn custom_tool_call_output_item_done_emits_tool_call_end() { - let mut acc = empty_accumulator(); - let patch = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"; - let data = serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "id": "ctc_abc", - "call_id": "call_001", - "name": "apply_patch", - "input": patch, - } - }); - - let events = on_event( - &mut acc, - Some("response.output_item.done"), - &data.to_string(), - ) - .expect("custom output item should parse"); - - assert!(matches!( - events.last(), - Some(StreamEvent::ToolCallEnd { tool_call }) - if tool_call.id == "call_001" - && tool_call.name == "apply_patch" - && tool_call.tool_type == "custom" - && tool_call.raw_arguments.as_deref() == Some(patch) - )); - } - - #[test] - fn error_event_with_insufficient_quota_returns_provider_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "insufficient_quota", - "code": "insufficient_quota", - "message": "You exceeded your current quota.", - "param": null - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert!(detail.message.contains("exceeded your current quota")); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - assert!(detail.raw.is_some()); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_classifies_on_type_when_code_absent() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "insufficient_quota", - "message": "You exceeded your current quota." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn response_failed_event_with_server_error_returns_provider_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "response.failed", - "response": { - "status": "failed", - "error": { - "type": "server_error", - "code": "server_error", - "message": "The server had an error while processing your request." - } - } - }"#; - - let err = on_event(&mut acc, Some("response.failed"), data) - .expect_err("response.failed should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert!(detail.message.contains("server had an error")); - assert_eq!(detail.error_code.as_deref(), Some("server_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn response_incomplete_preserves_partial_text() { - let mut acc = empty_accumulator(); - - on_event( - &mut acc, - Some("response.created"), - r#"{"type":"response.created","response":{"id":"resp_123","model":"gpt-5.4"}}"#, - ) - .expect("created event should parse"); - on_event( - &mut acc, - Some("response.output_text.delta"), - r#"{"type":"response.output_text.delta","delta":"Hel"}"#, - ) - .expect("first delta should parse"); - on_event( - &mut acc, - Some("response.output_text.delta"), - r#"{"type":"response.output_text.delta","delta":"lo"}"#, - ) - .expect("second delta should parse"); - - let events = on_event( - &mut acc, - Some("response.incomplete"), - r#"{ - "type": "response.incomplete", - "response": { - "id": "resp_123", - "model": "gpt-5.4", - "status": "incomplete" - } - }"#, - ) - .expect("incomplete response should finish normally"); - - let finish = events - .last() - .expect("incomplete response should emit finish"); - match finish { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(finish_reason.clone(), FinishReason::Length); - assert_eq!(response.text(), "Hello"); - } - other => panic!("expected finish event, got {other:?}"), - } - } - - #[test] - fn error_event_with_invalid_api_key_returns_authentication_error() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "invalid_api_key", - "code": "invalid_api_key", - "message": "Incorrect API key provided." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Authentication); - assert_eq!(detail.error_code.as_deref(), Some("invalid_api_key")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_rate_limit_error_returns_rate_limit() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "rate_limit_error", - "message": "Too many requests." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::RateLimit); - assert_eq!(detail.error_code.as_deref(), Some("rate_limit_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_unknown_invalid_prefix_returns_invalid_request() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "invalid_prompt", - "code": "invalid_prompt", - "message": "Prompt is invalid." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::InvalidRequest); - assert_eq!(detail.error_code.as_deref(), Some("invalid_prompt")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn error_event_with_unknown_code_falls_back_to_server_with_message() { - let mut acc = empty_accumulator(); - let data = r#"{ - "type": "error", - "error": { - "type": "unexpected_stream_failure", - "code": "unexpected_stream_failure", - "message": "Unexpected stream failure." - } - }"#; - - let err = on_event(&mut acc, Some("error"), data) - .expect_err("error event should fail the stream"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert_eq!(detail.message, "Unexpected stream failure."); - assert_eq!( - detail.error_code.as_deref(), - Some("unexpected_stream_failure") - ); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn reasoning_summary_delta_emits_reasoning_events() { - let mut acc = empty_accumulator(); - let data = r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me think"}"#; - let events = on_event( - &mut acc, - Some("response.reasoning_summary_text.delta"), - data, - ) - .expect("reasoning summary delta should parse"); - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::ReasoningStart)); - assert!( - matches!(events[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Let me think") - ); - } - - #[test] - fn reasoning_text_delta_emits_reasoning_events() { - let mut acc = empty_accumulator(); - - // First delta: should emit ReasoningStart + ReasoningDelta - let data1 = r#"{"type":"response.reasoning_text.delta","delta":"Step 1"}"#; - let events1 = on_event(&mut acc, Some("response.reasoning_text.delta"), data1) - .expect("first reasoning delta should parse"); - assert_eq!(events1.len(), 2); - assert!(matches!(events1[0], StreamEvent::ReasoningStart)); - assert!( - matches!(events1[1], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 1") - ); - - // Second delta: should NOT emit duplicate ReasoningStart - let data2 = r#"{"type":"response.reasoning_text.delta","delta":"Step 2"}"#; - let events2 = on_event(&mut acc, Some("response.reasoning_text.delta"), data2) - .expect("second reasoning delta should parse"); - assert_eq!(events2.len(), 1); - assert!( - matches!(events2[0], StreamEvent::ReasoningDelta { ref delta } if delta == "Step 2") - ); - } - - #[test] - fn reasoning_end_emitted_on_item_done() { - let mut acc = empty_accumulator(); - acc.emitted_reasoning_start = true; - - let data = r#"{"item":{"type":"reasoning","id":"rs_abc","summary":[]}}"#; - let events = on_event(&mut acc, Some("response.output_item.done"), data) - .expect("output item done should parse"); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], StreamEvent::ReasoningEnd)); - assert!(!acc.emitted_reasoning_start); - assert_eq!(acc.reasoning_items.len(), 1); - } -} diff --git a/lib/components/fabro-llm/src/codec/openai_responses/wire.rs b/lib/components/fabro-llm/src/codec/openai_responses/wire.rs deleted file mode 100644 index f1c1e82e6..000000000 --- a/lib/components/fabro-llm/src/codec/openai_responses/wire.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Serde types mirroring the OpenAI Responses API wire shapes. - -#[derive(serde::Serialize)] -pub(super) struct ApiRequest { - pub model: String, - pub input: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stop: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option>, - pub store: bool, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub include: Vec, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub stream: bool, -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -pub(super) struct ApiResponse { - pub id: String, - pub model: Option, - pub output: Vec, - pub status: Option, - pub usage: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct InputTokensResponse { - pub input_tokens: i64, - pub object: String, -} - -#[derive(serde::Deserialize)] -pub(super) struct ApiUsage { - pub input_tokens: i64, - pub output_tokens: i64, - pub output_tokens_details: Option, - pub input_tokens_details: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct OutputTokenDetails { - pub reasoning_tokens: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct InputTokenDetails { - pub cached_tokens: Option, -} diff --git a/lib/components/fabro-llm/src/cost.rs b/lib/components/fabro-llm/src/cost.rs deleted file mode 100644 index 898dcb362..000000000 --- a/lib/components/fabro-llm/src/cost.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Catalog-derived cost estimation for completion responses. -//! -//! The estimate is a thin wrapper over the catalog's billing machinery -//! ([`Catalog::price_tokens`]), which is billing-policy- and speed-aware. -//! Costs are stamped onto responses by the [`Client`](crate::Client) as a -//! post-decode step, so codecs stay wire-translation-only and every -//! registered adapter (including custom ones) gets the same treatment. - -use fabro_model::billing::{ModelRef, Speed, TokenCounts}; -use fabro_model::{Catalog, ProviderId}; - -use crate::types::{CostSource, Response}; - -/// Estimate the USD cost of a completion from the catalog's per-token -/// pricing for the model. Returns `None` if the catalog is absent, the -/// model is not in the catalog, or the model has no pricing. -#[must_use] -pub(crate) fn estimate_cost_usd( - catalog: Option<&Catalog>, - provider: &str, - model: &str, - tokens: &TokenCounts, - speed: Option, -) -> Option { - let catalog = catalog?; - // The billing machinery compares ModelRefs against the catalog's - // canonical identity, so resolve model aliases and provider names first. - let provider = catalog.provider(&ProviderId::new(provider))?; - let model = catalog.get_on_provider(&provider.id, model)?; - let model_ref = ModelRef { - provider: provider.id.clone(), - model_id: model.id.clone(), - speed, - }; - let micros = catalog.price_tokens(&model_ref, tokens)?; - #[expect( - clippy::cast_precision_loss, - reason = "micros fit comfortably in f64 for any realistic completion cost" - )] - Some(micros as f64 / 1_000_000.0) -} - -/// Stamp a catalog-estimated cost onto `response` unless the provider -/// already supplied one (providers that return authoritative billing data -/// in-band set [`CostSource::Authoritative`] directly and take precedence). -/// `model` is the request's model id or alias (the catalog lookup resolves -/// aliases); the response's provider name selects the billing policy. -pub(crate) fn apply_estimated_cost( - catalog: Option<&Catalog>, - provider: &str, - model: &str, - speed: Option, - response: &mut Response, -) { - if response.cost_usd.is_some() { - return; - } - let estimate = estimate_cost_usd(catalog, provider, model, &response.usage, speed); - response.cost_usd = estimate; - response.cost_source = estimate.map(|_| CostSource::Estimated); -} - -#[cfg(test)] -mod tests { - use fabro_model::catalog::LlmCatalogSettings; - - use super::*; - use crate::types::{FinishReason, Message}; - - /// Single-provider catalog with one `gpt-test` model (alias `gpt-alias`) - /// and the given `[models."gpt-test".costs]` block (empty for unpriced). - fn test_catalog(costs_block: &str) -> Catalog { - let toml = format!( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -agent_profile = "openai" - -[models."gpt-test"] -provider = "openai" -display_name = "GPT Test" -family = "gpt" -default = true -aliases = ["gpt-alias"] - -[models."gpt-test".limits] -context_window = 200000 -max_output = 4096 - -[models."gpt-test".features] -tools = true -vision = false -reasoning = false - -{costs_block} -"# - ); - let settings: LlmCatalogSettings = toml::from_str(&toml).unwrap(); - Catalog::from_settings(&settings).unwrap() - } - - fn priced_catalog(input_cost_per_mtok: f64, output_cost_per_mtok: f64) -> Catalog { - test_catalog(&format!( - r#" -[models."gpt-test".costs] -input_cost_per_mtok = {input_cost_per_mtok} -output_cost_per_mtok = {output_cost_per_mtok} -"# - )) - } - - fn response_with_usage(tokens: TokenCounts) -> Response { - Response { - id: "resp".to_string(), - model: "gpt-test".to_string(), - provider: "openai".to_string(), - message: Message::assistant("hi"), - finish_reason: FinishReason::Stop, - usage: tokens, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } - } - - #[test] - fn returns_none_when_catalog_is_none() { - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - assert_eq!( - estimate_cost_usd(None, "openai", "gpt-test", &tokens, None), - None - ); - } - - #[test] - fn returns_estimated_when_model_priced() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, // 1M tokens at $1/Mtok = $1.00 - output_tokens: 500_000, // 500k tokens at $2/Mtok = $1.00 - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None) - .expect("cost should be Some"); - assert!((cost - 2.0).abs() < 1e-9, "expected ~$2.00, got {cost}"); - } - - #[test] - fn resolves_model_aliases() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-alias", &tokens, None); - assert!(cost.is_some()); - } - - #[test] - fn returns_none_when_model_missing_from_catalog() { - let catalog = priced_catalog(1.0, 2.0); - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "nonexistent-model", &tokens, None); - assert_eq!(cost, None); - } - - #[test] - fn returns_none_when_model_has_no_pricing() { - let catalog = test_catalog(""); - let tokens = TokenCounts { - input_tokens: 1000, - output_tokens: 500, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None); - assert_eq!(cost, None); - } - - #[test] - fn micros_to_usd_conversion_is_exact_for_integer_amounts() { - // input_cost_per_mtok = 1.5 USD; 1M input tokens with no output - // yields exactly 1_500_000 micros = $1.50 (representable as f64). - let catalog = priced_catalog(1.5, 0.0); - let tokens = TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }; - let cost = estimate_cost_usd(Some(&catalog), "openai", "gpt-test", &tokens, None) - .expect("cost should be Some"); - assert!( - (cost - 1.5).abs() < f64::EPSILON, - "expected $1.50 exact, got {cost}" - ); - } - - #[test] - fn apply_estimated_cost_stamps_estimate() { - let catalog = priced_catalog(1.0, 2.0); - let mut response = response_with_usage(TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }); - - apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - assert!(response.cost_usd.is_some()); - } - - #[test] - fn apply_estimated_cost_leaves_source_unset_without_estimate() { - let mut response = response_with_usage(TokenCounts::default()); - - apply_estimated_cost(None, "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_usd, None); - assert_eq!(response.cost_source, None); - } - - #[test] - fn apply_estimated_cost_keeps_existing_cost() { - let catalog = priced_catalog(1.0, 2.0); - let mut response = response_with_usage(TokenCounts { - input_tokens: 1_000_000, - output_tokens: 0, - ..TokenCounts::default() - }); - response.cost_usd = Some(0.42); - response.cost_source = Some(CostSource::Authoritative); - - apply_estimated_cost(Some(&catalog), "openai", "gpt-test", None, &mut response); - - assert_eq!(response.cost_usd, Some(0.42)); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); - } -} diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index dd9a98347..05eec0e1c 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -1,1437 +1,325 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ProviderErrorKind { - Authentication, - AccessDenied, - NotFound, - InvalidRequest, - RateLimit, - Server, - ContentFilter, - ContextLength, - QuotaExceeded, -} +//! Classification of lithos errors for Fabro's retry, failover, and failure +//! signature policies, plus the stored form of a failure. +//! +//! lithos's live [`Error`] carries a source chain and is therefore neither +//! `Clone` nor serializable. Fabro records failures in events and agent +//! errors, so it works with [`LlmError`], a thin wrapper over lithos's own +//! [`ErrorData`] projection. Every policy here reads through [`ErrorFacts`] +//! and so applies to both forms. -impl std::fmt::Display for ProviderErrorKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Authentication => write!(f, "Authentication error for"), - Self::AccessDenied => write!(f, "Access denied by"), - Self::NotFound => write!(f, "Not found on"), - Self::InvalidRequest => write!(f, "Invalid request to"), - Self::RateLimit => write!(f, "Rate limited by"), - Self::Server => write!(f, "Server error from"), - Self::ContentFilter => write!(f, "Content filtered by"), - Self::ContextLength => write!(f, "Context length exceeded for"), - Self::QuotaExceeded => write!(f, "Quota exceeded for"), - } +use std::fmt; +use std::time::Duration; + +use fabro_types::ProviderId; +use lithos_llm::types::{Error, ErrorData, ErrorKind, RetryClassification}; +use serde::{Deserialize, Serialize}; + +/// The facts Fabro's policies read from an LLM failure. +pub trait ErrorFacts { + fn kind(&self) -> ErrorKind; + fn message(&self) -> &str; + fn provider(&self) -> Option<&ProviderId>; + fn provider_code(&self) -> Option<&str>; + fn status(&self) -> Option; + fn retry_classification(&self) -> RetryClassification; + + /// The delay the classification advises, when repeating is safe after + /// a wait. + fn retry_after(&self) -> Option { + self.retry_classification().delay() } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ProviderErrorDetail { - pub message: String, - pub provider: String, - pub status_code: Option, - pub error_code: Option, - pub retry_after: Option, - pub raw: Option, -} +impl ErrorFacts for Error { + fn kind(&self) -> ErrorKind { + Self::kind(self) + } -impl ProviderErrorDetail { - pub fn new(message: impl Into, provider: impl Into) -> Self { - Self { - message: message.into(), - provider: provider.into(), - status_code: None, - error_code: None, - retry_after: None, - raw: None, - } + fn message(&self) -> &str { + Self::message(self) + } + + fn provider(&self) -> Option<&ProviderId> { + Self::provider(self) + } + + fn provider_code(&self) -> Option<&str> { + Self::provider_code(self) + } + + fn status(&self) -> Option { + Self::status(self) + } + + fn retry_classification(&self) -> RetryClassification { + Self::retry_classification(self) } } -use std::sync::Arc; +impl ErrorFacts for ErrorData { + fn kind(&self) -> ErrorKind { + self.kind.clone() + } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, thiserror::Error)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Error { - #[error("{kind} {}: {}", .detail.provider, .detail.message)] - Provider { - kind: ProviderErrorKind, - detail: Box, - }, + fn message(&self) -> &str { + &self.message + } - #[error("Request timed out: {message}")] - RequestTimeout { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, + fn provider(&self) -> Option<&ProviderId> { + self.provider.as_ref() + } - #[error("Request interrupted: {message}")] - Interrupt { message: String }, + fn provider_code(&self) -> Option<&str> { + self.provider_code.as_deref() + } - #[error("Network error: {message}")] - Network { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, + fn status(&self) -> Option { + self.status + } - #[error("Stream error: {message}")] - Stream { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, - - #[error("Invalid tool call: {message}")] - InvalidToolCall { message: String }, - - #[error("No object generated: {message}")] - NoObjectGenerated { message: String }, - - #[error("Invalid request: {message}")] - InvalidRequest { message: String }, - - #[error("Configuration error: {message}")] - Configuration { - message: String, - #[source] - #[serde(skip)] - source: Option>, - }, - - #[error("Unsupported tool choice: {message}")] - UnsupportedToolChoice { message: String }, + fn retry_classification(&self) -> RetryClassification { + self.retry + } } -impl Error { - pub fn network( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Network { - message: message.into(), - source: Some(Arc::new(source)), - } - } +/// A cloneable, serializable LLM failure. +/// +/// This is lithos's [`ErrorData`] projection with Fabro's policy helpers +/// attached. It is what agent errors, run events, and API responses carry; +/// the live [`Error`] converts into it at the boundary where a failure stops +/// being handled and starts being recorded. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LlmError(Box); - pub fn request_timeout( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::RequestTimeout { - message: message.into(), - source: Some(Arc::new(source)), - } - } - - pub fn stream_error( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Stream { - message: message.into(), - source: Some(Arc::new(source)), - } - } - - pub fn configuration_error( - message: impl Into, - source: impl std::error::Error + Send + Sync + 'static, - ) -> Self { - Self::Configuration { - message: message.into(), - source: Some(Arc::new(source)), - } +impl LlmError { + /// A failure Fabro itself raises, never retried. + #[must_use] + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self::from(Error::new(kind, message)) } #[must_use] - pub const fn retryable(&self) -> bool { - match self { - Self::Provider { kind, .. } => !matches!( - kind, - ProviderErrorKind::Authentication - | ProviderErrorKind::AccessDenied - | ProviderErrorKind::NotFound - | ProviderErrorKind::InvalidRequest - | ProviderErrorKind::ContextLength - | ProviderErrorKind::QuotaExceeded - | ProviderErrorKind::ContentFilter - ), - Self::InvalidToolCall { .. } - | Self::NoObjectGenerated { .. } - | Self::Interrupt { .. } - | Self::InvalidRequest { .. } - | Self::Configuration { .. } - | Self::UnsupportedToolChoice { .. } - | Self::RequestTimeout { .. } => false, - _ => true, - } + pub fn data(&self) -> &ErrorData { + &self.0 } #[must_use] - pub const fn retry_after(&self) -> Option { - match self { - Self::Provider { detail, .. } => detail.retry_after, - _ => None, - } + pub fn into_data(self) -> ErrorData { + *self.0 + } + + /// The immediate source of the failure, rendered as text. + #[must_use] + pub fn source_message(&self) -> Option<&str> { + self.0.source_message.as_deref() + } + + /// The provider's advised wait, whatever the error kind. + #[must_use] + pub fn provider_retry_after(&self) -> Option { + self.0 + .provider_retry_after_millis + .map(Duration::from_millis) } #[must_use] - pub const fn status_code(&self) -> Option { - match self { - Self::Provider { detail, .. } => detail.status_code, - _ => None, - } + pub fn is_retryable(&self) -> bool { + is_retryable(self) } #[must_use] - pub const fn provider_kind(&self) -> Option { - match self { - Self::Provider { kind, .. } => Some(*kind), - _ => None, - } + pub fn is_auth_error(&self) -> bool { + is_auth_error(self) } #[must_use] - pub fn provider_name(&self) -> &str { - match self { - Self::Provider { detail, .. } => &detail.provider, - _ => "unknown", - } + pub fn is_cancelled(&self) -> bool { + is_cancelled(self) } - /// Whether this error is eligible for provider-level failover. - /// - /// Includes everything that is `retryable()` (transient errors good for - /// same-provider retry), provider-local availability failures, and - /// `QuotaExceeded`. A different provider has independent credentials, - /// access policy, model inventory, and quota. #[must_use] pub fn failover_eligible(&self) -> bool { - if self.retryable() { - return true; - } - matches!( - self, - Self::Provider { - kind: ProviderErrorKind::Authentication - | ProviderErrorKind::AccessDenied - | ProviderErrorKind::NotFound - | ProviderErrorKind::QuotaExceeded, - .. - } | Self::RequestTimeout { .. } - ) || self.refusal_content_filter() - } - - fn refusal_content_filter(&self) -> bool { - matches!( - self, - Self::Provider { - kind: ProviderErrorKind::ContentFilter, - detail, - } if detail.error_code.as_deref() == Some("refusal") - ) + failover_eligible(self) } #[must_use] pub fn failure_signature_hint(&self) -> String { - let provider = self.provider_name(); - match self { - Self::Provider { kind, .. } => { - let category = if self.retryable() { - "api_transient" - } else { - "api_deterministic" - }; - let detail = match kind { - ProviderErrorKind::RateLimit => "rate_limited", - ProviderErrorKind::Server => "server_error", - ProviderErrorKind::ContextLength => "context_length", - ProviderErrorKind::QuotaExceeded => "quota_exceeded", - ProviderErrorKind::Authentication => "authentication", - ProviderErrorKind::AccessDenied => "access_denied", - ProviderErrorKind::NotFound => "not_found", - ProviderErrorKind::InvalidRequest => "invalid_request", - ProviderErrorKind::ContentFilter => "content_filter", - }; - format!("{category}|{provider}|{detail}") - } - Self::RequestTimeout { .. } => format!("api_transient|{provider}|timeout"), - Self::Network { .. } => format!("api_transient|{provider}|network"), - Self::Stream { .. } => format!("api_transient|{provider}|stream"), - Self::Interrupt { .. } => format!("api_canceled|{provider}|interrupt"), - Self::Configuration { .. } => format!("api_deterministic|{provider}|configuration"), - Self::InvalidToolCall { .. } => { - format!("api_deterministic|{provider}|invalid_tool_call") - } - Self::NoObjectGenerated { .. } => { - format!("api_deterministic|{provider}|no_object") - } - Self::InvalidRequest { .. } => { - format!("api_deterministic|{provider}|invalid_request") - } - Self::UnsupportedToolChoice { .. } => { - format!("api_deterministic|{provider}|unsupported_tool_choice") - } - } + failure_signature_hint(self) } } -/// Provider error code to error kind mapping, for the codes that say more -/// than the transport-level status or stream event type does. +impl ErrorFacts for LlmError { + fn kind(&self) -> ErrorKind { + self.0.kind.clone() + } + + fn message(&self) -> &str { + &self.0.message + } + + fn provider(&self) -> Option<&ProviderId> { + self.0.provider.as_ref() + } + + fn provider_code(&self) -> Option<&str> { + self.0.provider_code.as_deref() + } + + fn status(&self) -> Option { + self.0.status + } + + fn retry_classification(&self) -> RetryClassification { + self.0.retry + } +} + +impl fmt::Display for LlmError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0.message) + } +} + +impl std::error::Error for LlmError {} + +impl From for LlmError { + fn from(error: Error) -> Self { + Self(Box::new(error.data())) + } +} + +impl From<&Error> for LlmError { + fn from(error: &Error) -> Self { + Self(Box::new(error.data())) + } +} + +impl From for LlmError { + fn from(data: ErrorData) -> Self { + Self(Box::new(data)) + } +} + +/// Whether repeating the same call on the same provider may succeed. +#[must_use] +pub fn is_retryable(error: &E) -> bool { + !matches!(error.retry_classification(), RetryClassification::Never) +} + +/// Whether the failure came from a credential problem. +#[must_use] +pub fn is_auth_error(error: &E) -> bool { + matches!( + error.kind(), + ErrorKind::Authentication | ErrorKind::AccessDenied + ) +} + +/// Whether the call was cancelled by Fabro rather than failed by the provider. +#[must_use] +pub fn is_cancelled(error: &E) -> bool { + error.kind() == ErrorKind::Cancelled +} + +/// Whether another provider is worth trying. /// -/// Returns `None` when the code adds nothing, so each caller keeps its own -/// default: the stream decoders treat an unrecognized code as transient, -/// while [`error_from_status_code`] falls back to the HTTP status. -/// -/// Every dialect classifies through this one table so a code such as -/// `insufficient_quota` means the same thing whether it arrives in an HTTP -/// error body or in a mid-stream error event. +/// Everything retryable qualifies, plus failures that are local to this +/// provider: credentials, access policy, model inventory, quota, and a +/// provider that ran out of time. A different provider has its own. #[must_use] -pub(crate) fn kind_from_error_code(code: &str) -> Option { - Some(match code { - // Out of credit, or over a billing cap. Distinct from RateLimit: - // backoff never clears it, but another provider has its own quota. - "insufficient_quota" | "billing_hard_limit_reached" | "exceeded_current_quota_error" => { - ProviderErrorKind::QuotaExceeded - } - "rate_limit_error" | "rate_limit_exceeded" | "too_many_requests" => { - ProviderErrorKind::RateLimit - } - "authentication_error" | "invalid_api_key" | "invalid_authentication" => { - ProviderErrorKind::Authentication - } - "access_denied" | "account_deactivated" | "permission_denied" | "permission_error" => { - ProviderErrorKind::AccessDenied - } - "content_filter" | "content_policy_violation" => ProviderErrorKind::ContentFilter, - // `request_too_large` is anthropic's oversized-input code, so it has - // to precede the `_too_large` suffix rule below. - "context_length_exceeded" | "request_too_large" => ProviderErrorKind::ContextLength, - "server_error" | "internal_error" | "service_unavailable" | "engine_overloaded" => { - ProviderErrorKind::Server - } - c if c == "not_found_error" || c.ends_with("_not_found") => ProviderErrorKind::NotFound, - c if c.starts_with("invalid_") - || c.starts_with("unsupported_") - || c.ends_with("_too_large") - || c.ends_with("_too_long") => - { - ProviderErrorKind::InvalidRequest - } - _ => return None, - }) -} - -/// HTTP status code to error type mapping (Section 6.4). -#[must_use] -pub fn error_from_status_code( - status_code: u16, - message: String, - provider: String, - error_code: Option, - raw: Option, - retry_after: Option, -) -> Error { - let detail = ProviderErrorDetail { - message, - provider, - status_code: Some(status_code), - error_code, - retry_after, - raw, - }; - - let code_kind = detail.error_code.as_deref().and_then(kind_from_error_code); - - // Check specific status codes first -- these always map to their designated - // error types - let kind = match status_code { - 401 => ProviderErrorKind::Authentication, - // A 412 is never about the request: no LLM request carries - // conditional-request preconditions. Fireworks documents it as - // "Account is suspended or there's an issue with account status", - // also emitted for a LoRA model that failed to load - // (https://docs.fireworks.ai/guides/inference-error-codes). The same - // family as `account_deactivated`: deterministic here, but another - // provider has independent billing and model inventory. - 403 | 412 => ProviderErrorKind::AccessDenied, - 404 => ProviderErrorKind::NotFound, - 408 => { - return Error::RequestTimeout { - message: detail.message, - source: None, - }; - } - 413 => ProviderErrorKind::ContextLength, - // A 429 means rate limited unless the body reports a spent quota, - // which retrying will never clear. - 429 if code_kind == Some(ProviderErrorKind::QuotaExceeded) => { - ProviderErrorKind::QuotaExceeded - } - 429 => ProviderErrorKind::RateLimit, - 500..=599 => ProviderErrorKind::Server, - // For ambiguous status codes (400, 422, etc.), the provider's error - // code is the better signal; fall back to the message only without one - _ => code_kind.unwrap_or_else(|| { - let lower_msg = detail.message.to_lowercase(); - if lower_msg.contains("not found") || lower_msg.contains("does not exist") { - ProviderErrorKind::NotFound - } else if lower_msg.contains("unauthorized") || lower_msg.contains("invalid key") { - ProviderErrorKind::Authentication - } else if lower_msg.contains("context length") || lower_msg.contains("too many tokens") - { - ProviderErrorKind::ContextLength - } else if lower_msg.contains("content filter") || lower_msg.contains("safety") { - ProviderErrorKind::ContentFilter - } else { - ProviderErrorKind::InvalidRequest - } - }), - }; - - Error::Provider { - kind, - detail: Box::new(detail), +pub fn failover_eligible(error: &E) -> bool { + if is_retryable(error) { + return true; } + matches!( + error.kind(), + ErrorKind::Authentication + | ErrorKind::AccessDenied + | ErrorKind::NotFound + | ErrorKind::QuotaExceeded + | ErrorKind::RateLimit + | ErrorKind::Server + | ErrorKind::Network + | ErrorKind::Timeout + | ErrorKind::StreamDecode + ) || (error.kind() == ErrorKind::ContentFilter && error.provider_code() == Some("refusal")) } -/// gRPC status code to error type mapping (Section 6.4, for Gemini). +/// A stable `category|provider|detail` string for loop and restart detection. #[must_use] -pub fn error_from_grpc_status( - grpc_code: &str, - message: String, - provider: String, - error_code: Option, - raw: Option, - retry_after: Option, -) -> Error { - let detail = ProviderErrorDetail { - message, - provider, - status_code: None, - error_code, - retry_after, - raw, +pub fn failure_signature_hint(error: &E) -> String { + let provider = error.provider().map_or("unknown", ProviderId::as_str); + let category = match error.kind() { + ErrorKind::Cancelled => "api_canceled", + _ if is_retryable(error) => "api_transient", + _ => "api_deterministic", }; - - let kind = match grpc_code { - "NOT_FOUND" => ProviderErrorKind::NotFound, - "INVALID_ARGUMENT" => ProviderErrorKind::InvalidRequest, - "UNAUTHENTICATED" => ProviderErrorKind::Authentication, - "PERMISSION_DENIED" => ProviderErrorKind::AccessDenied, - "RESOURCE_EXHAUSTED" => ProviderErrorKind::RateLimit, - "DEADLINE_EXCEEDED" => { - return Error::RequestTimeout { - message: detail.message, - source: None, - }; - } - _ => ProviderErrorKind::Server, - }; - - Error::Provider { - kind, - detail: Box::new(detail), - } + let detail = error.kind().as_str().to_string(); + format!("{category}|{provider}|{detail}") } -pub type Result = std::result::Result; - #[cfg(test)] mod tests { - use std::error::Error as _; - use super::*; - #[test] - fn retryable_classification() { - let auth_err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "openai") - }), - }; - assert!(!auth_err.retryable()); - - let rate_err = Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(2.0), - ..ProviderErrorDetail::new("too fast", "openai") - }), - }; - assert!(rate_err.retryable()); - assert_eq!(rate_err.retry_after(), Some(2.0)); - - let server_err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("internal error", "anthropic") - }), - }; - assert!(server_err.retryable()); - - let timeout = Error::RequestTimeout { - message: "timed out".into(), - source: None, - }; - assert!(!timeout.retryable()); - - let network = Error::Network { - message: "connection refused".into(), - source: None, - }; - assert!(network.retryable()); - - let config = Error::Configuration { - message: "missing provider".into(), - source: None, - }; - assert!(!config.retryable()); + fn error(kind: ErrorKind) -> Error { + Error::new(kind, "boom").with_provider(ProviderId::new("openai")) } #[test] - fn non_retryable_provider_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - let access_denied = Error::Provider { - kind: ProviderErrorKind::AccessDenied, - detail: detail(), - }; - assert!(!access_denied.retryable()); - - let not_found = Error::Provider { - kind: ProviderErrorKind::NotFound, - detail: detail(), - }; - assert!(!not_found.retryable()); - - let invalid_req = Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: detail(), - }; - assert!(!invalid_req.retryable()); - - let ctx_length = Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: detail(), - }; - assert!(!ctx_length.retryable()); - - let quota = Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: detail(), - }; - assert!(!quota.retryable()); - - let content_filter = Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: detail(), - }; - assert!(!content_filter.retryable()); - } - - #[test] - fn non_retryable_sdk_errors() { - let invalid_tool = Error::InvalidToolCall { - message: "bad tool".into(), - }; - assert!(!invalid_tool.retryable()); - - let no_object = Error::NoObjectGenerated { - message: "no output".into(), - }; - assert!(!no_object.retryable()); - - let interrupt = Error::Interrupt { - message: "interrupted".into(), - }; - assert!(!interrupt.retryable()); - } - - #[test] - fn error_from_status_code_mapping() { - let err = error_from_status_code( - 401, - "unauthorized".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - assert!(!err.retryable()); - - let err = - error_from_status_code(403, "forbidden".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let err = - error_from_status_code(404, "not found".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = - error_from_status_code(400, "bad request".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = error_from_status_code( - 422, - "unprocessable".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - .. - })); - - let err = error_from_status_code(408, "timeout".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::RequestTimeout { .. })); - - let err = - error_from_status_code(413, "too large".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - - let err = error_from_status_code( - 429, - "rate limited".into(), - "openai".into(), - None, - None, - Some(5.0), - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - assert!(err.retryable()); - assert_eq!(err.retry_after(), Some(5.0)); - - let err = error_from_status_code(500, "internal".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - assert!(err.retryable()); - - let err = - error_from_status_code(502, "bad gateway".into(), "openai".into(), None, None, None); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - - let err = error_from_status_code( - 529, - "Overloaded".into(), - "anthropic".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - assert!(err.retryable()); - } - - /// Every vendor spelling of "you are out of credit" arrives as a 429 and - /// has to classify as a spent quota, not as a rate limit. - #[test] - fn quota_codes_on_429_are_non_retryable_quota_failures() { - for (provider, code) in [ - ("kimi", "exceeded_current_quota_error"), - ("openai", "insufficient_quota"), - ("openai", "billing_hard_limit_reached"), - ] { - let err = error_from_status_code( - 429, - "Your account has insufficient balance".into(), - provider.into(), - Some(code.into()), - None, - None, - ); - - assert_eq!( - err.provider_kind(), - Some(ProviderErrorKind::QuotaExceeded), - "{code}" - ); - assert!(!err.retryable(), "{code}"); - assert!(err.failover_eligible(), "{code}"); - } - } - - /// A 429 that is a genuine rate limit stays retryable, whether the body - /// names it, names something unrecognized, or carries no code at all. - #[test] - fn non_quota_429_stays_a_retryable_rate_limit() { - for code in [ - Some("rate_limit_error"), - Some("rate_limit_reached_error"), - Some("invalid_request_error"), - None, - ] { - let err = error_from_status_code( - 429, - "slow down".into(), - "openai".into(), - code.map(String::from), - None, - None, - ); - - assert_eq!( - err.provider_kind(), - Some(ProviderErrorKind::RateLimit), - "{code:?}" - ); - assert!(err.retryable(), "{code:?}"); - } - } - - /// For a status with no fixed meaning, the structured code beats guessing - /// from the message text. - #[test] - fn ambiguous_status_prefers_error_code_over_message() { - let err = error_from_status_code( - 402, - "Payment required".into(), - "openai".into(), - Some("insufficient_quota".into()), - None, - None, - ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - } - - /// Fireworks reports an account suspension (spending cap reached or - /// unpaid invoices) as HTTP 412 with `code: "PRECONDITION_FAILED"` in - /// the body. A chat completion carries no conditional-request - /// preconditions, so a 412 is always an account-level lockout, never a - /// defect in the request: it must not classify as `InvalidRequest`, and - /// a fallback provider with independent billing must stay eligible. - #[test] - fn account_suspension_412_is_failover_eligible() { - let err = error_from_status_code( - 412, - "Account lithoscomputer is suspended, possibly due to reaching \ - the monthly spending limit or failure to pay past invoices." - .into(), - "fireworks".into(), - // The openai_compatible dialect reads `error.type` as the code, - // so the discriminating `PRECONDITION_FAILED` only reaches this - // mapping through the status code. - Some("error".into()), - Some(serde_json::json!({ - "error": { - "message": "Account lithoscomputer is suspended, possibly due to reaching the monthly spending limit or failure to pay past invoices. Please go to https://fireworks.ai/account/billing for more information.", - "param": null, - "code": "PRECONDITION_FAILED", - "type": "error" - }, - "request_id": "chatcmpl-d9652b89a6604931ac27dddd5ef5bdc0" - })), - None, - ); - - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied)); - assert!(!err.retryable()); - assert!(err.failover_eligible()); - - // A bare 412 with no parseable body classifies the same way. - let err = error_from_status_code( - 412, - "Precondition Failed".into(), - "fireworks".into(), - None, - None, - None, - ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied)); - assert!(err.failover_eligible()); - } - - #[test] - fn kind_from_error_code_covers_every_dialect() { - for (code, expected) in [ - ("insufficient_quota", ProviderErrorKind::QuotaExceeded), - ("rate_limit_error", ProviderErrorKind::RateLimit), - ("authentication_error", ProviderErrorKind::Authentication), - ("permission_error", ProviderErrorKind::AccessDenied), - ("content_policy_violation", ProviderErrorKind::ContentFilter), - ("context_length_exceeded", ProviderErrorKind::ContextLength), - ("engine_overloaded", ProviderErrorKind::Server), - // anthropic's oversized-input code beats the `_too_large` rule - ("request_too_large", ProviderErrorKind::ContextLength), - ("prompt_too_long", ProviderErrorKind::InvalidRequest), - ("invalid_request_error", ProviderErrorKind::InvalidRequest), - ("unsupported_parameter", ProviderErrorKind::InvalidRequest), - // both the anthropic and openai not-found spellings - ("not_found_error", ProviderErrorKind::NotFound), - ("model_not_found", ProviderErrorKind::NotFound), - ] { - assert_eq!(kind_from_error_code(code), Some(expected), "{code}"); - } - - // No opinion, so the caller keeps its own default. - assert_eq!(kind_from_error_code("overloaded_error"), None); - assert_eq!(kind_from_error_code("api_error"), None); - assert_eq!(kind_from_error_code(""), None); - } - - #[test] - fn error_message_classification_context_length() { - let err = error_from_status_code( - 400, - "This model's maximum context length is 4096 tokens".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - } - - #[test] - fn error_message_classification_too_many_tokens() { - let err = error_from_status_code( - 400, - "too many tokens in the request".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContextLength, - .. - })); - } - - #[test] - fn error_message_classification_content_filter() { - let err = error_from_status_code( - 400, - "Output blocked by content filter".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContentFilter, - .. - })); - } - - #[test] - fn error_message_classification_safety() { - let err = error_from_status_code( - 400, - "Response blocked due to safety concerns".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::ContentFilter, - .. - })); - } - - #[test] - fn error_message_classification_not_found() { - let err = error_from_status_code( - 400, - "The model gpt-5 was not found".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - } - - #[test] - fn error_message_classification_does_not_exist() { - let err = error_from_status_code( - 400, - "The resource does not exist".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - } - - #[test] - fn error_message_classification_unauthorized() { - let err = error_from_status_code( - 400, - "Request unauthorized for this resource".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - } - - #[test] - fn error_message_classification_invalid_key() { - let err = error_from_status_code( - 400, - "Provided invalid key for authentication".into(), - "openai".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - } - - #[test] - fn grpc_status_mapping() { - let err = error_from_grpc_status( - "NOT_FOUND", - "model not found".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::NotFound, - .. - })); - - let err = error_from_grpc_status( - "RESOURCE_EXHAUSTED", - "rate limited".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::RateLimit, - .. - })); - assert!(err.retryable()); - - let err = error_from_grpc_status( - "UNAUTHENTICATED", - "bad key".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Authentication, - .. - })); - - let err = error_from_grpc_status( - "DEADLINE_EXCEEDED", - "timeout".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::RequestTimeout { .. })); - - let err = error_from_grpc_status( - "UNKNOWN_CODE", - "something".into(), - "gemini".into(), - None, - None, - None, - ); - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::Server, - .. - })); - } - - #[test] - fn error_display_messages() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("invalid api key", "openai") - }), - }; + fn signatures_name_category_provider_and_kind() { assert_eq!( - err.to_string(), - "Authentication error for openai: invalid api key" - ); - - let err = Error::Configuration { - message: "no provider".into(), - source: None, - }; - assert_eq!(err.to_string(), "Configuration error: no provider"); - - let err = Error::InvalidRequest { - message: "unsupported reasoning effort".into(), - }; - assert_eq!( - err.to_string(), - "Invalid request: unsupported reasoning effort" - ); - } - - #[test] - fn status_code_accessor() { - let err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(503), - ..ProviderErrorDetail::new("error", "openai") - }), - }; - assert_eq!(err.status_code(), Some(503)); - - let err = Error::Network { - message: "refused".into(), - source: None, - }; - assert_eq!(err.status_code(), None); - } - - #[test] - fn provider_name_from_provider_variant() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }; - assert_eq!(err.provider_name(), "openai"); - } - - #[test] - fn provider_name_defaults_to_unknown() { - let err = Error::Network { - message: "refused".into(), - source: None, - }; - assert_eq!(err.provider_name(), "unknown"); - } - - #[test] - fn failover_eligible_transient_provider_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - assert!( - Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - Error::Provider { - kind: ProviderErrorKind::Server, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: detail(), - } - .failover_eligible() - ); - } - - #[test] - fn failover_eligible_provider_local_availability_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - for kind in [ - ProviderErrorKind::Authentication, - ProviderErrorKind::AccessDenied, - ProviderErrorKind::NotFound, - ] { - assert!( - Error::Provider { - kind, - detail: detail(), - } - .failover_eligible(), - "{kind:?} should permit another provider" - ); - } - } - - #[test] - fn failover_eligible_transient_non_provider_errors() { - assert!( - Error::RequestTimeout { - message: "timed out".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - Error::Network { - message: "refused".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - Error::Stream { - message: "broken".into(), - source: None, - } - .failover_eligible() - ); - } - - #[test] - fn failover_not_eligible_deterministic_errors() { - let detail = || Box::new(ProviderErrorDetail::new("error", "openai")); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: detail(), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: detail(), - } - .failover_eligible() - ); - } - - #[test] - fn failover_eligible_for_refusal_content_filter_only() { - assert!( - Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - error_code: Some("refusal".to_string()), - raw: Some(serde_json::json!({ - "stop_reason": "refusal", - "stop_details": {"type": "refusal", "category": "cyber"} - })), - ..ProviderErrorDetail::new("declined", "anthropic") - }), - } - .failover_eligible() - ); - - assert!( - !Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail { - error_code: Some("safety".to_string()), - ..ProviderErrorDetail::new("blocked", "anthropic") - }), - } - .failover_eligible() - ); - } - - #[test] - fn failover_not_eligible_non_provider_errors() { - assert!( - !Error::Configuration { - message: "bad".into(), - source: None, - } - .failover_eligible() - ); - - assert!( - !Error::Interrupt { - message: "cancelled".into(), - } - .failover_eligible() - ); - - assert!( - !Error::InvalidToolCall { - message: "bad".into(), - } - .failover_eligible() - ); - - assert!( - !Error::NoObjectGenerated { - message: "none".into(), - } - .failover_eligible() - ); - - assert!( - !Error::InvalidRequest { - message: "bad".into(), - } - .failover_eligible() - ); - - assert!( - !Error::UnsupportedToolChoice { - message: "nope".into(), - } - .failover_eligible() - ); - } - - #[test] - fn failure_signature_hint_provider_transient() { - let err = Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail::new("too fast", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_transient|openai|rate_limited" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail::new("500", "anthropic")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_transient|anthropic|server_error" - ); - } - - #[test] - fn failure_signature_hint_provider_deterministic() { - let err = Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail::new("bad key", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|authentication" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::AccessDenied, - detail: Box::new(ProviderErrorDetail::new("denied", "anthropic")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|anthropic|access_denied" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::NotFound, - detail: Box::new(ProviderErrorDetail::new("missing", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|not_found" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::InvalidRequest, - detail: Box::new(ProviderErrorDetail::new("bad", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), + failure_signature_hint(&error(ErrorKind::InvalidRequest)), "api_deterministic|openai|invalid_request" ); - - let err = Error::Provider { - kind: ProviderErrorKind::ContentFilter, - detail: Box::new(ProviderErrorDetail::new("blocked", "openai")), - }; assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|content_filter" + failure_signature_hint( + &error(ErrorKind::RateLimit).with_retry(RetryClassification::Safe) + ), + "api_transient|openai|rate_limit" ); - - let err = Error::Provider { - kind: ProviderErrorKind::ContextLength, - detail: Box::new(ProviderErrorDetail::new("too long", "openai")), - }; assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|context_length" - ); - - let err = Error::Provider { - kind: ProviderErrorKind::QuotaExceeded, - detail: Box::new(ProviderErrorDetail::new("out of quota", "openai")), - }; - assert_eq!( - err.failure_signature_hint(), - "api_deterministic|openai|quota_exceeded" + failure_signature_hint(&error(ErrorKind::Cancelled)), + "api_canceled|openai|cancelled" ); } #[test] - fn failure_signature_hint_non_provider_variants() { - assert_eq!( - Error::RequestTimeout { - message: "timed out".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|timeout" - ); - assert_eq!( - Error::Network { - message: "refused".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|network" - ); - assert_eq!( - Error::Stream { - message: "broken".into(), - source: None, - } - .failure_signature_hint(), - "api_transient|unknown|stream" - ); - assert_eq!( - Error::Interrupt { - message: "cancelled".into(), - } - .failure_signature_hint(), - "api_canceled|unknown|interrupt" - ); - assert_eq!( - Error::Configuration { - message: "bad".into(), - source: None, - } - .failure_signature_hint(), - "api_deterministic|unknown|configuration" - ); - assert_eq!( - Error::InvalidToolCall { - message: "bad".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|invalid_tool_call" - ); - assert_eq!( - Error::NoObjectGenerated { - message: "none".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|no_object" - ); - assert_eq!( - Error::InvalidRequest { - message: "bad".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|invalid_request" - ); - assert_eq!( - Error::UnsupportedToolChoice { - message: "nope".into(), - } - .failure_signature_hint(), - "api_deterministic|unknown|unsupported_tool_choice" - ); + fn failover_covers_provider_local_failures() { + assert!(failover_eligible(&error(ErrorKind::Authentication))); + assert!(failover_eligible(&error(ErrorKind::QuotaExceeded))); + assert!(!failover_eligible(&error(ErrorKind::InvalidRequest))); + assert!(!failover_eligible(&error(ErrorKind::ContextLength))); + assert!(!failover_eligible(&error(ErrorKind::ContentFilter))); + assert!(failover_eligible( + &error(ErrorKind::ContentFilter).with_provider_code("refusal") + )); } #[test] - fn sdk_error_source_chaining() { - let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); - let err = Error::network("connection failed", io_err); - assert!(err.source().is_some()); - } + fn stored_errors_keep_the_facts_and_round_trip() { + let live = error(ErrorKind::RateLimit) + .with_status(429) + .with_provider_code("slow") + .with_retry(RetryClassification::after(Duration::from_secs(2))) + .with_source(std::io::Error::other("socket closed")); + let stored = LlmError::from(&live); + assert_eq!(stored.kind(), ErrorKind::RateLimit); + assert_eq!(stored.status(), Some(429)); + assert_eq!(stored.provider_code(), Some("slow")); + assert_eq!(stored.retry_after(), Some(Duration::from_secs(2))); + assert_eq!(stored.source_message(), Some("socket closed")); + assert_eq!(stored.to_string(), "boom"); + assert!(stored.is_retryable()); + assert_eq!( + stored.failure_signature_hint(), + failure_signature_hint(&live) + ); - #[test] - fn sdk_error_source_chain_walkable() { - let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); - let err = Error::network("connection failed", io_err); - // The source chain is walkable — the Arc wrapper preserves the inner error's - // display - let source = err.source().unwrap(); - assert!(source.to_string().contains("refused")); - } - - #[test] - fn sdk_error_serde_roundtrip_without_source() { - let io_err = std::io::Error::other("boom"); - let err = Error::network("network failed", io_err); - let json = serde_json::to_string(&err).unwrap(); - let deserialized: Error = serde_json::from_str(&json).unwrap(); - // source is lost through serde, message is preserved - assert!(deserialized.source().is_none()); - assert_eq!(deserialized.to_string(), "Network error: network failed"); + let json = serde_json::to_value(&stored).unwrap(); + assert_eq!(json["kind"], "rate_limit"); + let decoded: LlmError = serde_json::from_value(json).unwrap(); + assert_eq!(decoded, stored); } } diff --git a/lib/components/fabro-llm/src/gateway.rs b/lib/components/fabro-llm/src/gateway.rs new file mode 100644 index 000000000..21b267d67 --- /dev/null +++ b/lib/components/fabro-llm/src/gateway.rs @@ -0,0 +1,306 @@ +//! The Fabro server completions gateway as a lithos provider adapter. +//! +//! `fabro exec --server` sends every model call to `POST /api/v1/completions` +//! on a Fabro server, which holds the provider credentials and the catalog +//! and is the billing authority. The server returns lithos `Response` JSON +//! and streams lithos `StreamEvent` JSON verbatim, so this adapter decodes +//! the standard types and trusts the cost inside them. +//! +//! Transport (authentication, token refresh, base URL) belongs to the caller +//! through [`GatewayTransport`], so this crate does not depend on the CLI's +//! server client. + +use std::time::Duration; + +use async_trait::async_trait; +use fabro_http::HeaderMap; +use futures::{StreamExt as _, stream}; +use lithos_llm::adapter::{ProviderAdapter, ResolvedCall}; +use lithos_llm::catalog::{AdapterId, ProviderId}; +use lithos_llm::types::{ + Error, ErrorKind, Response, ResponseStream, RetryClassification, StreamEvent, +}; + +/// Adapter id reported for gateway routes. +pub const GATEWAY_ADAPTER_ID: &str = "fabro-gateway"; + +/// How the adapter reaches the server. +#[async_trait] +pub trait GatewayTransport: Send + Sync { + /// Posts a completion body and returns the raw HTTP response. + async fn post_completion( + &self, + body: serde_json::Value, + ) -> Result; +} + +/// A failure between the adapter and the server. +#[derive(Debug, thiserror::Error)] +pub enum GatewayError { + /// The request never produced an HTTP response. + #[error("{message}")] + Transport { + message: String, + /// Whether the failure was a missing or rejected Fabro login. + auth: bool, + }, + /// The server answered with an error status. + #[error("server returned HTTP {status}")] + Status { + status: u16, + headers: HeaderMap, + body: String, + }, +} + +pub struct GatewayAdapter { + id: AdapterId, + transport: Box, +} + +impl GatewayAdapter { + #[must_use] + pub fn new(transport: Box) -> Self { + Self { + id: AdapterId::new(GATEWAY_ADAPTER_ID), + transport, + } + } + + fn body(call: &ResolvedCall, stream: bool) -> Result { + let mut body = serde_json::to_value(call.request()).map_err(|source| { + Error::new(ErrorKind::InvalidRequest, "failed to serialize request").with_source(source) + })?; + // The gateway resolves models itself; send the canonical route so the + // server and the local catalog agree on the offering. + body["model"] = serde_json::Value::String(call.route().handle().to_string()); + body["stream"] = serde_json::Value::Bool(stream); + Ok(body) + } + + async fn send(&self, call: &ResolvedCall, stream: bool) -> Result { + let provider = call.route().provider().id().clone(); + self.transport + .post_completion(Self::body(call, stream)?) + .await + .map_err(|err| gateway_error(err, &provider)) + } +} + +fn gateway_error(err: GatewayError, provider: &ProviderId) -> Error { + match err { + GatewayError::Transport { message, auth } => { + let kind = if auth { + ErrorKind::Authentication + } else { + ErrorKind::Network + }; + let mut error = Error::new(kind, message).with_provider(provider.clone()); + if !auth { + error = error.with_retry(RetryClassification::Safe); + } + error + } + GatewayError::Status { + status, + headers, + body, + } => { + let (message, code) = parse_server_error_body(&body); + let kind = match status { + 400 | 422 => ErrorKind::InvalidRequest, + 401 => ErrorKind::Authentication, + 403 => ErrorKind::AccessDenied, + 404 => ErrorKind::NotFound, + 408 | 504 => ErrorKind::Timeout, + 429 => ErrorKind::RateLimit, + 500..=599 => ErrorKind::Server, + _ => ErrorKind::Provider, + }; + let mut error = Error::new(kind.clone(), message) + .with_provider(provider.clone()) + .with_status(status); + if let Some(code) = code { + error = error.with_provider_code(code); + } + match kind { + ErrorKind::RateLimit | ErrorKind::Server | ErrorKind::Timeout => { + error = error.with_retry(RetryClassification::Safe); + if let Some(after) = retry_after(&headers) { + error = error + .with_retry(RetryClassification::after(after)) + .with_provider_retry_after(after); + } + } + _ => {} + } + error + } + } +} + +fn retry_after(headers: &HeaderMap) -> Option { + headers + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs_f64) +} + +/// Reads the Fabro API error envelope (`errors[0].detail` / `code`), falling +/// back to the raw body. +#[must_use] +pub fn parse_server_error_body(body: &str) -> (String, Option) { + let Ok(value) = serde_json::from_str::(body) else { + return (body.to_string(), None); + }; + let first = value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()); + let detail = first + .and_then(|entry| entry.get("detail")) + .and_then(serde_json::Value::as_str) + .or_else(|| value.get("detail").and_then(serde_json::Value::as_str)) + .unwrap_or("Unknown error") + .to_string(); + let code = first + .and_then(|entry| entry.get("code")) + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned); + (detail, code) +} + +fn parse_sse_block(block: &str) -> Option<(String, String)> { + let mut event_type = None; + let mut data_lines = Vec::new(); + for line in block.lines() { + if let Some(value) = line.strip_prefix("event:") { + event_type = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim()); + } + } + let event_type = event_type?; + (!data_lines.is_empty()).then(|| (event_type, data_lines.join("\n"))) +} + +fn decode_error(message: String, source: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::new(ErrorKind::StreamDecode, message).with_source(source) +} + +#[async_trait] +impl ProviderAdapter for GatewayAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, call: &ResolvedCall) -> Result { + let response = self.send(call, false).await?; + let body = response.text().await.map_err(|source| { + Error::new(ErrorKind::Network, "failed to read completion body") + .with_source(source) + .with_retry(RetryClassification::Safe) + })?; + serde_json::from_str(&body) + .map_err(|source| decode_error("failed to parse completion response".into(), source)) + } + + async fn stream(&self, call: &ResolvedCall) -> Result { + let response = self.send(call, true).await?; + let state = SseState { + buffer: String::new(), + bytes: Box::pin(response.bytes_stream()), + }; + let events = stream::unfold(state, |mut state| async move { + loop { + if let Some(position) = state.buffer.find("\n\n") { + let block = state.buffer[..position].to_string(); + state.buffer = state.buffer[position + 2..].to_string(); + let Some((event_type, data)) = parse_sse_block(&block) else { + continue; + }; + if event_type != "stream_event" { + continue; + } + let event = serde_json::from_str::(&data).map_err(|source| { + decode_error("failed to parse stream event".into(), source) + }); + return Some((event, state)); + } + match state.bytes.next().await { + Some(Ok(chunk)) => state.buffer.push_str(&String::from_utf8_lossy(&chunk)), + Some(Err(source)) => { + let error = Error::new(ErrorKind::Network, "stream read failed") + .with_source(source) + .with_retry(RetryClassification::Safe); + return Some((Err(error), state)); + } + None => return None, + } + } + }); + Ok(ResponseStream::new(events)) + } +} + +type ByteStream = std::pin::Pin< + Box> + Send>, +>; + +struct SseState { + buffer: String, + bytes: ByteStream, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_error_envelope_is_parsed() { + let (detail, code) = parse_server_error_body( + r#"{"errors":[{"status":"429","title":"Too Many","detail":"slow down","code":"rate"}]}"#, + ); + assert_eq!(detail, "slow down"); + assert_eq!(code.as_deref(), Some("rate")); + let (detail, code) = parse_server_error_body("plain text"); + assert_eq!(detail, "plain text"); + assert!(code.is_none()); + } + + #[test] + fn sse_blocks_split_event_and_data() { + assert_eq!( + parse_sse_block("event: stream_event\ndata: {\"a\":1}"), + Some(("stream_event".to_string(), "{\"a\":1}".to_string())) + ); + assert_eq!(parse_sse_block(": comment"), None); + } + + #[test] + fn status_codes_map_to_error_kinds() { + let provider = ProviderId::new("openai"); + let mut headers = HeaderMap::new(); + headers.insert("retry-after", "2".parse().unwrap()); + let error = gateway_error( + GatewayError::Status { + status: 429, + headers, + body: String::new(), + }, + &provider, + ); + assert_eq!(error.kind(), ErrorKind::RateLimit); + assert_eq!(error.retry_after(), Some(Duration::from_secs(2))); + let error = gateway_error( + GatewayError::Transport { + message: "login required".into(), + auth: true, + }, + &provider, + ); + assert_eq!(error.kind(), ErrorKind::Authentication); + assert_eq!(error.retry_classification(), RetryClassification::Never); + } +} diff --git a/lib/components/fabro-llm/src/generate.rs b/lib/components/fabro-llm/src/generate.rs deleted file mode 100644 index 2e95d0070..000000000 --- a/lib/components/fabro-llm/src/generate.rs +++ /dev/null @@ -1,2634 +0,0 @@ -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use fabro_util::backoff::BackoffPolicy; -use futures::{Stream, StreamExt, future, stream}; -use tokio::sync::mpsc; -use tokio::time; -use tokio_stream::wrappers::ReceiverStream; -use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; - -use crate::client::Client; -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::retry::retry; -use crate::tools::{RepairToolCallFn, Tool, execute_all_tools_with_repair}; -use crate::types::{ - FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response, - ResponseFormat, ResponseFormatType, RetryPolicy, Speed, StepResult, StreamEvent, - TimeoutOptions, TokenCounts, ToolCall, ToolChoice, ToolDefinition, -}; - -fn build_initial_messages(params: &GenerateParams) -> Result, Error> { - let mut messages = Vec::new(); - if let Some(system) = ¶ms.system { - messages.push(Message::system(system)); - } - if let Some(ref prompt) = params.prompt { - if params.messages.is_some() { - return Err(Error::Configuration { - message: "Cannot specify both 'prompt' and 'messages'".into(), - source: None, - }); - } - messages.push(Message::user(prompt)); - } else if let Some(ref msgs) = params.messages { - messages.extend(msgs.clone()); - } - Ok(messages) -} - -fn build_request( - params: &GenerateParams, - messages: &[Message], - tool_definitions: Option<&[ToolDefinition]>, -) -> Request { - Request { - model: params.model.clone(), - messages: messages.to_vec(), - provider: params.provider.clone(), - tools: tool_definitions.map(<[ToolDefinition]>::to_vec), - tool_choice: params.tool_choice.clone(), - response_format: params.response_format.clone(), - temperature: params.temperature, - top_p: params.top_p, - max_tokens: params.max_tokens, - stop_sequences: params.stop_sequences.clone(), - reasoning_effort: params.reasoning_effort, - speed: params.speed, - metadata: params.metadata.clone(), - provider_options: params.provider_options.clone(), - } -} - -fn build_generate_result(steps: Vec, total_usage: TokenCounts) -> GenerateResult { - let last = steps.last().expect("steps should not be empty"); - let response = last.response.clone(); - let tool_results = last.tool_results.clone(); - GenerateResult { - response, - tool_results, - total_usage, - steps, - output: None, - } -} - -/// High-level blocking generation function (Section 4.3). -/// -/// Wraps `Client.complete()` with tool execution loops, prompt standardization, -/// and automatic retries. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during generation or tool execution. -/// -/// # Panics -/// -/// Panics if a tool's `execute` handler is `None` when matched during tool -/// execution. -pub async fn generate(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let retry_policy = RetryPolicy { - max_retries: params.max_retries, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_micros(1), - jitter: false, - ..Default::default() - }, - ..Default::default() - }; - - let mut messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - - let max_tool_rounds = params.max_tool_rounds; - - let abort_signal = params.abort_signal.clone(); - - let generate_future = async { - let mut steps: Vec = Vec::new(); - let mut total_usage = TokenCounts::default(); - - let mut round = 0u32; - loop { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - warn!("Generation interrupted by cancellation token"); - return Err(Error::Interrupt { - message: "Generation interrupted by cancellation token".into(), - }); - } - } - - let request = build_request(¶ms, &messages, tool_definitions.as_deref()); - - debug!( - model = %params.model, - provider = ?params.provider, - messages = messages.len(), - tools = tool_definitions.as_ref().map_or(0, std::vec::Vec::len), - "Sending LLM request" - ); - - let client_ref = client.clone(); - let response = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) - { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout( - duration, - retry(&retry_policy, || { - let c = client_ref.clone(); - let r = request.clone(); - async move { c.complete(&r).await } - }), - ) - .await - .map_err(|_| { - warn!(timeout_secs = per_step, "Per-step timeout exceeded"); - Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - } - })? - } else { - retry(&retry_policy, || { - let c = client_ref.clone(); - let r = request.clone(); - async move { c.complete(&r).await } - }) - .await - }?; - - debug!( - model = %response.model, - provider = %response.provider, - input_tokens = response.usage.input_tokens, - output_tokens = response.usage.output_tokens, - finish_reason = ?response.finish_reason, - "LLM response received" - ); - - let tool_calls = response.tool_calls(); - let mut tool_results = Vec::new(); - - if let Some(tools) = ¶ms.tools { - if !tool_calls.is_empty() - && response.finish_reason == FinishReason::ToolCalls - && max_tool_rounds > 0 - { - debug!( - tool_calls = tool_calls.len(), - round = round, - "Executing tool calls" - ); - if tools.iter().any(|t| t.is_active()) { - let tool_refs: Vec<&Tool> = - tools.iter().map(std::convert::AsRef::as_ref).collect(); - tool_results = execute_all_tools_with_repair( - &tool_refs, - &tool_calls, - &messages, - abort_signal.as_ref(), - params.repair_tool_call.as_ref(), - ) - .await; - } - } - } - - total_usage += response.usage.clone(); - - steps.push(StepResult { - response, - tool_results, - }); - - let last = steps - .last() - .expect("steps is non-empty: element was pushed on the line above"); - let should_continue = !tool_calls.is_empty() - && last.response.finish_reason == FinishReason::ToolCalls - && round < max_tool_rounds - && !last.tool_results.is_empty() - && !params.stop_when.as_ref().is_some_and(|f| f(&steps)); - - if !should_continue { - break; - } - - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - return Err(Error::Interrupt { - message: "Generation interrupted by cancellation token".into(), - }); - } - } - - let last = steps - .last() - .expect("steps is non-empty: element was pushed on the line above"); - messages.push(last.response.message.clone()); - for result in &last.tool_results { - messages.push(Message::tool_result( - &result.tool_call_id, - result.content.clone(), - result.is_error, - )); - } - - round += 1; - } - - Ok(build_generate_result(steps, total_usage)) - }; - - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - time::timeout(duration, generate_future) - .await - .map_err(|_| { - warn!(timeout_secs = total, "Total generation timeout exceeded"); - Error::RequestTimeout { - message: format!("Total timeout of {total}s exceeded"), - source: None, - } - })? - } else { - generate_future.await - } -} - -/// Callback type for custom stop conditions in the tool loop. -pub type StopCondition = Arc bool + Send + Sync>; - -/// Parameters for `generate()` (Section 4.3). -#[derive(Clone)] -pub struct GenerateParams { - pub model: String, - pub prompt: Option, - pub messages: Option>, - pub system: Option, - pub tools: Option>>, - pub tool_choice: Option, - pub max_tool_rounds: u32, - pub response_format: Option, - pub temperature: Option, - pub top_p: Option, - pub max_tokens: Option, - pub stop_sequences: Option>, - pub reasoning_effort: Option, - pub speed: Option, - pub provider: Option, - pub provider_options: Option, - pub metadata: Option>, - pub max_retries: u32, - pub timeout: Option, - pub client: Arc, - /// Cancellation token to interrupt generation (Section 4.8). - pub abort_signal: Option, - /// Custom stop condition checked after each tool round (Section 4.3). - pub stop_when: Option, - /// Callback to repair invalid tool call arguments (Section 5.8). - pub repair_tool_call: Option, -} - -impl GenerateParams { - pub fn new(model: impl Into, client: Arc) -> Self { - Self { - model: model.into(), - prompt: None, - messages: None, - system: None, - tools: None, - tool_choice: None, - max_tool_rounds: 1, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - provider: None, - provider_options: None, - metadata: None, - max_retries: 2, - timeout: None, - client, - abort_signal: None, - stop_when: None, - repair_tool_call: None, - } - } - - #[must_use] - pub fn prompt(mut self, prompt: impl Into) -> Self { - self.prompt = Some(prompt.into()); - self - } - - #[must_use] - pub fn messages(mut self, messages: Vec) -> Self { - self.messages = Some(messages); - self - } - - #[must_use] - pub fn system(mut self, system: impl Into) -> Self { - self.system = Some(system.into()); - self - } - - #[must_use] - pub fn tools(mut self, tools: Vec) -> Self { - self.tools = Some(tools.into_iter().map(Arc::new).collect()); - self - } - - #[must_use] - pub const fn max_tool_rounds(mut self, rounds: u32) -> Self { - self.max_tool_rounds = rounds; - self - } - - #[must_use] - pub fn provider(mut self, provider: impl Into) -> Self { - self.provider = Some(provider.into()); - self - } - - #[must_use] - pub fn tool_choice(mut self, tool_choice: ToolChoice) -> Self { - self.tool_choice = Some(tool_choice); - self - } - - #[must_use] - pub fn response_format(mut self, response_format: ResponseFormat) -> Self { - self.response_format = Some(response_format); - self - } - - #[must_use] - pub const fn temperature(mut self, temperature: f64) -> Self { - self.temperature = Some(temperature); - self - } - - #[must_use] - pub const fn top_p(mut self, top_p: f64) -> Self { - self.top_p = Some(top_p); - self - } - - #[must_use] - pub const fn max_tokens(mut self, max_tokens: i64) -> Self { - self.max_tokens = Some(max_tokens); - self - } - - #[must_use] - pub fn stop_sequences(mut self, stop_sequences: Vec) -> Self { - self.stop_sequences = Some(stop_sequences); - self - } - - #[must_use] - pub fn reasoning_effort(mut self, reasoning_effort: ReasoningEffort) -> Self { - self.reasoning_effort = Some(reasoning_effort); - self - } - - #[must_use] - pub const fn speed(mut self, speed: Speed) -> Self { - self.speed = Some(speed); - self - } - - #[must_use] - pub fn provider_options(mut self, provider_options: serde_json::Value) -> Self { - self.provider_options = Some(provider_options); - self - } - - #[must_use] - pub fn metadata(mut self, metadata: std::collections::HashMap) -> Self { - self.metadata = Some(metadata); - self - } - - #[must_use] - pub const fn max_retries(mut self, max_retries: u32) -> Self { - self.max_retries = max_retries; - self - } - - #[must_use] - pub const fn timeout(mut self, timeout: TimeoutOptions) -> Self { - self.timeout = Some(timeout); - self - } - - #[must_use] - pub fn abort_signal(mut self, token: CancellationToken) -> Self { - self.abort_signal = Some(token); - self - } - - /// Set a custom stop condition for the tool loop (Section 4.3). - /// - /// The callback receives the accumulated steps so far and returns `true` - /// to stop the tool loop early. - #[must_use] - pub fn stop_when(mut self, f: impl Fn(&[StepResult]) -> bool + Send + Sync + 'static) -> Self { - self.stop_when = Some(Arc::new(f)); - self - } - - #[must_use] - pub fn repair_tool_call(mut self, repair: RepairToolCallFn) -> Self { - self.repair_tool_call = Some(repair); - self - } -} - -/// `StreamAccumulator` collects stream events into a complete Response (Section -/// 4.4). -pub struct StreamAccumulator { - text_parts: Vec, - reasoning_parts: Vec, - tool_calls: Vec, - finish_reason: Option, - usage: Option, - response: Option, -} - -impl StreamAccumulator { - #[must_use] - pub const fn new() -> Self { - Self { - text_parts: Vec::new(), - reasoning_parts: Vec::new(), - tool_calls: Vec::new(), - finish_reason: None, - usage: None, - response: None, - } - } - - pub fn process(&mut self, event: &StreamEvent) { - match event { - StreamEvent::TextDelta { delta, .. } => { - self.text_parts.push(delta.clone()); - } - StreamEvent::ReasoningDelta { delta } => { - self.reasoning_parts.push(delta.clone()); - } - StreamEvent::ToolCallEnd { tool_call } => { - self.tool_calls.push(tool_call.clone()); - } - StreamEvent::Finish { - finish_reason, - usage, - response, - } => { - self.finish_reason = Some(finish_reason.clone()); - self.usage = Some(usage.clone()); - self.response = Some(*response.clone()); - info!( - model = %response.model, - input_tokens = response.usage.input_tokens, - output_tokens = response.usage.output_tokens, - "LLM stream complete" - ); - } - _ => {} - } - } - - #[must_use] - pub const fn response(&self) -> Option<&Response> { - self.response.as_ref() - } - - #[must_use] - pub fn text(&self) -> String { - self.text_parts.join("") - } - - #[must_use] - pub fn reasoning(&self) -> Option { - if self.reasoning_parts.is_empty() { - None - } else { - Some(self.reasoning_parts.join("")) - } - } -} - -impl Default for StreamAccumulator { - fn default() -> Self { - Self::new() - } -} - -/// Wraps a streaming response with an internal `StreamAccumulator` and -/// convenience methods. -/// -/// Implements `Stream>` so it can be used -/// as a drop-in replacement for `StreamEventStream`. Also supports multi-step -/// tool loops when active tools are provided. -pub struct StreamResult { - inner: StreamEventStream, - accumulator: StreamAccumulator, -} - -impl StreamResult { - fn new(inner: StreamEventStream) -> Self { - Self { - inner, - accumulator: StreamAccumulator::new(), - } - } - - /// Returns the accumulated response after the stream has ended. - #[must_use] - pub const fn response(&self) -> Option<&Response> { - self.accumulator.response() - } - - /// Returns the current partially accumulated response state. - #[must_use] - pub const fn partial_response(&self) -> Option<&Response> { - self.accumulator.response() - } - - /// Returns a stream that yields only text delta strings. - #[must_use] - pub fn text_stream(self) -> Pin> + Send>> { - Box::pin(self.filter_map(|result| { - future::ready(match result { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(Ok(delta)), - Err(e) => Some(Err(e)), - _ => None, - }) - })) - } -} - -impl Stream for StreamResult { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let inner = self.inner.as_mut(); - match inner.poll_next(cx) { - Poll::Ready(Some(Ok(event))) => { - self.accumulator.process(&event); - Poll::Ready(Some(Ok(event))) - } - other => other, - } - } -} - -/// High-level streaming generation (Section 4.4). -/// Returns a `StreamResult` that the caller can iterate over. -/// Supports multi-step tool loops when active tools are provided. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -pub async fn stream(params: GenerateParams) -> Result { - let inner = stream_with_tool_loop(params).await?; - Ok(StreamResult::new(inner)) -} - -/// Streaming generation with multi-step tool loop support. -/// -/// When active tools are provided and the model returns tool calls: -/// - Collects the stream to get the complete first response -/// - Executes tools concurrently -/// - Starts a new stream with updated conversation -/// - Yields all events from all rounds seamlessly -/// - Continues until no more tool calls or `max_tool_rounds` reached -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -async fn stream_with_tool_loop(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let mut messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - let abort_signal = params.abort_signal.clone(); - let max_tool_rounds = params.max_tool_rounds; - let repair_tool_call = params.repair_tool_call.clone(); - - let has_active_tools = max_tool_rounds > 0 - && params - .tools - .as_ref() - .is_some_and(|tools| tools.iter().any(|t| t.is_active())); - - debug!(model = %params.model, "Starting LLM stream"); - - if !has_active_tools { - // No tool loop needed, just stream directly - return stream_generate_raw(&client, ¶ms, &messages, tool_definitions.as_deref()).await; - } - - // Tool loop: collect events from each round, execute tools, continue - let (tx, rx) = mpsc::channel::>(64); - - let tools = params.tools.clone(); - let retry_policy = RetryPolicy { - max_retries: params.max_retries, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_micros(1), - jitter: false, - ..Default::default() - }, - ..Default::default() - }; - - tokio::spawn(async move { - let tool_loop_future = async { - let mut round = 0u32; - let mut steps: Vec = Vec::new(); - - loop { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - let _ = tx - .send(Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - })) - .await; - return; - } - } - - let request = build_request(¶ms, &messages, tool_definitions.as_deref()); - - // Retry initial connection (Section 6.6), with optional per_step timeout - let stream_connect = retry(&retry_policy, || { - let c = client.clone(); - let r = request.clone(); - async move { c.stream(&r).await } - }); - - let stream_result = - if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout(duration, stream_connect) - .await - .unwrap_or_else(|_| { - Err(Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - }) - }) - } else { - stream_connect.await - }; - - let mut inner_stream = match stream_result { - Ok(s) => s, - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - }; - - // Collect stream and forward events, accumulating for tool call detection - let mut accumulator = StreamAccumulator::new(); - - while let Some(item) = inner_stream.next().await { - if let Some(ref token) = abort_signal { - if token.is_cancelled() { - let _ = tx - .send(Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - })) - .await; - return; - } - } - - if let Ok(event) = &item { - accumulator.process(event); - } else { - let _ = tx.send(item).await; - return; - } - - // Forward the event to the consumer - if tx.send(item).await.is_err() { - return; // Consumer dropped - } - } - - // Check if we should continue with tool calls - let response = match accumulator.response() { - Some(r) => r.clone(), - None => return, // No response accumulated, stream ended - }; - - let tool_calls = response.tool_calls(); - if tool_calls.is_empty() - || response.finish_reason != FinishReason::ToolCalls - || round >= max_tool_rounds - { - return; // No more tool rounds needed - } - - // Execute tools - let Some(tool_list) = &tools else { return }; - - let tool_refs: Vec<&Tool> = - tool_list.iter().map(std::convert::AsRef::as_ref).collect(); - let tool_results = execute_all_tools_with_repair( - &tool_refs, - &tool_calls, - &messages, - abort_signal.as_ref(), - repair_tool_call.as_ref(), - ) - .await; - - if tool_results.is_empty() { - return; - } - - // Track step results for stop_when - steps.push(StepResult { - response: response.clone(), - tool_results: tool_results.clone(), - }); - - // Check stop_when condition (Section 4.3) - if params.stop_when.as_ref().is_some_and(|f| f(&steps)) { - // Emit StepFinish but do not continue to next round - let step_finish = StreamEvent::step_finish( - response.finish_reason.clone(), - response.usage.clone(), - response, - tool_calls, - tool_results, - ); - let _ = tx.send(Ok(step_finish)).await; - return; - } - - // Emit StepFinish event between steps - let step_finish = StreamEvent::step_finish( - response.finish_reason.clone(), - response.usage.clone(), - response.clone(), - tool_calls, - tool_results.clone(), - ); - if tx.send(Ok(step_finish)).await.is_err() { - return; // Consumer dropped - } - - // Append assistant message and tool results to conversation - messages.push(response.message.clone()); - for result in &tool_results { - messages.push(Message::tool_result( - &result.tool_call_id, - result.content.clone(), - result.is_error, - )); - } - - round += 1; - } - }; - - // Apply total timeout if configured (Section 4.7) - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - if time::timeout(duration, tool_loop_future).await.is_err() { - let _ = tx - .send(Err(Error::RequestTimeout { - message: format!("Total timeout of {total}s exceeded"), - source: None, - })) - .await; - } - } else { - tool_loop_future.await; - } - }); - - Ok(Box::pin(ReceiverStream::new(rx))) -} - -/// Internal single-round streaming (no tool loop). Used by `stream_object()`. -async fn stream_generate_raw( - client: &Arc, - params: &GenerateParams, - messages: &[Message], - tool_definitions: Option<&[ToolDefinition]>, -) -> Result { - let request = build_request(params, messages, tool_definitions); - - // Apply per_step timeout to the initial connection (Section 4.7) - let inner_stream = if let Some(per_step) = params.timeout.as_ref().and_then(|t| t.per_step) { - let duration = std::time::Duration::from_secs_f64(per_step); - time::timeout(duration, client.stream(&request)) - .await - .map_err(|_| Error::RequestTimeout { - message: format!("Per-step timeout of {per_step}s exceeded"), - source: None, - })?? - } else { - client.stream(&request).await? - }; - - // Apply interrupt signal if present - let stream: StreamEventStream = if let Some(ref token) = params.abort_signal { - let token = token.clone(); - let mapped = inner_stream.map(move |item| { - if token.is_cancelled() { - return Err(Error::Interrupt { - message: "Stream interrupted by cancellation token".into(), - }); - } - item - }); - Box::pin(mapped) - } else { - inner_stream - }; - - // Apply total timeout to the stream (Section 4.7) - if let Some(total) = params.timeout.as_ref().and_then(|t| t.total) { - let duration = std::time::Duration::from_secs_f64(total); - let deadline = time::Instant::now() + duration; - let total_copy = total; - let timed_stream = stream::unfold((stream, false), move |(mut stream, done)| async move { - if done { - return None; - } - match time::timeout_at(deadline, stream.next()).await { - Ok(Some(item)) => Some((item, (stream, false))), - Ok(None) => None, // stream completed naturally - Err(_) => Some(( - Err(Error::RequestTimeout { - message: format!("Total timeout of {total_copy}s exceeded"), - source: None, - }), - (stream, true), - )), - } - }); - Ok(Box::pin(timed_stream)) - } else { - Ok(stream) - } -} - -/// High-level streaming generation (Section 4.4). -/// Returns a `StreamEventStream` that the caller can iterate over. -/// -/// Alias: prefer [`stream()`] for consistency with the spec. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// or any provider error encountered during streaming setup. -pub async fn stream_generate(params: GenerateParams) -> Result { - let client = Arc::clone(¶ms.client); - let messages = build_initial_messages(¶ms)?; - let tool_definitions: Option> = params - .tools - .as_ref() - .map(|tools| tools.iter().map(|t| t.definition.clone()).collect()); - - stream_generate_raw(&client, ¶ms, &messages, tool_definitions.as_deref()).await -} - -/// Structured output generation with schema validation (Section 4.5). -/// -/// # Errors -/// -/// Returns `Error::NoObjectGenerated` if the response is not valid JSON, -/// or any error from `generate()`. -pub async fn generate_object( - params: GenerateParams, - schema: serde_json::Value, -) -> Result { - let params = GenerateParams { - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: true, - }), - ..params - }; - - let mut result = generate(params).await?; - - // Try to parse the text as JSON - match serde_json::from_str::(&result.text()) { - Ok(parsed) => { - result.output = Some(parsed); - Ok(result) - } - Err(e) => Err(Error::NoObjectGenerated { - message: format!("Failed to parse response as JSON: {e}"), - }), - } -} - -/// Stream type for `stream_object()`. -pub type ObjectStream = - Pin> + Send>>; - -/// Wraps an `ObjectStream` with an `object()` accessor for the final parsed -/// value. -/// -/// Implements `Stream>` so it can be -/// used as a drop-in replacement for `ObjectStream`. Tracks the last `Complete` -/// event's object internally so callers can retrieve it after the stream ends. -pub struct ObjectStreamResult { - inner: ObjectStream, - object: Option, -} - -impl ObjectStreamResult { - fn new(inner: ObjectStream) -> Self { - Self { - inner, - object: None, - } - } - - /// Returns the final parsed object after the stream has yielded a - /// `Complete` event. - #[must_use] - pub const fn object(&self) -> Option<&serde_json::Value> { - self.object.as_ref() - } -} - -impl Stream for ObjectStreamResult { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let inner = self.inner.as_mut(); - match inner.poll_next(cx) { - Poll::Ready(Some(Ok(event))) => { - if let ObjectStreamEvent::Complete { ref object, .. } = event { - self.object = Some(object.clone()); - } - Poll::Ready(Some(Ok(event))) - } - other => other, - } - } -} - -/// Streaming structured output with incremental JSON parsing (Section 4.6). -/// -/// Combines streaming with structured output: sets `response_format` to -/// `json_schema`, streams the response, and attempts to parse the accumulated -/// text as JSON on each text delta. Yields `ObjectStreamEvent::Partial` when a -/// new valid partial parse is obtained, `ObjectStreamEvent::Delta` for every -/// raw stream event, and `ObjectStreamEvent::Complete` when the stream finishes -/// with the final parsed object. -/// -/// # Errors -/// -/// Returns `Error::Configuration` if both `prompt` and `messages` are set, -/// `Error::NoObjectGenerated` if the final accumulated text is not valid -/// JSON, or any provider error encountered during streaming. -pub async fn stream_object( - params: GenerateParams, - schema: serde_json::Value, -) -> Result { - let params = GenerateParams { - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: true, - }), - ..params - }; - - let inner_stream = stream(params).await?; - - let mapped = inner_stream.scan( - (String::new(), Option::::None), - |(accumulated_text, last_parsed), event| { - let mut events: Vec> = Vec::new(); - - match &event { - Ok(stream_event) => { - // Accumulate text from TextDelta events - if let StreamEvent::TextDelta { delta, .. } = stream_event { - accumulated_text.push_str(delta); - - // Try incremental JSON parse - if let Ok(parsed) = - serde_json::from_str::(accumulated_text) - { - if last_parsed.as_ref() != Some(&parsed) { - *last_parsed = Some(parsed.clone()); - events.push(Ok(ObjectStreamEvent::Partial { object: parsed })); - } - } - } - - // On Finish, yield the Complete event with final parsed object - if let StreamEvent::Finish { response, .. } = stream_event { - match serde_json::from_str::(accumulated_text) { - Ok(final_object) => { - events.push(Ok(ObjectStreamEvent::Complete { - object: final_object, - response: response.clone(), - })); - } - Err(e) => { - events.push(Err(Error::NoObjectGenerated { - message: format!("Failed to parse final response as JSON: {e}"), - })); - } - } - } else { - // Yield the raw delta event - events.push(Ok(ObjectStreamEvent::Delta { - event: stream_event.clone(), - })); - } - } - Err(e) => { - events.push(Err(Error::Stream { - message: format!("{e}"), - source: None, - })); - } - } - - future::ready(Some(stream::iter(events))) - }, - ); - - Ok(ObjectStreamResult::new(Box::pin(mapped.flatten()))) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::atomic::{AtomicU32, Ordering}; - - use futures::{StreamExt, stream}; - use tokio::time::sleep; - - use super::*; - use crate::client::Client; - use crate::error::{ProviderErrorDetail, ProviderErrorKind}; - use crate::provider::ProviderAdapter; - use crate::types::{ContentPart, Role, ToolResult}; - - /// Mock provider that returns configurable responses. - struct MockProvider { - response_text: String, - } - - impl MockProvider { - fn new(text: &str) -> Self { - Self { - response_text: text.to_string(), - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for MockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.response_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let text = self.response_text.clone(); - let events = vec![ - Ok(StreamEvent::text_delta(&text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - fn mock_client(text: &str) -> Arc { - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), Arc::new(MockProvider::new(text))); - Arc::new(Client::new(providers, Some("mock".to_string()), vec![])) - } - - #[tokio::test] - async fn generate_simple_text() { - let result = - generate(GenerateParams::new("mock-model", mock_client("Hi there!")).prompt("Hello")) - .await - .unwrap(); - - assert_eq!(result.text(), "Hi there!"); - assert_eq!(result.finish_reason, FinishReason::Stop); - assert_eq!(result.usage.input_tokens, 10); - assert_eq!(result.steps.len(), 1); - } - - #[tokio::test] - async fn generate_with_system_message() { - let result = generate( - GenerateParams::new("mock-model", mock_client("Greetings!")) - .system("You are helpful") - .prompt("Hello"), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "Greetings!"); - } - - #[tokio::test] - async fn generate_with_messages() { - let result = generate( - GenerateParams::new("mock-model", mock_client("I'm doing well!")).messages(vec![ - Message::user("Hello"), - Message::assistant("Hi"), - Message::user("How are you?"), - ]), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "I'm doing well!"); - } - - #[tokio::test] - async fn generate_errors_on_both_prompt_and_messages() { - let result = generate(GenerateParams { - model: "mock-model".into(), - prompt: Some("Hello".into()), - messages: Some(vec![Message::user("World")]), - client: mock_client("test"), - ..GenerateParams::new("mock-model", mock_client("base")) - }) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Configuration { .. })); - } - - /// Mock provider that returns tool calls then text - struct ToolCallMockProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for ToolCallMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First call: return tool call - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } else { - // Second call: return text - Ok(Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("The weather in SF is 72F"), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 20, - output_tokens: 10, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - } - - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) - } - } - - #[tokio::test] - async fn generate_with_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(serde_json::json!(format!("72F in {}", city))) - }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - assert_eq!(result.text(), "The weather in SF is 72F"); - assert_eq!(result.steps.len(), 2); - assert_eq!(result.total_usage.input_tokens, 30); - assert_eq!(result.total_usage.output_tokens, 15); - assert_eq!(call_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn stream_accumulator_collects_events() { - let mut acc = StreamAccumulator::new(); - - acc.process(&StreamEvent::TextStart { - text_id: Some("t1".into()), - }); - - acc.process(&StreamEvent::text_delta("Hello", Some("t1".into()))); - acc.process(&StreamEvent::text_delta(" world", Some("t1".into()))); - - let resp = Response { - id: "r1".into(), - model: "m".into(), - provider: "p".into(), - message: Message::assistant("Hello world"), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 5, - output_tokens: 2, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - - acc.process(&StreamEvent::finish( - FinishReason::Stop, - resp.usage.clone(), - resp, - )); - - assert_eq!(acc.text(), "Hello world"); - assert_eq!(acc.reasoning(), None); - assert!(acc.response().is_some()); - assert_eq!(acc.response().unwrap().text(), "Hello world"); - } - - #[tokio::test] - async fn stream_accumulator_collects_reasoning() { - let mut acc = StreamAccumulator::new(); - - acc.process(&StreamEvent::ReasoningDelta { - delta: "Let me think...".into(), - }); - - assert_eq!(acc.reasoning(), Some("Let me think...".to_string())); - } - - #[tokio::test] - async fn stream_generate_returns_events() { - let client = mock_client("Hello stream!"); - let mut stream = stream_generate(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - let first = stream.next().await.unwrap().unwrap(); - match &first { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "Hello stream!"), - other => panic!("Expected TextDelta, got {other:?}"), - } - - let second = stream.next().await.unwrap().unwrap(); - assert!(matches!(second, StreamEvent::Finish { .. })); - } - - #[tokio::test] - async fn generate_object_parses_json() { - // Create a mock that returns valid JSON - let client = mock_client(r#"{"name": "Alice", "age": 30}"#); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - }); - - let result = generate_object( - GenerateParams::new("mock-model", client).prompt("Extract name and age"), - schema, - ) - .await - .unwrap(); - - assert!(result.output.is_some()); - let output = result.output.unwrap(); - assert_eq!(output["name"], "Alice"); - assert_eq!(output["age"], 30); - } - - #[tokio::test] - async fn generate_object_errors_on_invalid_json() { - let client = mock_client("not valid json"); - - let result = generate_object( - GenerateParams::new("mock-model", client).prompt("Extract data"), - serde_json::json!({"type": "object"}), - ) - .await; - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - Error::NoObjectGenerated { .. } - )); - } - - #[tokio::test] - async fn generate_stop_when_halts_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |args, _ctx| async move { - let city = args["city"].as_str().unwrap_or("unknown"); - Ok(serde_json::json!(format!("72F in {}", city))) - }, - )]) - .max_tool_rounds(5) - .stop_when(|_steps| true), // Stop immediately after first round - ) - .await - .unwrap(); - - // stop_when returned true, so the tool loop should stop after 1 step - assert_eq!(result.steps.len(), 1); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[test] - fn generate_params_builder_methods() { - let params = GenerateParams::new("test-model", mock_client("builder")) - .prompt("hello") - .system("you are helpful") - .temperature(0.7) - .top_p(0.9) - .max_tokens(100) - .stop_sequences(vec!["STOP".to_string()]) - .reasoning_effort(ReasoningEffort::High) - .speed(Speed::Fast) - .provider("anthropic") - .provider_options(serde_json::json!({"key": "value"})) - .max_retries(5) - .tool_choice(ToolChoice::Required) - .response_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }) - .max_tool_rounds(3); - - assert_eq!(params.model, "test-model"); - assert_eq!(params.prompt.as_deref(), Some("hello")); - assert_eq!(params.system.as_deref(), Some("you are helpful")); - assert_eq!(params.temperature, Some(0.7)); - assert_eq!(params.top_p, Some(0.9)); - assert_eq!(params.max_tokens, Some(100)); - assert_eq!(params.stop_sequences, Some(vec!["STOP".to_string()])); - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!(params.speed, Some(Speed::Fast)); - assert_eq!(params.provider.as_deref(), Some("anthropic")); - assert!(params.provider_options.is_some()); - assert_eq!(params.max_retries, 5); - assert_eq!(params.tool_choice, Some(ToolChoice::Required)); - assert!(params.response_format.is_some()); - assert_eq!(params.max_tool_rounds, 3); - } - - #[test] - fn generate_params_timeout_builder() { - let params = - GenerateParams::new("test-model", mock_client("timeout")).timeout(TimeoutOptions { - total: Some(30.0), - per_step: Some(10.0), - }); - assert!(params.timeout.is_some()); - let t = params.timeout.unwrap(); - assert_eq!(t.total, Some(30.0)); - assert_eq!(t.per_step, Some(10.0)); - } - - /// Mock provider that streams JSON tokens incrementally. - struct StreamingJsonMockProvider { - deltas: Vec, - full_text: String, - } - - impl StreamingJsonMockProvider { - fn new(deltas: Vec<&str>) -> Self { - let full_text: String = deltas.iter().copied().collect(); - Self { - deltas: deltas.into_iter().map(String::from).collect(), - full_text, - } - } - } - - #[async_trait::async_trait] - impl ProviderAdapter for StreamingJsonMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.full_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let mut events: Vec> = self - .deltas - .iter() - .map(|d| Ok(StreamEvent::text_delta(d.as_str(), Some("t1".into())))) - .collect(); - - events.push(Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(&self.full_text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }, - ))); - - Ok(Box::pin(stream::iter(events))) - } - } - - fn streaming_json_mock_client(deltas: Vec<&str>) -> Arc { - let mut providers: HashMap> = HashMap::new(); - providers.insert( - "mock".to_string(), - Arc::new(StreamingJsonMockProvider::new(deltas)), - ); - Arc::new(Client::new(providers, Some("mock".to_string()), vec![])) - } - - #[tokio::test] - async fn stream_object_yields_complete_event() { - let client = streaming_json_mock_client(vec![r#"{"name": "Alice", "age": 30}"#]); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - }); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let events: Vec = obj_stream - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - let complete = events - .iter() - .find(|e| matches!(e, ObjectStreamEvent::Complete { .. })); - assert!(complete.is_some(), "Expected a Complete event"); - - if let ObjectStreamEvent::Complete { object, .. } = complete.unwrap() { - assert_eq!(object["name"], "Alice"); - assert_eq!(object["age"], 30); - } - } - - #[tokio::test] - async fn stream_object_yields_partial_events_incrementally() { - let client = - streaming_json_mock_client(vec![r#"{"name""#, r#": "Bob""#, r#", "age": 25}"#]); - - let schema = serde_json::json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - } - }); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let events: Vec = obj_stream - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - let partial_count = events - .iter() - .filter(|e| matches!(e, ObjectStreamEvent::Partial { .. })) - .count(); - - assert!( - partial_count >= 1, - "Expected at least one Partial event, got {partial_count}" - ); - - let delta_count = events - .iter() - .filter(|e| matches!(e, ObjectStreamEvent::Delta { .. })) - .count(); - - assert_eq!(delta_count, 3); - - let last_complete = events - .iter() - .rev() - .find(|e| matches!(e, ObjectStreamEvent::Complete { .. })); - assert!(last_complete.is_some(), "Expected a Complete event"); - if let ObjectStreamEvent::Complete { object, .. } = last_complete.unwrap() { - assert_eq!(object["name"], "Bob"); - assert_eq!(object["age"], 25); - } - } - - #[tokio::test] - async fn stream_object_errors_on_invalid_final_json() { - let client = streaming_json_mock_client(vec![r#"{"name": "Alice"#]); - - let schema = serde_json::json!({"type": "object"}); - - let obj_stream = stream_object( - GenerateParams::new("mock-model", client).prompt("Extract info"), - schema, - ) - .await - .unwrap(); - - let results: Vec> = obj_stream.collect().await; - - let has_error = results.iter().any(std::result::Result::is_err); - assert!(has_error, "Expected an error for invalid final JSON"); - } - - #[tokio::test] - async fn generate_abort_signal_before_call() { - let token = CancellationToken::new(); - token.cancel(); - - let result = generate( - GenerateParams::new("mock-model", mock_client("Hi")) - .prompt("Hello") - .abort_signal(token), - ) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Interrupt { .. })); - } - - #[tokio::test] - async fn generate_abort_signal_between_tool_rounds() { - // Provider that always returns tool calls - struct AlwaysToolCallProvider { - call_count: Arc, - cancel_token: CancellationToken, - } - - #[async_trait::async_trait] - impl ProviderAdapter for AlwaysToolCallProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - // Cancel after first call completes - if count == 0 { - self.cancel_token.cancel(); - } - Ok(Response { - id: format!("resp_{count}"), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - format!("call_{count}"), - "get_weather", - serde_json::json!({"city": "SF"}), - ))], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - Ok(Box::pin(stream::empty())) - } - } - - let call_count = Arc::new(AtomicU32::new(0)); - let token = CancellationToken::new(); - let token_clone = token.clone(); - - let provider: Arc = Arc::new(AlwaysToolCallProvider { - call_count: call_count.clone(), - cancel_token: token_clone, - }); - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(10) - .abort_signal(token), - ) - .await; - - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::Interrupt { .. })); - // Should have only made 1 call before aborting - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn stream_abort_signal_terminates_stream() { - let token = CancellationToken::new(); - let token_clone = token.clone(); - - // Create a mock that produces events, but cancel after stream starts - let client = mock_client("Hello stream!"); - token_clone.cancel(); - - let mut stream_result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .abort_signal(token), - ) - .await - .unwrap(); - - let first = stream_result.next().await.unwrap(); - assert!(first.is_err()); - assert!(matches!(first.unwrap_err(), Error::Interrupt { .. })); - } - - #[tokio::test] - async fn generate_max_tool_rounds_zero_skips_tool_execution() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(ToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let tool_executed = Arc::new(AtomicU32::new(0)); - let tool_executed_clone = tool_executed.clone(); - - let result = generate( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - move |_args, _ctx| { - let counter = tool_executed_clone.clone(); - async move { - counter.fetch_add(1, Ordering::SeqCst); - Ok(serde_json::json!("72F")) - } - }, - )]) - .max_tool_rounds(0), - ) - .await - .unwrap(); - - // Should return after first LLM call without executing any tools - assert_eq!(result.steps.len(), 1); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - assert_eq!(tool_executed.load(Ordering::SeqCst), 0); - // The tool results should be empty since tools were not executed - assert!(result.tool_results.is_empty()); - } - - #[test] - fn generate_params_abort_signal_builder() { - let token = CancellationToken::new(); - let params = GenerateParams::new("test-model", mock_client("abort")).abort_signal(token); - assert!(params.abort_signal.is_some()); - } - - #[tokio::test] - async fn stream_result_accumulates_response() { - let client = mock_client("Hello!"); - let mut result = stream(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - assert!(result.response().is_none()); - assert!(result.partial_response().is_none()); - - // Consume all events - while result.next().await.is_some() {} - - assert!(result.response().is_some()); - assert_eq!(result.response().unwrap().text(), "Hello!"); - } - - #[tokio::test] - async fn stream_result_text_stream() { - let client = streaming_json_mock_client(vec!["Hello", " ", "world"]); - let result = stream(GenerateParams::new("mock-model", client).prompt("Hi")) - .await - .unwrap(); - - let texts: Vec = result - .text_stream() - .filter_map(|r| future::ready(r.ok())) - .collect() - .await; - - assert_eq!(texts, vec!["Hello", " ", "world"]); - } - - /// Mock provider that streams tool calls then text on second stream - struct StreamingToolCallMockProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for StreamingToolCallMockProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First stream: return tool call - let tool_call = - ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "SF"})); - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call.clone())], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::ToolCallEnd { tool_call }), - Ok(StreamEvent::finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } else { - // Second stream: return text - let text = "The weather in SF is 72F"; - let response = Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 20, - output_tokens: 10, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - } - - #[tokio::test] - async fn stream_with_tool_loop_executes_tools() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - // Collect all events - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have events from both rounds - assert_eq!(call_count.load(Ordering::SeqCst), 2); - - // Should have text deltas from the second round - let text_deltas: Vec<_> = events - .iter() - .filter_map(|e| match e { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["The weather in SF is 72F"]); - - // The final response should be the text response - assert!(result.response().is_some()); - assert_eq!( - result.response().unwrap().text(), - "The weather in SF is 72F" - ); - } - - #[tokio::test] - async fn stream_no_tool_loop_when_max_rounds_zero() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(0), - ) - .await - .unwrap(); - - // Consume all events - while result.next().await.is_some() {} - - // Only one stream call, no tool execution - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn stream_accumulator_handles_step_finish() { - let mut acc = StreamAccumulator::new(); - - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("tool step"), - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - - let tool_calls = vec![ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )]; - - let tool_results = vec![ToolResult::success("call_1", serde_json::json!("72F"))]; - - // Processing StepFinish should not panic and should not set the final response - acc.process(&StreamEvent::step_finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - tool_calls, - tool_results, - )); - - // StepFinish should not set the final response (only Finish does that) - assert!(acc.response().is_none()); - assert_eq!(acc.text(), ""); - } - - #[tokio::test] - async fn stream_with_tool_loop_emits_step_finish() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have a StepFinish event between the tool call round and text round - let step_finish_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::StepFinish { .. }))) - .count(); - assert_eq!( - step_finish_count, 1, - "Expected exactly one StepFinish event" - ); - - // Verify StepFinish contents - let step_finish = events - .iter() - .find_map(|e| match e { - Ok(StreamEvent::StepFinish { - finish_reason, - tool_calls, - tool_results, - .. - }) => Some((finish_reason, tool_calls, tool_results)), - _ => None, - }) - .expect("StepFinish event should exist"); - - assert_eq!(*step_finish.0, FinishReason::ToolCalls); - assert_eq!(step_finish.1.len(), 1); - assert_eq!(step_finish.1[0].name, "get_weather"); - assert_eq!(step_finish.2.len(), 1); - assert_eq!(step_finish.2[0].tool_call_id, "call_1"); - } - - #[tokio::test] - async fn stream_stop_when_halts_streaming_tool_loop() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(StreamingToolCallMockProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather in SF?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5) - .stop_when(|_steps| true), // Stop immediately after first round - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // stop_when returned true, so only 1 stream call should have been made - assert_eq!(call_count.load(Ordering::SeqCst), 1); - - // Should have a StepFinish event but no second round text - let step_finish_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::StepFinish { .. }))) - .count(); - assert_eq!( - step_finish_count, 1, - "Expected StepFinish event from stopped round" - ); - - // Should NOT have any text deltas (second round never started) - let text_delta_count = events - .iter() - .filter(|e| matches!(e, Ok(StreamEvent::TextDelta { .. }))) - .count(); - assert_eq!( - text_delta_count, 0, - "Expected no text deltas since loop was stopped" - ); - } - - /// Mock provider that fails on stream N times then succeeds - struct FailThenStreamProvider { - call_count: Arc, - failures: u32, - } - - #[async_trait::async_trait] - impl ProviderAdapter for FailThenStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count < self.failures { - return Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("server error", "mock") - }), - }); - } - - let text = "Hello after retry"; - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 20, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - response.usage.clone(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - #[tokio::test] - async fn stream_retry_on_initial_connection() { - let call_count = Arc::new(AtomicU32::new(0)); - let provider: Arc = Arc::new(FailThenStreamProvider { - call_count: call_count.clone(), - failures: 2, // fail twice, succeed on third - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - // Need active tools so the tool loop path (with retry) is used - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(1) - .max_retries(3), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have called stream 3 times (2 failures + 1 success) - assert_eq!(call_count.load(Ordering::SeqCst), 3); - - // Should have received the text from the successful attempt - let text_deltas: Vec<_> = events - .iter() - .filter_map(|e| match e { - Ok(StreamEvent::TextDelta { delta, .. }) => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello after retry"]); - } - - /// Mock provider that delays before returning stream - struct SlowStreamProvider { - delay: std::time::Duration, - } - - #[async_trait::async_trait] - impl ProviderAdapter for SlowStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - sleep(self.delay).await; - let text = "Slow response"; - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - - #[tokio::test] - async fn stream_per_step_timeout() { - let provider: Arc = Arc::new(SlowStreamProvider { - delay: std::time::Duration::from_secs(5), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - // Need active tools so the tool loop path (with timeout) is used - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("Hi") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(1) - .timeout(TimeoutOptions { - total: None, - per_step: Some(0.01), // 10ms timeout, provider takes 5s - }) - .max_retries(0), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have received a timeout error - let has_timeout = events - .iter() - .any(|e| matches!(e, Err(Error::RequestTimeout { .. }))); - assert!(has_timeout, "Expected a RequestTimeout error"); - } - - #[tokio::test] - async fn stream_total_timeout() { - // Use a streaming tool call provider with a slow tool to trigger total timeout - // across multiple rounds - /// Provider that always returns tool calls with a delay on the second - /// stream - struct SlowToolCallStreamProvider { - call_count: Arc, - } - - #[async_trait::async_trait] - impl ProviderAdapter for SlowToolCallStreamProvider { - fn name(&self) -> &'static str { - "mock" - } - - async fn complete(&self, _request: &Request) -> Result { - Ok(Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant("fallback"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }) - } - - async fn stream(&self, _request: &Request) -> Result { - let count = self.call_count.fetch_add(1, Ordering::SeqCst); - - if count == 0 { - // First stream: return tool call quickly - let tool_call = - ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "SF"})); - let response = Response { - id: "resp_1".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call.clone())], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::ToolCallEnd { tool_call }), - Ok(StreamEvent::finish( - FinishReason::ToolCalls, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } else { - // Second stream: delay long enough to exceed total timeout - sleep(std::time::Duration::from_secs(5)).await; - let text = "Should not arrive"; - let response = Response { - id: "resp_2".into(), - model: "mock-model".into(), - provider: "mock".into(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let events = vec![ - Ok(StreamEvent::text_delta(text, Some("t1".into()))), - Ok(StreamEvent::finish( - FinishReason::Stop, - TokenCounts::default(), - response, - )), - ]; - Ok(Box::pin(stream::iter(events))) - } - } - } - - let call_count = Arc::new(AtomicU32::new(0)); - - let provider: Arc = Arc::new(SlowToolCallStreamProvider { - call_count: call_count.clone(), - }); - - let mut providers: HashMap> = HashMap::new(); - providers.insert("mock".to_string(), provider); - let client = Arc::new(Client::new(providers, Some("mock".to_string()), vec![])); - - let mut result = stream( - GenerateParams::new("mock-model", client) - .prompt("What's the weather?") - .tools(vec![Tool::active( - "get_weather", - "Get weather", - serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}), - |_args, _ctx| async { Ok(serde_json::json!("72F")) }, - )]) - .max_tool_rounds(5) - .timeout(TimeoutOptions { - total: Some(0.05), // 50ms total timeout - per_step: None, - }) - .max_retries(0), - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Some(item) = result.next().await { - events.push(item); - } - - // Should have received a total timeout error - let has_timeout = events - .iter() - .any(|e| matches!(e, Err(Error::RequestTimeout { .. }))); - assert!( - has_timeout, - "Expected a RequestTimeout error from total timeout" - ); - } -} diff --git a/lib/components/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs index 0b33bfece..1f1830f3a 100644 --- a/lib/components/fabro-llm/src/lib.rs +++ b/lib/components/fabro-llm/src/lib.rs @@ -1,23 +1,54 @@ -pub mod adapter_registry; -mod attachments; -pub mod client; -mod codec; -pub(crate) mod cost; -pub mod error; -pub mod generate; -pub mod middleware; -pub mod model_test; -pub mod provider; -pub mod providers; -mod reasoning; -pub mod retry; -pub mod token_count; -pub mod tools; -pub(crate) mod transport; -pub mod types; +//! Fabro's integration layer over [`lithos_llm`]. +//! +//! lithos owns the LLM vocabulary, the provider catalog, the wire codecs, and +//! the client. This crate adds what is specific to Fabro: +//! +//! - building the catalog from lithos built-ins, Fabro's policy layer, and the +//! operator `[llm]` overlay ([`catalog`]); +//! - enforcing Fabro policy (`enabled`, `small_default`, `probe`) at model +//! selection ([`resolver`]), and the same passthrough policy for selections +//! made before a request exists ([`selection`]); +//! - constructing a client from a Fabro credential source ([`client`]); +//! - inlining local file attachments ([`attachments`]); +//! - normalizing readable reasoning into [`fabro_types::ReasoningOutput`] +//! ([`reasoning`]); +//! - one-shot structured output ([`structured`]); +//! - model and provider probes ([`probe`]), and the API views of the catalog +//! ([`api`]); +//! - the `fabro exec` gateway adapter that speaks to a Fabro server +//! ([`gateway`]); +//! - error classification for retries, failover, and failure signatures +//! ([`error`]). -pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result}; -pub use fabro_model::{ModelHandle, ProviderId}; -pub use token_count::{ - InputTokenCount, InputTokenCountMethod, InputTokenCountPreference, estimate_input_tokens, +pub mod api; +pub mod attachments; +pub mod catalog; +pub mod client; +pub mod error; +pub mod gateway; +pub mod probe; +pub mod reasoning; +pub mod resolver; +pub mod selection; +pub mod structured; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use catalog::{FABRO_POLICY_TOML, build_catalog, default_catalog}; +pub use client::{ + ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client, + build_offline_client, }; +pub use error::{ErrorFacts, LlmError}; +pub use lithos_llm::client::{Client, ClientBuild}; +pub use lithos_llm::middleware::{CallContext, CancellationToken, RetryPolicy, RetryStage}; +pub use lithos_llm::resolver::ModelSelectionError as RouteSelectionError; +pub use lithos_llm::types::{ + Error, ErrorData, ErrorKind, FinishReason, Request, Response, ResponseStream, + RetryClassification, StreamEvent, +}; +pub use lithos_llm::{ + adapter, catalog as lithos_catalog, credentials, estimate, middleware, types, +}; +pub use resolver::FabroResolver; +pub use selection::{FallbackTarget, ModelSelectionError, SelectedModel}; diff --git a/lib/components/fabro-llm/src/middleware.rs b/lib/components/fabro-llm/src/middleware.rs deleted file mode 100644 index ba8fef42d..000000000 --- a/lib/components/fabro-llm/src/middleware.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::types::{Request, Response}; - -/// The next handler in the middleware chain. -pub type NextFn = Arc< - dyn Fn(Request) -> Pin> + Send>> + Send + Sync, ->; - -/// The next handler for streaming. -pub type NextStreamFn = Arc< - dyn Fn(Request) -> Pin> + Send>> - + Send - + Sync, ->; - -/// Middleware for intercepting `complete()` and streaming calls (Section 2.3). -#[async_trait::async_trait] -pub trait Middleware: Send + Sync { - async fn handle_complete(&self, request: Request, next: NextFn) -> Result; - - async fn handle_stream( - &self, - request: Request, - next: NextStreamFn, - ) -> Result; -} diff --git a/lib/components/fabro-llm/src/model_test.rs b/lib/components/fabro-llm/src/model_test.rs deleted file mode 100644 index 7498e5e64..000000000 --- a/lib/components/fabro-llm/src/model_test.rs +++ /dev/null @@ -1,400 +0,0 @@ -use std::future::Future; -use std::sync::Arc; -use std::time::Duration; - -use fabro_model::Model; -pub use fabro_model::ModelTestMode; -use strum::IntoStaticStr; -use tokio::time; - -use crate::client::Client; -use crate::generate::{self, GenerateParams}; -use crate::tools::Tool; -use crate::types::{GenerateResult, ReasoningEffort}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] -#[strum(serialize_all = "lowercase")] -pub enum ModelTestStatus { - Ok, - Error, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ModelTestOutcome { - pub status: ModelTestStatus, - pub error_message: Option, -} - -impl ModelTestOutcome { - #[must_use] - pub fn ok() -> Self { - Self { - status: ModelTestStatus::Ok, - error_message: None, - } - } - - #[must_use] - pub fn error(message: impl Into) -> Self { - Self { - status: ModelTestStatus::Error, - error_message: Some(message.into()), - } - } -} - -pub async fn run_model_test( - info: &Model, - mode: ModelTestMode, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - match mode { - ModelTestMode::Basic => run_basic_test(info, reasoning_effort, client).await, - ModelTestMode::Deep => run_tools_test(info, reasoning_effort, client).await, - } -} - -/// Output budget for tests where reasoning or tool rounds consume completion -/// tokens before the final answer. -const EXPANDED_MAX_TOKENS: i64 = 1024; - -async fn run_basic_test( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - basic_probe( - info.id.as_str(), - info.provider.to_string(), - reasoning_effort, - client, - Duration::from_secs(ModelTestMode::Basic.timeout_secs()), - ) - .await -} - -/// Run the cheap single-prompt model availability probe without requiring a -/// catalog-backed [`Model`]. -pub async fn run_basic_model_probe( - model_id: &str, - provider: impl ToString, - client: Arc, -) -> ModelTestOutcome { - run_basic_model_probe_with_timeout( - model_id, - provider, - client, - Duration::from_secs(ModelTestMode::Basic.timeout_secs()), - ) - .await -} - -pub async fn run_basic_model_probe_with_timeout( - model_id: &str, - provider: impl ToString, - client: Arc, - probe_timeout: Duration, -) -> ModelTestOutcome { - basic_probe(model_id, provider.to_string(), None, client, probe_timeout).await -} - -async fn basic_probe( - model_id: &str, - provider: String, - reasoning_effort: Option, - client: Arc, - probe_timeout: Duration, -) -> ModelTestOutcome { - let params = build_basic_test_params(model_id, provider, reasoning_effort, client); - basic_model_probe_outcome(generate::generate(params), probe_timeout).await -} - -fn build_basic_test_params( - model_id: &str, - provider: String, - reasoning_effort: Option, - client: Arc, -) -> GenerateParams { - let max_tokens = if reasoning_effort.is_some() { - EXPANDED_MAX_TOKENS - } else { - 16 - }; - let mut params = GenerateParams::new(model_id, client) - .provider(provider) - .prompt("Say OK") - .max_tokens(max_tokens); - - if let Some(reasoning_effort) = reasoning_effort { - params = params.reasoning_effort(reasoning_effort); - } - - params -} - -async fn basic_model_probe_outcome(probe: F, probe_timeout: Duration) -> ModelTestOutcome -where - F: Future>, -{ - match time::timeout(probe_timeout, probe).await { - Ok(Ok(_)) => ModelTestOutcome::ok(), - Ok(Err(err)) => ModelTestOutcome::error(err.to_string()), - Err(_) => ModelTestOutcome::error(format!("timeout ({probe_timeout:?})")), - } -} - -async fn run_tools_test( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> ModelTestOutcome { - let Some(params) = build_tools_test_params(info, reasoning_effort, client) else { - return ModelTestOutcome::error("model does not support tools"); - }; - - let result = time::timeout( - Duration::from_secs(ModelTestMode::Deep.timeout_secs()), - generate::generate(params), - ) - .await; - - match result { - Ok(Ok(gen_result)) => match validate_tools_result(&gen_result) { - Ok(()) => ModelTestOutcome::ok(), - Err(message) => ModelTestOutcome::error(message), - }, - Ok(Err(err)) => ModelTestOutcome::error(err.to_string()), - Err(_) => ModelTestOutcome::error("timeout (90s)"), - } -} - -fn build_tools_test_params( - info: &Model, - reasoning_effort: Option, - client: Arc, -) -> Option { - if !info.features.tools { - return None; - } - - let add_tool = Tool::active( - "add", - "Add two integers and return the sum", - serde_json::json!({ - "type": "object", - "properties": { - "a": { "type": "integer", "description": "First number" }, - "b": { "type": "integer", "description": "Second number" } - }, - "required": ["a", "b"] - }), - |args, _ctx| async move { - let a = args - .get("a") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let b = args - .get("b") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - Ok(serde_json::json!(a + b)) - }, - ); - - let mut params = GenerateParams::new(info.id.to_string(), client) - .provider(info.provider.to_string()) - .prompt( - "Use the add tool twice: first add 15 and 27, then add that result to 42. \ - Finally, tell me whether the grand total is even or odd and why.", - ) - .tools(vec![add_tool]) - .max_tool_rounds(5) - .max_tokens(EXPANDED_MAX_TOKENS); - - if let Some(reasoning_effort) = reasoning_effort { - params = params.reasoning_effort(reasoning_effort); - } - - Some(params) -} - -fn validate_tools_result(result: &GenerateResult) -> Result<(), String> { - if result.steps.len() < 2 { - return Err("model did not call tool".to_string()); - } - - if result.steps[0].tool_results.is_empty() { - return Err("tool was not executed".to_string()); - } - - if !result.response.text().contains("84") { - return Err("wrong answer".to_string()); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use fabro_model::{ - ModelControls, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, - }; - - use super::*; - use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts, ToolResult}; - - fn test_model_with(features: ModelFeatures) -> Model { - Model { - id: "test-model".into(), - provider: ProviderId::anthropic(), - family: "test".to_string(), - display_name: "Test Model".to_string(), - limits: ModelLimits { - context_window: 200_000, - max_output: Some(8_000), - }, - training: None, - knowledge_cutoff: None, - features, - controls: ModelControls::default(), - costs: ModelCosts { - input_cost_per_mtok: None, - output_cost_per_mtok: None, - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: vec![], - default: false, - small_default: false, - configured: false, - } - } - - fn response_with_text(text: &str) -> Response { - Response { - id: "resp_1".to_string(), - model: "test-model".to_string(), - provider: "anthropic".to_string(), - message: Message::assistant(text), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - } - } - - fn empty_test_client() -> Arc { - Arc::new(Client::new(HashMap::new(), None, vec![])) - } - - #[tokio::test] - async fn run_model_test_tools_errors_when_model_lacks_tools() { - let info = test_model_with(ModelFeatures { - tools: false, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: false, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let outcome = run_model_test(&info, ModelTestMode::Deep, None, empty_test_client()).await; - - assert_eq!(outcome.status, ModelTestStatus::Error); - assert_eq!( - outcome.error_message.as_deref(), - Some("model does not support tools") - ); - } - - #[tokio::test] - async fn basic_model_probe_reports_configured_timeout() { - let outcome = basic_model_probe_outcome( - std::future::pending::>(), - Duration::from_millis(1), - ) - .await; - - assert_eq!(outcome.status, ModelTestStatus::Error); - assert_eq!(outcome.error_message.as_deref(), Some("timeout (1ms)")); - } - - #[test] - fn basic_test_expands_output_budget_for_reasoning() { - let params = build_basic_test_params( - "test-model", - "anthropic".to_string(), - Some(ReasoningEffort::Max), - empty_test_client(), - ); - - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Max)); - assert_eq!(params.max_tokens, Some(1024)); - } - - #[test] - fn tools_test_omits_effort_when_not_requested() { - let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let params = build_tools_test_params(&info, None, empty_test_client()) - .expect("tool-capable model should produce tools-test params"); - - assert_eq!(params.reasoning_effort, None); - } - - #[test] - fn tools_test_uses_requested_effort() { - let info = test_model_with(ModelFeatures { - tools: true, - vision: false, - reasoning: true, - reasoning_effort: ReasoningEffortFeature::Levels, - prompt_cache: true, - cache_control_breakpoints: false, - sampling_params: true, - }); - - let params = - build_tools_test_params(&info, Some(ReasoningEffort::Low), empty_test_client()) - .expect("tool-capable model should produce tools-test params"); - - assert_eq!(params.reasoning_effort, Some(ReasoningEffort::Low)); - } - - #[test] - fn validate_tools_result_does_not_fail_only_for_missing_reasoning() { - let tool_results = vec![ToolResult::success("call_1", serde_json::json!(42))]; - let first_step = StepResult { - response: response_with_text("tool step"), - tool_results: tool_results.clone(), - }; - let second_step = StepResult { - response: response_with_text("84 is even"), - tool_results: vec![], - }; - let result = GenerateResult { - response: response_with_text("84 is even"), - tool_results, - total_usage: TokenCounts::default(), - steps: vec![first_step, second_step], - output: None, - }; - - assert_eq!(validate_tools_result(&result), Ok(())); - } -} diff --git a/lib/components/fabro-llm/src/probe.rs b/lib/components/fabro-llm/src/probe.rs new file mode 100644 index 000000000..0d52e5aa1 --- /dev/null +++ b/lib/components/fabro-llm/src/probe.rs @@ -0,0 +1,129 @@ +//! Model and provider probes for the server's test endpoints. + +use std::sync::Arc; +use std::time::Duration; + +use fabro_auth::ApiKeyCredentialSource; +use fabro_types::{ModelTestMode, ProviderId, ReasoningEffort}; +use lithos_llm::catalog::Catalog; +use lithos_llm::client::{Client, ProbeOptions, ProbeOutcome}; +use strum::IntoStaticStr; + +use crate::catalog; +use crate::client::{ClientOptions, LlmSetupError, build_client}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] +#[strum(serialize_all = "lowercase")] +pub enum ModelTestStatus { + Ok, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelTestOutcome { + pub status: ModelTestStatus, + pub error_message: Option, +} + +impl ModelTestOutcome { + #[must_use] + pub fn ok() -> Self { + Self { + status: ModelTestStatus::Ok, + error_message: None, + } + } + + #[must_use] + pub fn error(message: impl Into) -> Self { + Self { + status: ModelTestStatus::Error, + error_message: Some(message.into()), + } + } +} + +/// Probes `selector` (a `provider/model` route or bare selector) in `mode`. +/// +/// `Basic` asks for one word; `Deep` runs a two-step tool exchange and checks +/// the total. The probe resolves, authenticates, and retries exactly as a +/// real request would. +pub async fn run_model_test( + client: &Client, + selector: &str, + mode: ModelTestMode, + reasoning_effort: Option, + timeout: Option, +) -> ModelTestOutcome { + let mut options = ProbeOptions::new() + .tools(mode == ModelTestMode::Deep) + .timeout(timeout.unwrap_or_else(|| Duration::from_secs(mode.timeout_secs()))); + if let Some(effort) = reasoning_effort { + options = options.reasoning_effort(effort); + } + let report = client.probe(selector, options).await; + match report.outcome { + ProbeOutcome::Passed => ModelTestOutcome::ok(), + ProbeOutcome::Failed(data) => ModelTestOutcome::error(data.message), + ProbeOutcome::Incorrect { detail } => ModelTestOutcome::error(detail), + _ => ModelTestOutcome::error("probe ended in an unknown state"), + } +} + +/// A basic probe of `selector` bounded by `timeout`. +pub async fn run_basic_probe( + client: &Client, + selector: &str, + timeout: Duration, +) -> ModelTestOutcome { + run_model_test(client, selector, ModelTestMode::Basic, None, Some(timeout)).await +} + +/// Why an API key could not be probed. +#[derive(Debug, thiserror::Error)] +pub enum ApiKeyProbeError { + #[error("provider '{0}' is not configured in the model catalog")] + UnknownProvider(String), + #[error("provider '{0}' does not define an API-key credential path")] + NoApiKeyPath(ProviderId), + #[error("provider '{0}' does not define a probe model")] + NoProbeModel(ProviderId), + #[error(transparent)] + Setup(#[from] LlmSetupError), +} + +/// Probes `provider` with an operator-supplied `api_key`, the check behind +/// `fabro provider login`, the install flow, and the credential test API. +/// +/// The key is shaped into the provider's auth scheme and used for the +/// provider's probe model. The result says whether the key works; the caller +/// decides whether to store it. +pub async fn probe_provider_with_api_key( + catalog: Catalog, + provider: &ProviderId, + api_key: String, + timeout: Duration, +) -> Result { + let entry = catalog::provider(&catalog, provider.as_str()) + .ok_or_else(|| ApiKeyProbeError::UnknownProvider(provider.to_string()))?; + let provider_id = entry.provider.id().clone(); + if !fabro_auth::accepts_api_key(entry.provider) { + return Err(ApiKeyProbeError::NoApiKeyPath(provider_id)); + } + let model = catalog::probe_model(&catalog, provider_id.as_str()) + .ok_or_else(|| ApiKeyProbeError::NoProbeModel(provider_id.clone()))?; + let selector = format!("{provider_id}/{}", model.model.id()); + let source = Arc::new(ApiKeyCredentialSource::new(provider_id.clone(), api_key)); + let built = build_client(catalog, source, ClientOptions::standard()).await?; + if let Some((_, issue)) = built + .auth_issues + .iter() + .find(|(candidate, _)| candidate == &provider_id) + { + return Ok(ModelTestOutcome::error(fabro_auth::auth_issue_message( + &provider_id, + issue, + ))); + } + Ok(run_basic_probe(&built.client, &selector, timeout).await) +} diff --git a/lib/components/fabro-llm/src/provider.rs b/lib/components/fabro-llm/src/provider.rs deleted file mode 100644 index b49a70cdd..000000000 --- a/lib/components/fabro-llm/src/provider.rs +++ /dev/null @@ -1,185 +0,0 @@ -use std::pin::Pin; - -pub use fabro_model::{ModelHandle, ProviderId}; -use futures::Stream; - -use crate::error::Error; -use crate::token_count::InputTokenCount; -use crate::types::{Request, Response, Speed, StreamEvent, ToolChoice}; - -// --------------------------------------------------------------------------- -// ProviderAdapter trait -// --------------------------------------------------------------------------- - -/// Async stream of `StreamEvents` returned by streaming providers. -pub type StreamEventStream = Pin> + Send>>; - -/// The contract that every provider adapter must implement (Section 2.4). -#[async_trait::async_trait] -pub trait ProviderAdapter: Send + Sync { - /// Provider name, e.g. "openai", "anthropic", "gemini" - fn name(&self) -> &str; - - /// Send a request and block until the model finishes (Section 4.1). - async fn complete(&self, request: &Request) -> Result; - - /// Send a request and return an async stream of events (Section 4.2). - async fn stream(&self, request: &Request) -> Result; - - /// Count model-visible input/context tokens without creating a completion, - /// when the provider exposes a count endpoint. - async fn count_input_tokens( - &self, - _request: &Request, - ) -> Result, Error> { - Ok(None) - } - - /// Release resources. Called by `Client::close()`. - async fn close(&self) -> Result<(), Error> { - Ok(()) - } - - /// Validate configuration on startup. Called by Client on registration. - async fn initialize(&self) -> Result<(), Error> { - Ok(()) - } - - /// Query whether a particular tool choice mode is supported. - fn supports_tool_choice(&self, _mode: &str) -> bool { - true - } - - /// Validate the final request before dispatching it to the provider API. - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - let mode = tool_choice.mode_str(); - if !self.supports_tool_choice(mode) { - return Err(Error::UnsupportedToolChoice { - message: format!( - "provider '{}' does not support tool_choice mode '{mode}'", - self.name() - ), - }); - } - } - Ok(()) - } -} - -/// Validate that the adapter supports the requested tool choice mode. -/// -/// Returns `Err(Error::UnsupportedToolChoice)` if the adapter does not -/// support the given mode. -/// -/// # Errors -/// -/// Returns `Error::UnsupportedToolChoice` when the adapter does not -/// support the requested tool choice mode. -pub fn validate_tool_choice( - adapter: &dyn ProviderAdapter, - tool_choice: &ToolChoice, -) -> Result<(), Error> { - let mode = tool_choice.mode_str(); - if !adapter.supports_tool_choice(mode) { - return Err(Error::UnsupportedToolChoice { - message: format!( - "provider '{}' does not support tool_choice mode '{mode}'", - adapter.name() - ), - }); - } - Ok(()) -} - -/// Validate that an adapter without provider-native speed controls only sees -/// standard-speed requests. -pub fn validate_standard_speed( - adapter: &dyn ProviderAdapter, - request: &Request, -) -> Result<(), Error> { - if let Some(speed) = request.speed.filter(|speed| *speed != Speed::Standard) { - return Err(Error::Configuration { - message: format!( - "provider '{}' does not support speed '{}'", - adapter.name(), - speed - ), - source: None, - }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Mock adapter that supports all tool choices - struct MockAdapter; - - #[async_trait::async_trait] - impl ProviderAdapter for MockAdapter { - fn name(&self) -> &'static str { - "mock" - } - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - } - - // Mock adapter that rejects "named" tool choice - struct RestrictedAdapter; - - #[async_trait::async_trait] - impl ProviderAdapter for RestrictedAdapter { - fn name(&self) -> &'static str { - "restricted" - } - async fn complete(&self, _request: &Request) -> Result { - unimplemented!() - } - async fn stream(&self, _request: &Request) -> Result { - unimplemented!() - } - fn supports_tool_choice(&self, mode: &str) -> bool { - mode != "named" - } - } - - #[test] - fn validate_tool_choice_auto_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::Auto).is_ok()); - } - - #[test] - fn validate_tool_choice_none_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::None).is_ok()); - } - - #[test] - fn validate_tool_choice_required_accepted() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::Required).is_ok()); - } - - #[test] - fn validate_tool_choice_named_rejected_by_restricted() { - let result = validate_tool_choice(&RestrictedAdapter, &ToolChoice::named("my_tool")); - assert!(result.is_err()); - match result.unwrap_err() { - Error::UnsupportedToolChoice { message } => { - assert!(message.contains("restricted")); - assert!(message.contains("named")); - } - other => panic!("expected UnsupportedToolChoice, got {other:?}"), - } - } - - #[test] - fn validate_tool_choice_named_accepted_by_default() { - assert!(validate_tool_choice(&MockAdapter, &ToolChoice::named("my_tool")).is_ok()); - } -} diff --git a/lib/components/fabro-llm/src/providers/anthropic.rs b/lib/components/fabro-llm/src/providers/anthropic.rs deleted file mode 100644 index 723e12441..000000000 --- a/lib/components/fabro-llm/src/providers/anthropic.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::sync::Arc; - -use fabro_model::{Catalog, ReasoningEffortFeature}; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option}; -use crate::codec::{AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; - -/// Provider adapter for the Anthropic Messages API. -/// -/// A thin transport shell over the `anthropic_messages` codec: it owns auth, -/// base URL, the streaming byte loop, and the route configuration that selects -/// between the direct-Anthropic and Kimi-over-anthropic behaviors. All wire -/// translation lives in the codec. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - provider_name: "anthropic".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Resolve the route configuration for this adapter. - /// - /// The direct-Anthropic route (`provider_name == "anthropic"`) - /// authenticates with `x-api-key`, emits the version + beta headers, - /// and supports the count-tokens endpoint. Every other name (e.g. - /// Kimi-over-anthropic) is a bearer-auth route with no anthropic - /// headers, no count-tokens route, and blocking requests served via - /// streaming. Resolved once here instead of string-comparing - /// `provider_name` at each request-time decision. - fn route_config(&self) -> RouteConfig { - if self.provider_name == "anthropic" { - RouteConfig { - auth: AuthScheme::ApiKey, - codec_params: CodecParams { - anthropic_version: AnthropicVersion::Header("2023-06-01"), - anthropic_beta: true, - ..CodecParams::default() - }, - supports_count_tokens: true, - force_streaming: false, - } - } else { - RouteConfig { - auth: AuthScheme::Bearer, - codec_params: CodecParams::default(), - supports_count_tokens: false, - force_streaming: true, - } - } - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // Anthropic loads images and documents inline; audio falls back to a - // text placeholder in the codec, so it is not loaded here. - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Apply the route base URL, auth, and codec-emitted dialect headers to an - /// encoded request. - fn build_http_request( - &self, - encoded: &EncodedRequest, - route: &RouteConfig, - ) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - // default_headers first so codec/auth headers can override. - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - match route.auth { - AuthScheme::ApiKey => { - if let Some(api_key) = &self.http.api_key { - req = req.header("x-api-key", api_key); - } - } - AuthScheme::Bearer => { - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - } - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } - - /// Collect a streaming response into a single [`Response`]. - /// - /// Used by non-Anthropic providers (e.g. Moonshot) that require - /// `stream=true`. - async fn complete_via_stream(&self, request: &Request) -> Result { - use futures::StreamExt; - - let mut stream = self.stream(request).await?; - let mut response: Option = None; - - while let Some(event) = stream.next().await { - if let StreamEvent::Finish { response: r, .. } = event? { - response = Some(*r); - } - } - - response.ok_or_else(|| Error::Stream { - message: "complete_via_stream: stream ended without a Finish event".to_string(), - source: None, - }) - } -} - -/// Resolved per-request routing decisions (auth, dialect headers, optional -/// routes) that used to be inline `provider_name == "anthropic"` branches. -struct RouteConfig { - auth: AuthScheme, - codec_params: CodecParams, - supports_count_tokens: bool, - force_streaming: bool, -} - -enum AuthScheme { - ApiKey, - Bearer, -} - -/// The `provider_options.anthropic.thinking.type` value, if any. -fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Option<&str> { - anthropic_option(provider_options, "thinking") - .and_then(|thinking| thinking.get("type")) - .and_then(serde_json::Value::as_str) -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - let route = self.route_config(); - if !route.supports_count_tokens { - return Ok(None); - } - - self.validate_request(request)?; - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded, &route); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let route = self.route_config(); - // Non-Anthropic providers (e.g. Moonshot) require stream=true even for - // blocking calls. Collect the stream into a single Response. - if route.force_streaming { - return self.complete_via_stream(request).await; - } - - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded, &route); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let route = self.route_config(); - let resolved = self.resolve_request(request).await; - let codec = AnthropicMessages; - let deployment_id = self.api_model_id(&resolved.model); - let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded, &route), - &codec, - &ctx, - SseFraming::EventBlocks, - self.http.stream_read_timeout, - ) - .await - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - matches!(mode, "auto" | "none" | "required" | "named") - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - provider::validate_tool_choice(self, tool_choice)?; - } - - // Always-adaptive models reject manual enabled/disabled thinking - // configs at the API, so fail them locally with a clear message - // instead. - let model_info = self.catalog_model(&request.model); - if let Some(model) = model_info - .filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive) - { - if let Some(kind @ ("enabled" | "disabled")) = - anthropic_thinking_type(request.provider_options.as_ref()) - { - return Err(Error::Configuration { - message: format!( - "{} uses always-on adaptive thinking; provider_options.anthropic.thinking.type = \"{kind}\" is not supported. Omit thinking or set only display options.", - model.display_name() - ), - source: None, - }); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use httpmock::prelude::*; - - use super::*; - use crate::token_count::InputTokenCountMethod; - use crate::types::{Message, ToolDefinition}; - - fn make_base_request() -> Request { - Request { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![Message::user("Hello")], - provider: Some("anthropic".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[test] - fn adapter_with_name() { - let adapter = Adapter::new("key").with_name("moonshot"); - assert_eq!(adapter.name(), "moonshot"); - } - - #[test] - fn adapter_default_name() { - let adapter = Adapter::new("key"); - assert_eq!(adapter.name(), "anthropic"); - } - - #[tokio::test] - async fn count_input_tokens_posts_count_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/messages/count_tokens") - .header("x-api-key", "test-key") - .header("anthropic-version", "2023-06-01"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"input_tokens": 123})); - }); - let adapter = Adapter::new("test-key").with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object"}), - )]), - ..make_base_request() - }; - - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("anthropic should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs b/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs deleted file mode 100644 index bb4015eff..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/eventstream.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Decoder for Bedrock's `application/vnd.amazon.eventstream` streaming -//! responses. -//! -//! ConverseStream wraps each event in a binary event-stream frame: the event -//! name (`messageStart`, `contentBlockDelta`, `metadata`, ...) travels in the -//! frame's `:event-type` header and the payload is that event's JSON -//! directly. (The base64 `{"bytes": ...}` wrapping belongs to -//! `InvokeModelWithResponseStream`'s `PayloadPart` and does not apply here.) -//! Exception and error frames are surfaced as stream errors. - -use aws_smithy_eventstream::frame::{DecodedFrame, MessageFrameDecoder}; -use aws_smithy_types::event_stream::Message; -use aws_smithy_types::str_bytes::StrBytes; -use bytes::BytesMut; - -use crate::error::Error; - -/// One decoded ConverseStream event: the `:event-type` header value plus the -/// frame's JSON payload, ready to feed a stream decoder. -#[derive(Debug)] -pub(crate) struct DecodedEvent { - pub event_type: String, - pub payload: String, -} - -/// Incremental decoder over event-stream bytes. -pub(crate) struct FrameDecoder { - inner: MessageFrameDecoder, - buffer: BytesMut, -} - -impl FrameDecoder { - pub(crate) fn new() -> Self { - Self { - inner: MessageFrameDecoder::new(), - buffer: BytesMut::new(), - } - } - - /// Feed newly received bytes and return any complete events decoded from - /// them. Bedrock exception and error frames are surfaced as errors. - pub(crate) fn push(&mut self, bytes: &[u8]) -> Result, Error> { - self.buffer.extend_from_slice(bytes); - let mut events = Vec::new(); - loop { - // `decode_frame` advances `self.buffer` and retains partial-frame - // state internally, so repeated calls over a growing buffer work. - let frame = self.inner.decode_frame(&mut self.buffer).map_err(|e| { - Error::stream_error( - format!("bedrock event-stream decode: {e}"), - std::io::Error::other(e.to_string()), - ) - })?; - match frame { - DecodedFrame::Complete(message) => { - if let Some(event) = Self::message_to_event(&message)? { - events.push(event); - } - } - DecodedFrame::Incomplete => break, - } - } - Ok(events) - } - - /// Classify one event-stream message. - /// - /// `event` frames yield their `:event-type` name and JSON payload; - /// `exception` frames (modeled AWS errors such as `throttlingException`, - /// arriving in-band after HTTP 200) and `error` frames (unmodeled) are - /// turned into errors. Frames without an event type are skipped. - fn message_to_event(message: &Message) -> Result, Error> { - match header_str(message, ":message-type") { - Some("exception") => { - let kind = header_str(message, ":exception-type").unwrap_or("unknown"); - let body = String::from_utf8_lossy(message.payload()); - Err(Error::stream_error( - format!("bedrock stream exception ({kind}): {body}"), - std::io::Error::other("bedrock event-stream exception frame"), - )) - } - Some("error") => { - let code = header_str(message, ":error-code").unwrap_or("unknown"); - let detail = header_str(message, ":error-message").unwrap_or(""); - Err(Error::stream_error( - format!("bedrock stream error ({code}): {detail}"), - std::io::Error::other("bedrock event-stream error frame"), - )) - } - _ => { - let Some(event_type) = header_str(message, ":event-type") else { - return Ok(None); - }; - Ok(Some(DecodedEvent { - event_type: event_type.to_string(), - payload: String::from_utf8_lossy(message.payload()).into_owned(), - })) - } - } - } -} - -/// Read a string-valued event-stream header by name. -fn header_str<'a>(message: &'a Message, name: &str) -> Option<&'a str> { - message - .headers() - .iter() - .find(|header| header.name().as_str() == name) - .and_then(|header| header.value().as_string().ok()) - .map(StrBytes::as_str) -} - -#[cfg(test)] -pub(crate) mod tests { - use aws_smithy_eventstream::frame::write_message_to; - use aws_smithy_types::event_stream::{Header, HeaderValue, Message}; - - use super::*; - - /// Build one ConverseStream event frame: event name in `:event-type`, - /// payload = the event JSON directly. - fn encode_event_frame(event_type: &str, payload_json: &str) -> Vec { - let message = Message::new(payload_json.as_bytes().to_vec()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("event".into()), - )) - .add_header(Header::new( - ":event-type", - HeaderValue::String(event_type.to_string().into()), - )) - .add_header(Header::new( - ":content-type", - HeaderValue::String("application/json".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - buf - } - - /// Build a full streaming body from `(event_type, payload_json)` pairs. - pub(crate) fn build_stream_body(events: &[(&str, &str)]) -> Vec { - let mut body = Vec::new(); - for (event_type, payload) in events { - body.extend_from_slice(&encode_event_frame(event_type, payload)); - } - body - } - - #[test] - fn decodes_event_frame_to_typed_payload() { - let frame = encode_event_frame( - "contentBlockDelta", - r#"{"delta":{"text":"hi"},"contentBlockIndex":0}"#, - ); - let mut decoder = FrameDecoder::new(); - let events = decoder.push(&frame).unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "contentBlockDelta"); - let payload: serde_json::Value = serde_json::from_str(&events[0].payload).unwrap(); - assert_eq!(payload["delta"]["text"], "hi"); - } - - #[test] - fn reassembles_frame_split_across_pushes() { - let frame = encode_event_frame("messageStop", r#"{"stopReason":"end_turn"}"#); - let split = frame.len() / 2; - let mut decoder = FrameDecoder::new(); - assert!(decoder.push(&frame[..split]).unwrap().is_empty()); - let events = decoder.push(&frame[split..]).unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "messageStop"); - } - - #[test] - fn exception_frame_surfaces_as_error() { - let message = Message::new(br#"{"message":"Too many requests"}"#.to_vec()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("exception".into()), - )) - .add_header(Header::new( - ":exception-type", - HeaderValue::String("throttlingException".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - - let mut decoder = FrameDecoder::new(); - let err = decoder.push(&buf).unwrap_err(); - let rendered = err.to_string(); - assert!(rendered.contains("throttlingException"), "{rendered}"); - assert!(rendered.contains("Too many requests"), "{rendered}"); - } - - #[test] - fn unmodeled_error_frame_surfaces_as_error() { - let message = Message::new(Vec::new()) - .add_header(Header::new( - ":message-type", - HeaderValue::String("error".into()), - )) - .add_header(Header::new( - ":error-code", - HeaderValue::String("InternalError".into()), - )) - .add_header(Header::new( - ":error-message", - HeaderValue::String("stream broke".into()), - )); - let mut buf = Vec::new(); - write_message_to(&message, &mut buf).unwrap(); - - let mut decoder = FrameDecoder::new(); - let err = decoder.push(&buf).unwrap_err(); - let rendered = err.to_string(); - assert!(rendered.contains("InternalError"), "{rendered}"); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/mod.rs b/lib/components/fabro-llm/src/providers/bedrock/mod.rs deleted file mode 100644 index 6f10dadb2..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/mod.rs +++ /dev/null @@ -1,716 +0,0 @@ -//! Provider adapter for Amazon Bedrock (Converse/ConverseStream). -//! -//! A thin transport shell over the `bedrock_converse` codec: it owns auth -//! (SigV4 signing or a bearer Bedrock API key), the region derivation, and -//! the AWS event-stream byte loop. All wire translation lives in the codec; -//! one codec serves every Converse-capable family because AWS translates the -//! envelope server-side. - -pub(crate) mod eventstream; -pub(crate) mod sigv4; - -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; -use std::time::Duration; - -use eventstream::FrameDecoder; -use fabro_auth::ApiKeyHeader; -use fabro_model::Catalog; -use futures::stream; -use sigv4::Sigv4Signer; -use tokio::sync::OnceCell; -use tokio::time; - -use crate::adapter_registry::AdapterConfig; -#[cfg(test)] -use crate::adapter_registry::AdapterKindOptions; -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::bedrock_converse::BedrockConverse; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::provider::{self, ProviderAdapter, StreamEventStream}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::transport::{self, HttpTransport}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -/// How the adapter authenticates to Bedrock. -pub(crate) enum BedrockAuth { - /// Bedrock API key, sent as an `Authorization: Bearer` token. - ApiKey(String), - /// SigV4 signing. The signer (holding the AWS default credential chain) - /// is resolved on first use and cached; the chain itself re-resolves - /// expiring credentials per request. Tests pre-seed the cell with a - /// static signer. - Sigv4(OnceCell), -} - -/// Build a boxed Bedrock adapter from a resolved [`AdapterConfig`]. -/// -/// Kept in this module (rather than the generic adapter registry) so that -/// Bedrock-specific construction stays encapsulated here. The auth mode is -/// implied by the resolved credential: an `aws_sigv4` credential signs with -/// the AWS chain; a static token is sent as a bearer API key. -pub(crate) fn build(config: AdapterConfig) -> Result, Error> { - let base_url = config - .base_url - .clone() - .ok_or_else(|| Error::Configuration { - message: format!( - "bedrock provider '{}' requires a base_url (the Bedrock runtime endpoint)", - config.provider_id - ), - source: None, - })?; - let adapter = match config.auth_header { - Some(ApiKeyHeader::AwsSigv4) => Adapter::new_sigv4(base_url)?, - Some(ApiKeyHeader::Bearer(token)) => Adapter::new_api_key(token, base_url)?, - Some(ApiKeyHeader::Custom { name, .. }) => { - return Err(Error::Configuration { - message: format!( - "bedrock provider '{}' does not support custom auth header '{}' (use bearer \ - credentials or aws_sigv4)", - config.provider_id, name - ), - source: None, - }); - } - None => { - return Err(Error::Configuration { - message: format!( - "bedrock provider '{}' has no resolved credential (configure `aws_sigv4` or \ - an API key)", - config.provider_id - ), - source: None, - }); - } - }; - let mut adapter = adapter.with_name(config.provider_id); - if !config.extra_headers.is_empty() { - adapter = adapter.with_default_headers(config.extra_headers); - } - if let Some(catalog) = config.catalog { - adapter = adapter.with_catalog(catalog); - } - Ok(Arc::new(adapter)) -} - -/// Provider adapter for Amazon Bedrock. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - region: String, - auth: BedrockAuth, - catalog: Option>, -} - -impl Adapter { - /// Construct an adapter that authenticates with a Bedrock API key. - /// `base_url` is the Bedrock runtime endpoint; the signing region is - /// parsed from it. - pub fn new_api_key( - token: impl Into, - base_url: impl Into, - ) -> Result { - Self::with_auth(base_url, BedrockAuth::ApiKey(token.into())) - } - - /// Construct a SigV4 adapter. Credentials resolve lazily from the AWS - /// default chain on the first request, so construction stays synchronous. - pub fn new_sigv4(base_url: impl Into) -> Result { - Self::with_auth(base_url, BedrockAuth::Sigv4(OnceCell::new())) - } - - fn with_auth(base_url: impl Into, auth: BedrockAuth) -> Result { - let base_url = base_url.into(); - let region = region_from_base_url(&base_url)?; - Ok(Self { - http: HttpTransport::new_optional(None, base_url), - provider_name: "bedrock".to_string(), - region, - auth, - catalog: None, - }) - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_default_headers(mut self, headers: HashMap) -> Self { - self.http = self.http.with_default_headers(headers); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Resolve file-backed attachments to inline data first: Converse takes - /// inline image and document bytes (no URL sources). - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Build the signed/bearer HTTP request for an encoded Converse call. - async fn build_http_request( - &self, - encoded: &EncodedRequest, - stream: bool, - ) -> Result { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let body = serde_json::to_vec(&encoded.body).map_err(|e| Error::Configuration { - message: format!("failed to serialize converse request: {e}"), - source: None, - })?; - - let mut req = self.http.client.post(&url); - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - - req = match &self.auth { - BedrockAuth::ApiKey(token) => req.bearer_auth(token).body(body), - BedrockAuth::Sigv4(cell) => { - let signer = cell - .get_or_try_init(Sigv4Signer::from_default_chain) - .await?; - signer.sign_post(req, &self.region, &url, body).await? - } - }; - - req = req.header("content-type", "application/json"); - if stream { - req = req.header("accept", "application/vnd.amazon.eventstream"); - } - if let Some(t) = self.http.request_timeout { - if !stream { - req = req.timeout(t); - } - } - Ok(req) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = BedrockConverse; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let req = self.build_http_request(&encoded, false).await?; - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = BedrockConverse; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - let req = self.build_http_request(&encoded, true).await?; - - let http_resp = req - .send() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - let status = http_resp.status(); - if !status.is_success() { - let retry_after = transport::parse_retry_after(http_resp.headers()); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - return Err(codec.decode_error(status.as_u16(), &body, &ctx, retry_after)); - } - - let rate_limit = transport::parse_rate_limit_headers(http_resp.headers()); - let decoder = codec.stream_decoder(&ctx, rate_limit); - Ok(decode_eventstream( - http_resp, - decoder, - self.http.stream_read_timeout, - )) - } - - fn supports_tool_choice(&self, mode: &str) -> bool { - // Converse has no `none` tool choice on the wire. - matches!(mode, "auto" | "required" | "named") - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - if let Some(tool_choice) = &request.tool_choice { - provider::validate_tool_choice(self, tool_choice)?; - } - Ok(()) - } -} - -/// State driving the event-stream byte loop: the codec's decoder plus the -/// frame decoder, with a buffer that flattens batched events. -struct EventStreamLoop { - response: fabro_http::Response, - frames: FrameDecoder, - decoder: Box, - pending: VecDeque>, - done: bool, - /// `finish()` already drained. - finished: bool, - /// [`StreamEvent::StreamStart`] already emitted for this stream. - stream_started: bool, - timeout: Option, -} - -/// Drive `decoder` over the AWS event-stream byte stream of `response`: the -/// event-stream sibling of the transport's shared SSE loop, anticipated by -/// the transport consolidation notes. -fn decode_eventstream( - response: fabro_http::Response, - decoder: Box, - timeout: Option, -) -> StreamEventStream { - let out = stream::unfold( - EventStreamLoop { - response, - frames: FrameDecoder::new(), - decoder, - pending: VecDeque::new(), - done: false, - finished: false, - stream_started: false, - timeout, - }, - move |mut state| async move { - loop { - if let Some(event) = state.pending.pop_front() { - return Some((event, state)); - } - - if state.done { - if state.finished { - return None; - } - state.finished = true; - state - .pending - .extend(state.decoder.finish().into_iter().map(Ok)); - if state.pending.is_empty() { - return None; - } - continue; - } - - let chunk_result = match state.timeout { - Some(timeout) => time::timeout(timeout, state.response.chunk()).await, - None => Ok(state.response.chunk().await), - }; - match chunk_result { - Ok(Ok(Some(bytes))) => { - let frames = match state.frames.push(&bytes) { - Ok(frames) => frames, - Err(e) => return Some((Err(e), state)), - }; - for frame in frames { - let raw = RawEvent { - event: Some(frame.event_type.as_str()), - data: frame.payload.as_str(), - }; - // Mirrors the SSE loop: the first decoded frame is - // the liveness edge, independent of which event - // type the provider happens to open with. - if !state.stream_started { - state.stream_started = true; - state.pending.push_back(Ok(StreamEvent::StreamStart)); - } - match state.decoder.on_event(raw) { - Ok(events) => state.pending.extend(events.into_iter().map(Ok)), - Err(error) => { - state.pending.push_back(Err(error)); - break; - } - } - } - } - Ok(Ok(None)) => state.done = true, - Ok(Err(e)) => { - return Some((Err(Error::stream_error(e.to_string(), e)), state)); - } - Err(_) => { - return Some(( - Err(Error::Stream { - message: "stream read timed out waiting for next event".to_string(), - source: None, - }), - state, - )); - } - } - } - }, - ); - Box::pin(out) -} - -/// Derive the AWS region from a Bedrock runtime endpoint URL. -/// -/// The region is a SigV4 signing parameter, so it is parsed from the -/// configured base URL rather than carried as a separate AWS-specific config -/// field. It is validated as `[a-z0-9-]` since it ultimately appears in a -/// signed request. -fn region_from_base_url(base_url: &str) -> Result { - let invalid = || Error::Configuration { - message: format!( - "bedrock base_url '{base_url}' is not a recognized Bedrock runtime endpoint \ - (expected https://bedrock-runtime[-fips]..amazonaws.com[.cn])" - ), - source: None, - }; - #[expect( - clippy::disallowed_types, - reason = "Bedrock region derivation needs URL host parsing; the raw URL is not logged or rendered." - )] - let parsed = fabro_http::Url::parse(base_url).map_err(|_| invalid())?; - let host = parsed.host_str().ok_or_else(invalid)?; - let rest = host - .strip_prefix("bedrock-runtime-fips.") - .or_else(|| host.strip_prefix("bedrock-runtime.")) - .ok_or_else(invalid)?; - let region = rest - .strip_suffix(".amazonaws.com.cn") - .or_else(|| rest.strip_suffix(".amazonaws.com")) - .ok_or_else(invalid)?; - let valid = !region.is_empty() - && region - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); - if valid { - Ok(region.to_string()) - } else { - Err(invalid()) - } -} - -#[cfg(test)] -mod tests { - use futures::StreamExt; - use httpmock::prelude::*; - - use super::*; - use crate::types::{FinishReason, Message}; - - fn make_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: Some("bedrock".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(64), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - /// Adapter pointed at httpmock: region parsing only applies to real - /// bedrock-runtime URLs, so the test constructor sets the region field - /// directly. - fn test_adapter(server: &MockServer) -> Adapter { - Adapter { - http: HttpTransport::new_optional(None, server.base_url()), - provider_name: "bedrock".to_string(), - region: "us-east-1".to_string(), - auth: BedrockAuth::ApiKey("test-bedrock-key".to_string()), - catalog: None, - } - } - - #[test] - fn region_parses_from_standard_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.eu-west-1.amazonaws.com").unwrap(), - "eu-west-1" - ); - } - - #[test] - fn region_parses_from_fips_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com") - .unwrap(), - "us-gov-west-1" - ); - } - - #[test] - fn region_parses_from_china_endpoint() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.cn-north-1.amazonaws.com.cn").unwrap(), - "cn-north-1" - ); - } - - #[test] - fn region_rejects_non_bedrock_hosts() { - for url in [ - "https://example.com", - "https://bedrock.us-east-1.amazonaws.com", - "https://bedrock-runtime.amazonaws.com", - ] { - assert!(region_from_base_url(url).is_err(), "{url}"); - } - } - - #[test] - fn region_normalizes_hostname_case() { - assert_eq!( - region_from_base_url("https://bedrock-runtime.US-EAST-1.amazonaws.com").unwrap(), - "us-east-1" - ); - } - - #[tokio::test] - async fn complete_posts_converse_body_with_bearer_auth() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/us.anthropic.claude-sonnet-4-6/converse") - .header("authorization", "Bearer test-bedrock-key") - .json_body_includes( - r#"{"messages":[{"role":"user","content":[{"text":"Hello"}]}],"inferenceConfig":{"maxTokens":64}}"#, - ); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "Hi!"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 8, "outputTokens": 2, "totalTokens": 10} - })); - }); - - let adapter = test_adapter(&server); - let response = adapter - .complete(&make_request("us.anthropic.claude-sonnet-4-6")) - .await - .unwrap(); - - mock.assert(); - assert_eq!(response.text(), "Hi!"); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert_eq!(response.usage.input_tokens, 8); - assert_eq!(response.provider, "bedrock"); - } - - #[tokio::test] - async fn complete_applies_default_headers() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse") - .header("x-fabro-test", "present"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2} - })); - }); - - let adapter = test_adapter(&server).with_default_headers(HashMap::from([( - "x-fabro-test".to_string(), - "present".to_string(), - )])); - let response = adapter.complete(&make_request("m")).await.unwrap(); - - mock.assert(); - assert_eq!(response.text(), "ok"); - } - - #[test] - fn factory_rejects_custom_auth_header() { - let result = build(AdapterConfig { - provider_id: "bedrock".to_string(), - auth_header: Some(ApiKeyHeader::Custom { - name: "x-api-key".to_string(), - value: "secret".to_string(), - }), - base_url: Some("https://bedrock-runtime.us-east-1.amazonaws.com".to_string()), - extra_headers: HashMap::new(), - kind_options: AdapterKindOptions::None, - catalog: None, - }); - - let Err(err) = result else { - panic!("expected custom auth header to be rejected"); - }; - assert!( - err.to_string() - .contains("does not support custom auth header") - ); - } - - #[tokio::test] - async fn complete_signs_with_sigv4_when_configured() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse") - .header_exists("authorization") - .header_exists("x-amz-date"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}}, - "stopReason": "end_turn", - "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2} - })); - }); - - let mut adapter = test_adapter(&server); - let cell = OnceCell::new(); - cell.set(Sigv4Signer::from_static("AKIDEXAMPLE", "secret", None)) - .ok(); - adapter.auth = BedrockAuth::Sigv4(cell); - - let response = adapter.complete(&make_request("m")).await.unwrap(); - mock.assert(); - assert_eq!(response.text(), "ok"); - } - - #[tokio::test] - async fn stream_decodes_eventstream_frames() { - let server = MockServer::start(); - let body = eventstream::tests::build_stream_body(&[ - ("messageStart", r#"{"role":"assistant"}"#), - ( - "contentBlockDelta", - r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#, - ), - ( - "contentBlockDelta", - r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#, - ), - ("contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "metadata", - r#"{"usage":{"inputTokens":9,"outputTokens":3,"totalTokens":12}}"#, - ), - ]); - server.mock(|when, then| { - when.method(POST) - .path("/model/m/converse-stream") - .header("accept", "application/vnd.amazon.eventstream"); - then.status(200) - .header("content-type", "application/vnd.amazon.eventstream") - .body(body); - }); - - let adapter = test_adapter(&server); - let mut stream = adapter.stream(&make_request("m")).await.unwrap(); - - let mut text = String::new(); - let mut finish: Option = None; - while let Some(event) = stream.next().await { - match event.unwrap() { - StreamEvent::TextDelta { delta, .. } => text.push_str(&delta), - StreamEvent::Finish { response, .. } => finish = Some(*response), - _ => {} - } - } - assert_eq!(text, "Hello"); - let response = finish.expect("stream should finish"); - assert_eq!(response.text(), "Hello"); - assert_eq!(response.usage.input_tokens, 9); - } - - #[tokio::test] - async fn stream_surfaces_http_error_before_bytes() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/model/m/converse-stream"); - then.status(429) - .json_body(serde_json::json!({"message": "Too many requests"})); - }); - - let adapter = test_adapter(&server); - let Err(err) = adapter.stream(&make_request("m")).await else { - panic!("expected an HTTP error before any stream bytes"); - }; - assert_eq!(err.status_code(), Some(429)); - } - - #[test] - fn tool_choice_none_is_rejected() { - let server = MockServer::start(); - let adapter = test_adapter(&server); - assert!(!adapter.supports_tool_choice("none")); - assert!(adapter.supports_tool_choice("auto")); - } -} diff --git a/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs b/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs deleted file mode 100644 index 54a31849d..000000000 --- a/lib/components/fabro-llm/src/providers/bedrock/sigv4.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! AWS Signature Version 4 signing for Bedrock requests. -//! -//! Wraps the `aws-sigv4` crate to compute the `Authorization`, `x-amz-date`, -//! and (for temporary credentials) `x-amz-security-token` headers for a fully -//! built request. The headers are then attached to the shared `fabro-http` -//! request builder, so signed Bedrock requests still flow through the same -//! retry/redaction/transport layers as every other adapter. - -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use aws_credential_types::Credentials; -use aws_credential_types::provider::SharedCredentialsProvider; -use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign}; -use aws_sigv4::sign::v4; -use aws_smithy_runtime_api::client::identity::Identity; - -use crate::error::Error; - -/// Service name used in the SigV4 credential scope for Bedrock runtime calls. -pub(crate) const SERVICE: &str = "bedrock"; - -/// Where the signer's credentials come from. -enum CredentialSource { - /// Fixed credentials (tests / explicitly supplied keys). - #[cfg(test)] - Static(Credentials), - /// The AWS default provider chain. Credentials are resolved per request - /// so expiring session credentials (STS, IRSA, instance roles) refresh - /// through the chain's identity cache instead of being snapshotted once - /// at startup. - Chain(SharedCredentialsProvider), -} - -/// Signs HTTP requests for AWS services with SigV4. -pub(crate) struct Sigv4Signer { - credentials: CredentialSource, -} - -impl Sigv4Signer { - /// Build a signer from static keys. Test-only: production paths resolve - /// credentials through the AWS chain. - #[cfg(test)] - pub(crate) fn from_static( - access_key_id: &str, - secret_access_key: &str, - session_token: Option, - ) -> Self { - Self { - credentials: CredentialSource::Static(Credentials::from_keys( - access_key_id, - secret_access_key, - session_token, - )), - } - } - - /// Build a signer over the standard AWS provider chain (environment, - /// IRSA/web identity, EC2/ECS instance profile, SSO, assume-role). The - /// chain is resolved once; the credentials it yields are fetched per - /// signing call so they stay fresh over long-lived adapters. - pub(crate) async fn from_default_chain() -> Result { - let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) - .load() - .await; - let provider = config - .credentials_provider() - .ok_or_else(|| Error::Configuration { - message: "no AWS credentials provider found in the default chain".to_string(), - source: None, - })?; - Ok(Self { - credentials: CredentialSource::Chain(provider), - }) - } - - /// The credentials to sign the next request with. - async fn current_credentials(&self) -> Result { - use aws_credential_types::provider::ProvideCredentials; - - match &self.credentials { - #[cfg(test)] - CredentialSource::Static(credentials) => Ok(credentials.clone()), - CredentialSource::Chain(provider) => { - provider - .provide_credentials() - .await - .map_err(|e| Error::Configuration { - message: format!("failed to resolve AWS credentials: {e}"), - source: None, - }) - } - } - } - - /// Compute the SigV4 headers for a request: `Authorization`, `x-amz-date`, - /// and `x-amz-security-token` when the credentials carry a session token. - fn signed_headers( - credentials: &Credentials, - region: &str, - service: &str, - method: &str, - url: &str, - body: &[u8], - epoch_secs: u64, - ) -> Result, Error> { - let identity: Identity = credentials.clone().into(); - let signing_params = v4::SigningParams::builder() - .identity(&identity) - .region(region) - .name(service) - .time(UNIX_EPOCH + Duration::from_secs(epoch_secs)) - .settings(SigningSettings::default()) - .build() - .map_err(|e| Error::Configuration { - message: format!("sigv4 params: {e}"), - source: None, - })? - .into(); - - let signable = - SignableRequest::new(method, url, std::iter::empty(), SignableBody::Bytes(body)) - .map_err(|e| Error::Configuration { - message: format!("sigv4 signable request: {e}"), - source: None, - })?; - - let (instructions, _signature) = sign(signable, &signing_params) - .map_err(|e| Error::Configuration { - message: format!("sigv4 signing failed: {e}"), - source: None, - })? - .into_parts(); - - Ok(instructions - .headers() - .map(|(name, value)| (name.to_string(), value.to_string())) - .collect()) - } - - /// Apply SigV4 signed headers to a `fabro-http` request builder for a - /// `POST` to `url` carrying `body`. - pub(crate) async fn sign_post( - &self, - mut req: fabro_http::RequestBuilder, - region: &str, - url: &str, - body: Vec, - ) -> Result { - let credentials = self.current_credentials().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| Error::Configuration { - message: format!("system clock before epoch: {e}"), - source: None, - })? - .as_secs(); - for (name, value) in - Self::signed_headers(&credentials, region, SERVICE, "POST", url, &body, now)? - { - req = req.header(name, value); - } - Ok(req.body(body)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Fixed credentials + time produce a deterministic Authorization header. - // The expected value is locked below after the first green run so the test - // guards against accidental changes to the signing logic. - const ACCESS_KEY: &str = "AKIDEXAMPLE"; - const SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; - const FIXED_EPOCH: u64 = 1_716_960_000; // 2024-05-29T04:00:00Z - const URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-sonnet-4-6/converse"; - - fn static_credentials(signer: &Sigv4Signer) -> Credentials { - match &signer.credentials { - CredentialSource::Static(credentials) => credentials.clone(), - CredentialSource::Chain(_) => panic!("test signer should hold static credentials"), - } - } - - fn auth_header(headers: &[(String, String)]) -> &str { - headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) - .map(|(_, value)| value.as_str()) - .expect("authorization header must be present") - } - - fn sign_fixed(signer: &Sigv4Signer, body: &[u8]) -> Vec<(String, String)> { - Sigv4Signer::signed_headers( - &static_credentials(signer), - "us-east-1", - SERVICE, - "POST", - URL, - body, - FIXED_EPOCH, - ) - .unwrap() - } - - #[test] - fn produces_authorization_and_date_headers() { - let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None); - let headers = sign_fixed(&signer, br#"{"messages":[]}"#); - - assert!( - headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case("authorization")) - ); - assert!( - headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case("x-amz-date")) - ); - let auth = auth_header(&headers); - assert!(auth.starts_with("AWS4-HMAC-SHA256 ")); - assert!(auth.contains("Credential=AKIDEXAMPLE/20240529/us-east-1/bedrock/aws4_request")); - assert!(auth.contains("SignedHeaders=")); - assert!(auth.contains("Signature=")); - } - - #[test] - fn deterministic_signature_is_stable() { - let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None); - // Same inputs must yield an identical signature (regression lock). - assert_eq!( - auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)), - auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)), - ); - } - - #[test] - fn session_token_adds_security_token_header() { - let signer = - Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, Some("session-tok".to_string())); - let headers = sign_fixed(&signer, b"{}"); - assert!( - headers - .iter() - .any(|(n, v)| n.eq_ignore_ascii_case("x-amz-security-token") && v == "session-tok") - ); - } -} diff --git a/lib/components/fabro-llm/src/providers/common.rs b/lib/components/fabro-llm/src/providers/common.rs deleted file mode 100644 index 6eff3b727..000000000 --- a/lib/components/fabro-llm/src/providers/common.rs +++ /dev/null @@ -1,145 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_model::{Catalog, Model, ProviderId}; -use fabro_static::EnvVars; -use tokio::fs; - -#[must_use] -pub fn catalog_model<'a>( - catalog: Option<&'a Catalog>, - provider: &str, - model: &str, -) -> Option<&'a Model> { - catalog.and_then(|catalog| catalog.get_on_provider(&ProviderId::new(provider), model)) -} - -#[must_use] -pub fn api_model_id(catalog: Option<&Catalog>, provider: &str, model: &str) -> String { - catalog - .and_then(|catalog| catalog.model_settings_on_provider(&ProviderId::new(provider), model)) - .map_or_else(|| model.to_string(), |settings| settings.api_id.clone()) -} - -/// Adapters that route models through an optional catalog scoped to one -/// provider name. -pub trait CatalogRoute { - fn catalog(&self) -> Option<&Catalog>; - fn provider_name(&self) -> &str; - - /// Catalog offering for a canonical ID or alias on this provider. - fn catalog_model(&self, model: &str) -> Option<&Model> { - catalog_model(self.catalog(), self.provider_name(), model) - } - - /// Identifier sent to the provider API for a model. - fn api_model_id(&self, model: &str) -> String { - api_model_id(self.catalog(), self.provider_name(), model) - } -} - -/// Check if a URL string looks like a local file path. -#[must_use] -pub fn is_file_path(url: &str) -> bool { - url.starts_with('/') || url.starts_with("./") || url.starts_with("~/") -} - -/// Infer MIME type from a file extension. -#[must_use] -pub fn mime_from_extension(path: &str) -> &str { - match path.rsplit('.').next().map(str::to_lowercase).as_deref() { - Some("png") => "image/png", - Some("jpg" | "jpeg") => "image/jpeg", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - Some("heic") => "image/heic", - Some("heif") => "image/heif", - Some("pdf") => "application/pdf", - Some("wav") => "audio/wav", - Some("mp3") => "audio/mp3", - _ => "application/octet-stream", - } -} - -/// Load a local file, returning (`base64_data`, `mime_type`). -/// Expands ~ to home directory. -/// -/// # Errors -/// Returns an error if the file cannot be read. -#[expect( - clippy::disallowed_methods, - reason = "Attachment path expansion supports the conventional HOME env var." -)] -pub async fn load_file_bytes(path: &str) -> Result<(Vec, String), std::io::Error> { - let expanded = path.strip_prefix("~/").map_or_else( - || path.to_string(), - |rest| { - let home = std::env::var(EnvVars::HOME).unwrap_or_else(|_| "/".to_string()); - format!("{home}/{rest}") - }, - ); - let data = fs::read(&expanded).await.map_err(|err| { - std::io::Error::new(err.kind(), format!("read attachment {expanded}: {err}")) - })?; - let mime = mime_from_extension(&expanded).to_string(); - Ok((data, mime)) -} - -/// Read a file and return base64-encoded contents plus the inferred MIME type. -/// -/// # Errors -/// -/// Returns an error if the file cannot be read. -pub async fn load_file_as_base64(path: &str) -> Result<(String, String), std::io::Error> { - let (data, mime) = load_file_bytes(path).await?; - Ok((BASE64_STANDARD.encode(&data), mime)) -} - -// Transport pieces moved to `crate::transport`; re-exported here because -// fabro-cli imports them from this path (frozen public surface). -pub use crate::transport::{LineReader, parse_rate_limit_headers, parse_retry_after}; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_file_path_absolute() { - assert!(is_file_path("/tmp/image.png")); - assert!(is_file_path("/home/user/photo.jpg")); - } - - #[test] - fn is_file_path_relative() { - assert!(is_file_path("./image.png")); - assert!(is_file_path("./subdir/photo.jpg")); - } - - #[test] - fn is_file_path_tilde() { - assert!(is_file_path("~/image.png")); - assert!(is_file_path("~/Documents/photo.jpg")); - } - - #[test] - fn is_file_path_url() { - assert!(!is_file_path("https://example.com/image.png")); - assert!(!is_file_path("http://example.com/image.png")); - assert!(!is_file_path("data:image/png;base64,abc")); - } - - #[test] - fn mime_from_extension_known() { - assert_eq!(mime_from_extension("photo.png"), "image/png"); - assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg"); - assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg"); - assert_eq!(mime_from_extension("photo.gif"), "image/gif"); - assert_eq!(mime_from_extension("photo.webp"), "image/webp"); - assert_eq!(mime_from_extension("doc.pdf"), "application/pdf"); - } - - #[test] - fn mime_from_extension_unknown() { - assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream"); - assert_eq!(mime_from_extension("noext"), "application/octet-stream"); - } -} diff --git a/lib/components/fabro-llm/src/providers/fabro_server.rs b/lib/components/fabro-llm/src/providers/fabro_server.rs deleted file mode 100644 index 5e4d6d990..000000000 --- a/lib/components/fabro-llm/src/providers/fabro_server.rs +++ /dev/null @@ -1,529 +0,0 @@ -use fabro_redact::DisplaySafeUrl; -use futures::stream; -use tracing::{debug, error}; - -use crate::error::{Error, error_from_status_code}; -use crate::provider::{ProviderAdapter, StreamEventStream}; -use crate::transport::{LineReader, parse_sse_block}; -use crate::types::{ - CostSource, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, -}; - -/// Provider adapter that routes LLM requests through an fabro server's -/// `/completions` endpoint, delegating to whatever real provider the server -/// is configured with. -pub struct Adapter { - client: fabro_http::HttpClient, - base_url: String, - provider_name: String, -} - -impl Adapter { - pub fn new( - client: fabro_http::HttpClient, - base_url: impl Into, - provider_name: impl Into, - ) -> Self { - Self { - client, - base_url: base_url.into(), - provider_name: provider_name.into(), - } - } -} - -// --------------------------------------------------------------------------- -// Server response deserialization types -// --------------------------------------------------------------------------- - -#[derive(serde::Deserialize)] -struct ServerCompletionResponse { - id: String, - model: String, - message: Message, - stop_reason: String, - usage: ServerUsage, - cost_usd: Option, - cost_source: Option, -} - -#[derive(serde::Deserialize)] -struct ServerUsage { - input_tokens: i64, - output_tokens: i64, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn map_stop_reason(reason: &str) -> FinishReason { - match reason { - "end_turn" | "stop" => FinishReason::Stop, - "max_tokens" | "length" => FinishReason::Length, - "tool_calls" => FinishReason::ToolCalls, - other => FinishReason::Other(other.to_string()), - } -} - -/// Build the JSON request body by serializing the `Request` and injecting -/// the `stream` flag. -fn build_body(request: &Request, stream: bool) -> Result { - let mut body = serde_json::to_value(request) - .map_err(|e| Error::configuration_error(format!("failed to serialize request: {e}"), e))?; - body["stream"] = serde_json::Value::Bool(stream); - Ok(body) -} - -/// Send a POST request and return the validated response. -/// -/// Handles timeout/network error mapping and non-2xx status codes. -async fn send_request( - client: &fabro_http::HttpClient, - url: &str, - body: &serde_json::Value, - provider: &str, -) -> Result { - let http_resp = client.post(url).json(body).send().await.map_err(|e| { - if e.is_timeout() { - Error::request_timeout(e.to_string(), e) - } else { - Error::network(e.to_string(), e) - } - })?; - - let status = http_resp.status(); - debug!(status = %status, "Fabro server response received"); - - if !status.is_success() { - let status_code = status.as_u16(); - let body = http_resp.text().await.unwrap_or_default(); - error!(status = %status_code, body = %body, "Fabro server request failed"); - return Err(error_from_status_code( - status_code, - body, - provider.to_string(), - None, - None, - None, - )); - } - - Ok(http_resp) -} - -// --------------------------------------------------------------------------- -// ProviderAdapter implementation -// --------------------------------------------------------------------------- - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - async fn complete(&self, request: &Request) -> Result { - let url = format!("{}/completions", self.base_url); - let safe_url = redacted_url_for_log(&url); - debug!(base_url = %safe_url, provider = %self.provider_name, "Sending completion to fabro server"); - - let body = build_body(request, false)?; - let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?; - - let resp_body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - let server_resp: ServerCompletionResponse = - serde_json::from_str(&resp_body).map_err(|e| { - Error::stream_error(format!("failed to parse completion response: {e}"), e) - })?; - - let finish_reason = map_stop_reason(&server_resp.stop_reason); - Ok(Response { - id: server_resp.id, - model: server_resp.model, - provider: self.provider_name.clone(), - message: server_resp.message, - finish_reason, - usage: TokenCounts { - input_tokens: server_resp.usage.input_tokens, - output_tokens: server_resp.usage.output_tokens, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - // Carry the server's cost through; the local client's stamping - // never overwrites an already-set cost. - cost_usd: server_resp.cost_usd, - cost_source: server_resp.cost_source, - }) - } - - async fn stream(&self, request: &Request) -> Result { - let url = format!("{}/completions", self.base_url); - let safe_url = redacted_url_for_log(&url); - debug!(base_url = %safe_url, provider = %self.provider_name, "Sending completion to fabro server"); - - let body = build_body(request, true)?; - let http_resp = send_request(&self.client, &url, &body, &self.provider_name).await?; - - let stream = stream::unfold(LineReader::new(http_resp, None), |mut reader| async move { - loop { - match reader.read_next_chunk("\n\n").await { - Ok(Some(block)) => { - if let Some((Some("stream_event"), data)) = parse_sse_block(&block) { - match serde_json::from_str::(&data) { - Ok(event) => return Some((Ok(event), reader)), - Err(e) => { - return Some(( - Err(Error::stream_error( - format!("failed to parse stream event: {e}"), - e, - )), - reader, - )); - } - } - } - // Empty, unparsable, or non-stream_event block — keep - // reading. - } - Ok(None) => return None, - Err(e) => return Some((Err(e), reader)), - } - } - }); - - Ok(Box::pin(stream)) - } -} - -fn redacted_url_for_log(url: &str) -> String { - DisplaySafeUrl::parse(url) - .map_or_else(|_| "".to_string(), |url| url.redacted_string()) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use futures::StreamExt; - use httpmock::prelude::*; - - use super::*; - use crate::error::ProviderErrorKind; - use crate::types::Message; - - fn make_request() -> Request { - Request { - model: "test-model".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[test] - fn redacted_url_for_log_masks_provider_query_credentials() { - assert_eq!( - redacted_url_for_log("https://fabro.example.test?api_key=secret&project=demo"), - "https://fabro.example.test/?api_key=****&project=demo" - ); - } - - #[tokio::test] - async fn stream_parses_sse_events() { - let server = MockServer::start(); - - let sse_body = "\ -event: stream_event\n\ -data: {\"type\":\"stream_start\"}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"text_delta\",\"delta\":\"Hello\",\"text_id\":null}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"text_delta\",\"delta\":\" world\",\"text_id\":null}\n\ -\n"; - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let mut stream = adapter.stream(&make_request()).await.unwrap(); - - // First event: StreamStart - let event = stream.next().await.unwrap().unwrap(); - assert!(matches!(event, StreamEvent::StreamStart)); - - // Second event: TextDelta "Hello" - let event = stream.next().await.unwrap().unwrap(); - match &event { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, "Hello"), - other => panic!("expected TextDelta, got {other:?}"), - } - - // Third event: TextDelta " world" - let event = stream.next().await.unwrap().unwrap(); - match &event { - StreamEvent::TextDelta { delta, .. } => assert_eq!(delta, " world"), - other => panic!("expected TextDelta, got {other:?}"), - } - - // Stream should end - assert!(stream.next().await.is_none()); - } - - #[tokio::test] - async fn complete_parses_response() { - let server = MockServer::start(); - - let response_json = serde_json::json!({ - "id": "resp-123", - "model": "test-model", - "message": { - "role": "assistant", - "content": [{"kind": "text", "data": "Hello there!"}], - "name": null, - "tool_call_id": null - }, - "stop_reason": "end_turn", - "usage": { - "input_tokens": 10, - "output_tokens": 5 - }, - "cost_usd": 0.000_25, - "cost_source": "estimated" - }); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "application/json") - .json_body(response_json); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let response = adapter.complete(&make_request()).await.unwrap(); - - assert_eq!(response.id, "resp-123"); - assert_eq!(response.model, "test-model"); - assert_eq!(response.provider, "test-provider"); - assert_eq!(response.text(), "Hello there!"); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert_eq!(response.usage.input_tokens, 10); - assert_eq!(response.usage.output_tokens, 5); - assert_eq!(response.usage.total_tokens(), 15); - assert_eq!(response.cost_usd, Some(0.000_25)); - assert_eq!(response.cost_source, Some(CostSource::Estimated)); - } - - /// Reasoning needs no dedicated wire field on this hop: the canonical - /// message already transports the provider parts it is derived from. - #[tokio::test] - async fn complete_normalizes_reasoning_from_the_transported_message() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "resp-123", - "model": "test-model", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_compat_reasoning_details", - "data": [ - {"type": "reasoning.summary", "summary": "weighed both"}, - {"type": "reasoning.text", "text": "step one"}, - ] - }, - {"kind": "text", "data": "Hello there!"}, - ], - "name": null, - "tool_call_id": null - }, - "stop_reason": "end_turn", - "usage": {"input_tokens": 10, "output_tokens": 5} - })); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let response = adapter.complete(&make_request()).await.unwrap(); - - assert_eq!(response.text(), "Hello there!"); - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("weighed both")); - assert_eq!(reasoning.trace(), Some("step one")); - } - - #[tokio::test] - async fn complete_returns_error_on_502() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(502).body("Bad Gateway"); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let err = adapter.complete(&make_request()).await.unwrap_err(); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::Server); - assert_eq!(detail.status_code, Some(502)); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn stream_returns_error_on_502() { - let server = MockServer::start(); - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(502).body("Bad Gateway"); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let result = adapter.stream(&make_request()).await; - let Err(err) = result else { - panic!("expected error"); - }; - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::Server); - assert_eq!(detail.status_code, Some(502)); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn stream_skips_non_stream_event_types() { - let server = MockServer::start(); - - let sse_body = "\ -event: ping\n\ -data: {}\n\ -\n\ -event: stream_event\n\ -data: {\"type\":\"stream_start\"}\n\ -\n"; - - server.mock(|when, then| { - when.method(POST).path("/completions"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new( - fabro_test::test_http_client(), - server.base_url(), - "test-provider", - ); - - let mut stream = adapter.stream(&make_request()).await.unwrap(); - - // The ping event should be skipped, only StreamStart yielded - let event = stream.next().await.unwrap().unwrap(); - assert!(matches!(event, StreamEvent::StreamStart)); - - assert!(stream.next().await.is_none()); - } - - #[test] - fn map_stop_reason_variants() { - assert_eq!(map_stop_reason("end_turn"), FinishReason::Stop); - assert_eq!(map_stop_reason("stop"), FinishReason::Stop); - assert_eq!(map_stop_reason("max_tokens"), FinishReason::Length); - assert_eq!(map_stop_reason("length"), FinishReason::Length); - assert_eq!(map_stop_reason("tool_calls"), FinishReason::ToolCalls); - assert_eq!( - map_stop_reason("something_else"), - FinishReason::Other("something_else".to_string()) - ); - } - - #[test] - fn parse_sse_block_valid() { - let block = "event: stream_event\ndata: {\"type\":\"stream_start\"}"; - let (event_type, data) = parse_sse_block(block).unwrap(); - assert_eq!(event_type, Some("stream_event")); - assert_eq!(data, "{\"type\":\"stream_start\"}"); - } - - #[test] - fn parse_sse_block_missing_data() { - let block = "event: stream_event"; - assert!(parse_sse_block(block).is_none()); - } - - /// A block without an `event:` line parses with `event = None`; the - /// stream loop's `Some("stream_event")` match is what filters it out. - #[test] - fn parse_sse_block_missing_event() { - let block = "data: {\"type\":\"stream_start\"}"; - let (event_type, _) = parse_sse_block(block).unwrap(); - assert_eq!(event_type, None); - } - - #[test] - fn adapter_name() { - let adapter = Adapter::new( - fabro_test::test_http_client(), - "http://localhost", - "anthropic", - ); - assert_eq!(adapter.name(), "anthropic"); - } -} diff --git a/lib/components/fabro-llm/src/providers/gemini.rs b/lib/components/fabro-llm/src/providers/gemini.rs deleted file mode 100644 index e2a33337b..000000000 --- a/lib/components/fabro-llm/src/providers/gemini.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::gemini_generate::GeminiGenerate; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response}; - -const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; - -/// Provider adapter for the Google Gemini `generateContent` API. -/// -/// A thin transport shell over the `gemini_generate` codec: it owns auth -/// (`x-goog-api-key`), base URL, and the streaming byte loop. All wire -/// translation — including the model-in-path endpoints — lives in the codec. -/// Gemini has no route variance (single auth scheme, count-tokens always -/// available, no forced streaming), so there is no route config. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - provider_name: "gemini".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // Gemini loads all three attachment kinds inline. - let policy = AttachmentPolicy { - images: true, - documents: true, - audio: true, - }; - attachments::resolve(request, policy).await - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Apply the base URL, auth (`x-goog-api-key`), and codec-emitted headers - /// to an encoded request. - fn build_http_request(&self, encoded: &EncodedRequest) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - if let Some(api_key) = &self.http.api_key { - req = req.header("x-goog-api-key", api_key); - } - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = GeminiGenerate; - let deployment_id = self.api_model_id(&resolved.model); - let params = CodecParams::default(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded), - &codec, - &ctx, - SseFraming::DataLines, - self.http.stream_read_timeout, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use httpmock::prelude::*; - - use super::*; - use crate::types::Message; - - fn minimal_request() -> Request { - Request { - model: "gemini-2.0-flash".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[tokio::test] - async fn count_input_tokens_posts_generate_content_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST) - .path("/models/gemini-2.0-flash:countTokens") - .header("x-goog-api-key", "test-key"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"totalTokens": 456})); - }); - let adapter = Adapter::new("test-key").with_base_url(server.base_url()); - - let count = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap() - .expect("gemini should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 456); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } -} diff --git a/lib/components/fabro-llm/src/providers/mod.rs b/lib/components/fabro-llm/src/providers/mod.rs deleted file mode 100644 index d0c72788d..000000000 --- a/lib/components/fabro-llm/src/providers/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod anthropic; -pub(crate) mod bedrock; -pub mod common; -pub mod fabro_server; -pub mod gemini; -pub mod openai; -pub mod openai_compatible; - -pub use anthropic::Adapter as AnthropicAdapter; -pub use bedrock::Adapter as BedrockAdapter; -pub use fabro_server::Adapter as FabroServerAdapter; -pub use gemini::Adapter as GeminiAdapter; -pub use openai::Adapter as OpenAiAdapter; -pub use openai_compatible::Adapter as OpenAiCompatibleAdapter; diff --git a/lib/components/fabro-llm/src/providers/openai.rs b/lib/components/fabro-llm/src/providers/openai.rs deleted file mode 100644 index b56159c2c..000000000 --- a/lib/components/fabro-llm/src/providers/openai.rs +++ /dev/null @@ -1,590 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::attachments::{self, AttachmentPolicy}; -use crate::codec::openai_responses::OpenAiResponses; -use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; - -const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; - -/// Provider adapter for the `OpenAI` Responses API (`/v1/responses`). -/// -/// A thin transport shell over the `openai_responses` codec: it owns auth -/// (bearer + org/project headers), base URL, the streaming byte loop, and the -/// route configuration for codex mode. All wire translation lives in the -/// codec. -/// -/// Per spec Section 2.7, this adapter uses the Responses API (not Chat -/// Completions) to properly surface reasoning tokens, built-in tools, and -/// server-side state. -pub struct Adapter { - pub(crate) http: HttpTransport, - org_id: Option, - project_id: Option, - provider_name: String, - catalog: Option>, - /// When true, always use streaming (required by the Codex endpoint). - codex_mode: bool, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into())) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option) -> Self { - Self { - http: HttpTransport::new_optional(api_key, DEFAULT_BASE_URL), - org_id: None, - project_id: None, - provider_name: "openai".to_string(), - catalog: None, - codex_mode: false, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_codex_mode(mut self) -> Self { - self.codex_mode = true; - self - } - - #[must_use] - pub fn with_base_url(mut self, base_url: impl Into) -> Self { - self.http.base_url = base_url.into(); - self - } - - #[must_use] - pub fn with_org_id(mut self, org_id: impl Into) -> Self { - self.org_id = Some(org_id.into()); - self - } - - #[must_use] - pub fn with_project_id(mut self, project_id: impl Into) -> Self { - self.project_id = Some(project_id.into()); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Per-route dialect knobs for the codec. - /// - /// OpenAI has a single auth scheme (bearer + org/project headers), so the - /// only route variation is codex mode: its encode-side half (param - /// omission) rides on `CodecParams`; its transport-side half (forced - /// streaming) is checked directly off `codex_mode` in `complete`. - fn codec_params(&self) -> CodecParams { - CodecParams { - openai_codex: self.codex_mode, - ..CodecParams::default() - } - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Build the canonical request for the codec, resolving file-backed - /// attachments to inline data first. Borrowed when nothing needs loading. - async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { - // OpenAI loads images inline; audio and documents render as text - // placeholders in the codec, so they are not loaded here. - let policy = AttachmentPolicy { - images: true, - documents: false, - audio: false, - }; - attachments::resolve(request, policy).await - } - - /// Apply the base URL, auth (bearer + org/project headers), and - /// codec-emitted headers to an encoded request. - fn build_http_request(&self, encoded: &EncodedRequest) -> fabro_http::RequestBuilder { - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.http.client.post(&url); - // Apply default_headers first so adapter-specific headers can override - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - if let Some(org_id) = &self.org_id { - req = req.header("OpenAI-Organization", org_id); - } - if let Some(project_id) = &self.project_id { - req = req.header("OpenAI-Project", project_id); - } - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - req.json(&encoded.body) - } - - /// Complete a request by streaming and collecting the final response. - /// Used for the Codex endpoint which requires `stream: true`. - async fn complete_via_stream(&self, request: &Request) -> Result { - use futures::StreamExt; - let mut event_stream = self.stream(request).await?; - let mut last_response: Option = None; - while let Some(event) = event_stream.next().await { - if let StreamEvent::Finish { response, .. } = event? { - last_response = Some(*response); - break; - } - } - last_response.ok_or_else(|| Error::Network { - message: "Stream ended without a finish event".into(), - source: None, - }) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn count_input_tokens( - &self, - request: &Request, - ) -> Result, Error> { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { - return Ok(None); - }; - - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - let (body, _headers) = - transport::send_for_body(req, "input_token_count", &codec, &ctx).await?; - let input_tokens = codec.decode_count_tokens(&body)?; - - Ok(Some(InputTokenCount { - input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], - })) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - // Codex endpoint requires streaming; collect the stream into a - // response. - if self.codex_mode { - return self.complete_via_stream(request).await; - } - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, false)?; - let mut req = self.build_http_request(&encoded); - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let resolved = self.resolve_request(request).await; - let codec = OpenAiResponses; - let deployment_id = self.api_model_id(&resolved.model); - let params = self.codec_params(); - let ctx = self.codec_ctx(&resolved, &deployment_id, ¶ms); - - let encoded = codec.encode(&ctx, true)?; - transport::stream_via_http( - self.build_http_request(&encoded), - &codec, - &ctx, - SseFraming::EventBlocks, - self.http.stream_read_timeout, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - use httpmock::prelude::*; - use tracing::field::{Field, Visit}; - use tracing::{Event, Subscriber, subscriber}; - use tracing_subscriber::layer::{Context as SubscriberContext, SubscriberExt}; - use tracing_subscriber::{Layer, Registry}; - - use super::*; - use crate::error::ProviderErrorKind; - use crate::types::Message; - - fn minimal_request() -> Request { - Request { - model: "gpt-4o".to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - #[derive(Clone, Default)] - struct CapturedLogEvents(Arc>>); - - #[derive(Clone, Debug, Default)] - struct CapturedLogEvent { - message: Option, - fields: HashMap, - } - - struct CaptureLayer { - events: CapturedLogEvents, - } - - impl Layer for CaptureLayer - where - S: Subscriber, - { - fn on_event(&self, event: &Event<'_>, _ctx: SubscriberContext<'_, S>) { - let mut visitor = LogFieldVisitor::default(); - event.record(&mut visitor); - self.events.0.lock().unwrap().push(CapturedLogEvent { - message: visitor.message, - fields: visitor.fields, - }); - } - } - - #[derive(Default)] - struct LogFieldVisitor { - message: Option, - fields: HashMap, - } - - impl LogFieldVisitor { - fn record_value(&mut self, field: &Field, value: String) { - if field.name() == "message" { - self.message = Some(value); - } else { - self.fields.insert(field.name().to_string(), value); - } - } - } - - impl Visit for LogFieldVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - self.record_value(field, format!("{value:?}")); - } - - fn record_str(&mut self, field: &Field, value: &str) { - self.record_value(field, value.to_string()); - } - - fn record_u64(&mut self, field: &Field, value: u64) { - self.record_value(field, value.to_string()); - } - - fn record_i64(&mut self, field: &Field, value: i64) { - self.record_value(field, value.to_string()); - } - } - - #[test] - fn adapter_with_org_id_sets_field() { - let adapter = Adapter::new("sk-test").with_org_id("org-123"); - assert_eq!(adapter.org_id.as_deref(), Some("org-123")); - } - - #[test] - fn adapter_with_project_id_sets_field() { - let adapter = Adapter::new("sk-test").with_project_id("proj-456"); - assert_eq!(adapter.project_id.as_deref(), Some("proj-456")); - } - - #[test] - fn adapter_with_default_headers_sets_field() { - let mut headers = HashMap::new(); - headers.insert("X-Custom".to_string(), "value".to_string()); - let adapter = Adapter::new("sk-test").with_default_headers(headers); - assert_eq!( - adapter - .http - .default_headers - .get("X-Custom") - .map(String::as_str), - Some("value") - ); - } - - #[test] - fn adapter_defaults_have_no_org_project_or_headers() { - let adapter = Adapter::new("sk-test"); - assert!(adapter.org_id.is_none()); - assert!(adapter.project_id.is_none()); - assert!(adapter.http.default_headers.is_empty()); - } - - #[tokio::test] - async fn count_input_tokens_posts_count_request_and_parses_response() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "object": "response.input_tokens", - "input_tokens": 789 - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let count = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap() - .expect("openai should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 789); - assert_eq!(count.method, InputTokenCountMethod::ProviderApi); - } - - #[tokio::test] - async fn count_input_tokens_logs_operation_on_provider_error() { - let events = CapturedLogEvents::default(); - let subscriber = Registry::default().with(CaptureLayer { - events: events.clone(), - }); - let _guard = subscriber::set_default(subscriber); - - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(403) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": { - "message": "input token counts are not enabled", - "type": "permission_error", - "code": "insufficient_permissions" - } - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Provider { - kind: ProviderErrorKind::AccessDenied, - .. - })); - - let captured = events.0.lock().unwrap(); - let event = captured - .iter() - .find(|event| event.message.as_deref() == Some("Provider returned error")) - .expect("provider error log should be captured"); - - assert_eq!( - event.fields.get("provider").map(String::as_str), - Some("openai") - ); - assert_eq!(event.fields.get("status").map(String::as_str), Some("403")); - assert_eq!( - event.fields.get("operation").map(String::as_str), - Some("input_token_count") - ); - } - - #[tokio::test] - async fn count_input_tokens_rejects_wrong_response_object() { - let server = MockServer::start(); - server.mock(|when, then| { - when.method(POST).path("/responses/input_tokens"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "object": "other", - "input_tokens": 789 - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .count_input_tokens(&minimal_request()) - .await - .unwrap_err(); - - assert!(matches!(err, Error::Configuration { .. })); - } - - #[tokio::test] - async fn complete_classifies_insufficient_quota_as_quota_exceeded() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path("/responses"); - then.status(429) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": { - "message": "You exceeded your current quota.", - "type": "insufficient_quota" - } - })); - }); - let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); - - let err = adapter - .complete(&minimal_request()) - .await - .expect_err("spent quota should fail the completion"); - - mock.assert(); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - assert_eq!(err.status_code(), Some(429)); - assert!(!err.retryable()); - assert!(err.failover_eligible()); - match err { - Error::Provider { detail, .. } => { - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[tokio::test] - async fn codex_complete_via_stream_propagates_stream_errors() { - let server = MockServer::start(); - let sse_body = r#"event: error -data: {"type":"error","error":{"type":"insufficient_quota","code":"insufficient_quota","message":"You exceeded your current quota."}} - -"#; - - server.mock(|when, then| { - when.method(POST).path("/responses"); - then.status(200) - .header("content-type", "text/event-stream") - .body(sse_body); - }); - - let adapter = Adapter::new("sk-test") - .with_base_url(server.base_url()) - .with_codex_mode(); - - let err = adapter - .complete(&minimal_request()) - .await - .expect_err("codex streaming completion should propagate stream errors"); - - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::QuotaExceeded); - assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); - assert!(detail.message.contains("exceeded your current quota")); - } - other => panic!("expected provider error, got {other:?}"), - } - } -} diff --git a/lib/components/fabro-llm/src/providers/openai_compatible.rs b/lib/components/fabro-llm/src/providers/openai_compatible.rs deleted file mode 100644 index b46444636..000000000 --- a/lib/components/fabro-llm/src/providers/openai_compatible.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::sync::Arc; - -use fabro_model::Catalog; - -use crate::codec::openai_compatible::OpenAiCompatible; -use crate::codec::{Codec, CodecCtx, CodecParams}; -use crate::error::Error; -use crate::provider::{ - ProviderAdapter, StreamEventStream, validate_standard_speed, validate_tool_choice, -}; -use crate::providers::common::{self as common, CatalogRoute}; -use crate::transport::{self, HttpTransport, SseFraming}; -use crate::types::{AdapterTimeout, Request, Response}; - -/// `OpenAI`-compatible Chat Completions adapter (Section 7.10). -/// -/// Use this for third-party services (vLLM, Ollama, Together AI, Groq, etc.) -/// that implement the `OpenAI` Chat Completions API (`/v1/chat/completions`). -/// -/// Does NOT support reasoning tokens, built-in tools, or other Responses API -/// features. Use the primary `OpenAiAdapter` for `OpenAI`'s own API. -/// -/// This is a thin transport shell over the `openai_compatible` codec: it owns -/// auth, base URL, and the streaming byte loop, and delegates all wire -/// translation to the codec. -pub struct Adapter { - pub(crate) http: HttpTransport, - provider_name: String, - catalog: Option>, -} - -impl Adapter { - #[must_use] - pub fn new(api_key: impl Into, base_url: impl Into) -> Self { - Self::new_optional_auth(Some(api_key.into()), base_url) - } - - #[must_use] - pub fn new_optional_auth(api_key: Option, base_url: impl Into) -> Self { - Self { - http: HttpTransport::new_optional(api_key, base_url), - provider_name: "openai-compatible".to_string(), - catalog: None, - } - } - - #[must_use] - pub fn with_name(mut self, name: impl Into) -> Self { - self.provider_name = name.into(); - self - } - - #[must_use] - pub fn with_default_headers(self, headers: std::collections::HashMap) -> Self { - Self { - http: self.http.with_default_headers(headers), - ..self - } - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = Some(catalog); - self - } - - #[must_use] - pub fn with_timeout(self, timeout: AdapterTimeout) -> Self { - Self { - http: self.http.with_timeout(timeout), - ..self - } - } - - /// Build a `fabro_http::RequestBuilder` with default headers and auth. - fn build_request(&self, url: &str) -> fabro_http::RequestBuilder { - let mut req = self.http.client.post(url); - // Apply default_headers first so adapter-specific headers can override - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - if let Some(api_key) = &self.http.api_key { - req = req.bearer_auth(api_key); - } - req - } - - /// Resolve the wire model id (catalog `api_id`, falling back to the - /// requested model). - fn deployment_id(&self, request: &Request) -> String { - self.api_model_id(&request.model) - } - - /// Build the borrowed codec context. `deployment_id` and `params` are - /// created by the caller so their borrows outlive the context. - fn codec_ctx<'a>( - &'a self, - request: &'a Request, - deployment_id: &'a str, - params: &'a CodecParams, - ) -> CodecCtx<'a> { - CodecCtx { - request, - provider_name: &self.provider_name, - deployment_id, - model: self.catalog_model(&request.model), - params, - } - } - - /// Encode `ctx.request` through the codec and assemble the HTTP request: - /// base URL + codec endpoint, default headers, auth, body, and dialect - /// headers. - fn encoded_request( - &self, - codec: &OpenAiCompatible, - ctx: &CodecCtx<'_>, - stream: bool, - ) -> Result { - let encoded = codec.encode(ctx, stream)?; - let url = format!("{}{}", self.http.base_url, encoded.endpoint); - let mut req = self.build_request(&url).json(&encoded.body); - for (key, value) in &encoded.headers { - req = req.header(key, value); - } - Ok(req) - } -} - -impl common::CatalogRoute for Adapter { - fn catalog(&self) -> Option<&Catalog> { - self.catalog.as_deref() - } - - fn provider_name(&self) -> &str { - &self.provider_name - } -} - -#[async_trait::async_trait] -impl ProviderAdapter for Adapter { - fn name(&self) -> &str { - &self.provider_name - } - - fn validate_request(&self, request: &Request) -> Result<(), Error> { - validate_standard_speed(self, request)?; - if let Some(tc) = &request.tool_choice { - validate_tool_choice(self, tc)?; - } - Ok(()) - } - - async fn complete(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let codec = OpenAiCompatible; - let deployment_id = self.deployment_id(request); - let params = CodecParams::default(); - let ctx = self.codec_ctx(request, &deployment_id, ¶ms); - - let mut req = self.encoded_request(&codec, &ctx, false)?; - if let Some(t) = self.http.request_timeout { - req = req.timeout(t); - } - - transport::complete_via_http(req, &codec, &ctx).await - } - - async fn stream(&self, request: &Request) -> Result { - self.validate_request(request)?; - - let codec = OpenAiCompatible; - let deployment_id = self.deployment_id(request); - let params = CodecParams::default(); - let ctx = self.codec_ctx(request, &deployment_id, ¶ms); - - let req = self.encoded_request(&codec, &ctx, true)?; - transport::stream_via_http( - req, - &codec, - &ctx, - SseFraming::DataLines, - self.http.stream_read_timeout, - ) - .await - } -} diff --git a/lib/components/fabro-llm/src/reasoning.rs b/lib/components/fabro-llm/src/reasoning.rs index 6c12ee762..ca34594d2 100644 --- a/lib/components/fabro-llm/src/reasoning.rs +++ b/lib/components/fabro-llm/src/reasoning.rs @@ -1,10 +1,10 @@ //! Normalization of provider reasoning material into [`ReasoningOutput`]. //! //! Every provider that returns readable reasoning does it differently, and -//! several return more than one channel at once. This module reduces the -//! final response's content parts to the two normalized fields without -//! reaching into opaque material (signatures, item IDs, encrypted payloads) -//! and without failing a completion it cannot classify. +//! several return more than one channel at once. This module reduces a final +//! response's content parts to the two normalized fields without reaching +//! into opaque material (signatures, item ids, encrypted payloads) and +//! without failing a completion it cannot classify. //! //! Parsing is deliberately tolerant: provider payloads are read as //! `serde_json::Value` with optional lookups, so unknown detail variants, @@ -13,17 +13,16 @@ use fabro_types::{ContentPart, ReasoningOutput}; -/// Separator between distinct complete reasoning blocks. Fragments of one -/// logical block are coalesced by the streaming decoders before they reach -/// this module. +/// OpenAI Responses reasoning items, as lithos stores them. +pub const OPENAI_REASONING_KIND: &str = "openai.reasoning"; +/// OpenAI Responses message items, as lithos stores them. +pub const OPENAI_MESSAGE_KIND: &str = "openai.message"; +/// OpenAI-compatible `reasoning_details` arrays, as lithos stores them. +pub const OPENAI_COMPAT_REASONING_DETAILS_KIND: &str = "openai_compatible.reasoning_details"; + +/// Separator between distinct complete reasoning blocks. const BLOCK_SEPARATOR: &str = "\n\n"; -/// Readable blocks collected per normalized field. -/// -/// Explicit blocks come from a channel with documented reasoning semantics. -/// Fallback blocks come from flattened provider strings, which aggregators -/// commonly duplicate alongside a structured channel. They only fill a trace -/// that no explicit trace produced. #[derive(Default)] struct Blocks<'a> { explicit_summary: Vec<&'a str>, @@ -47,8 +46,6 @@ impl Blocks<'_> { } } -/// Join retained complete blocks in provider order. Text is never trimmed or -/// rewritten. fn join_blocks(blocks: &[&str]) -> Option { (!blocks.is_empty()).then(|| blocks.join(BLOCK_SEPARATOR)) } @@ -59,16 +56,10 @@ fn push_block<'a>(blocks: &mut Vec<&'a str>, block: &'a str) { } } -/// Read a text-bearing member with the provider's documented semantics. fn readable_member<'a>(entry: &'a serde_json::Value, member: &str) -> Option<&'a str> { entry.get(member).and_then(serde_json::Value::as_str) } -/// Extract readable text from an OpenAI Responses `reasoning` output item. -/// -/// `summary[].text` is the model-authored summary; `content[]` entries typed -/// `reasoning_text` are the verbatim trace. `encrypted_content`, `id`, and -/// `status` are opaque and ignored. fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut Blocks<'a>) { if let Some(entries) = item.get("summary").and_then(serde_json::Value::as_array) { for entry in entries { @@ -95,7 +86,6 @@ fn collect_openai_reasoning_item<'a>(item: &'a serde_json::Value, blocks: &mut B } } -/// Extract readable text from OpenAI-compatible `reasoning_details` entries. fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Blocks<'a>) { let Some(entries) = details.as_array() else { return; @@ -121,23 +111,21 @@ fn collect_reasoning_details<'a>(details: &'a serde_json::Value, blocks: &mut Bl } } -/// Normalize the content parts of a final response into readable reasoning. +/// Normalizes the content parts of a final response into readable reasoning. /// -/// Returns `None` when the response carries no readable reasoning, so an -/// event without reasoning keeps its previous serialized shape. -pub(crate) fn normalize(content: &[ContentPart]) -> Option { +/// Returns `None` when the response carries no readable reasoning. +#[must_use] +pub fn normalize(content: &[ContentPart]) -> Option { let mut blocks = Blocks::default(); for part in content { match part { - ContentPart::Thinking(thinking) if !thinking.redacted => { - push_block(&mut blocks.fallback_trace, &thinking.text); + ContentPart::Reasoning(reasoning) if !reasoning.redacted => { + push_block(&mut blocks.fallback_trace, &reasoning.text); } - ContentPart::Other { kind, data } if kind == ContentPart::OPENAI_REASONING => { + ContentPart::Opaque { kind, data } if kind == OPENAI_REASONING_KIND => { collect_openai_reasoning_item(data, &mut blocks); } - ContentPart::Other { kind, data } - if kind == ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { + ContentPart::Opaque { kind, data } if kind == OPENAI_COMPAT_REASONING_DETAILS_KIND => { collect_reasoning_details(data, &mut blocks); } _ => {} @@ -146,33 +134,47 @@ pub(crate) fn normalize(content: &[ContentPart]) -> Option { blocks.into_output() } +/// Whether a part is provider-native replay material Fabro keeps in history +/// but never renders. +#[must_use] +pub fn is_provider_part(part: &ContentPart) -> bool { + matches!(part, ContentPart::Reasoning(_) | ContentPart::Opaque { .. }) +} + +/// Whether a part is an OpenAI Responses item tied to one specific API +/// response. Such items become invalid once compaction replaces their +/// surrounding context. +#[must_use] +pub fn is_opaque_openai(part: &ContentPart) -> bool { + matches!( + part, + ContentPart::Opaque { kind, .. } + if kind == OPENAI_REASONING_KIND || kind == OPENAI_MESSAGE_KIND + ) +} + #[cfg(test)] mod tests { - use fabro_types::ThinkingData; + use fabro_types::ReasoningContent; use serde_json::json; use super::*; fn thinking(text: &str) -> ContentPart { - ContentPart::Thinking(ThinkingData { - text: text.to_string(), - signature: None, - redacted: false, + ContentPart::Reasoning(ReasoningContent { + text: text.to_string(), + signature: None, + signature_origin: None, + redacted: false, }) } fn openai_reasoning(item: serde_json::Value) -> ContentPart { - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: item, - } + ContentPart::opaque(OPENAI_REASONING_KIND, item) } fn reasoning_details(details: serde_json::Value) -> ContentPart { - ContentPart::Other { - kind: ContentPart::OPENAI_COMPAT_REASONING_DETAILS.to_string(), - data: details, - } + ContentPart::opaque(OPENAI_COMPAT_REASONING_DETAILS_KIND, details) } #[test] @@ -184,10 +186,11 @@ mod tests { #[test] fn redacted_thinking_yields_no_readable_reasoning() { - let redacted = ContentPart::Thinking(ThinkingData { - text: "AAAAopaque".to_string(), - signature: Some("sig".to_string()), - redacted: true, + let redacted = ContentPart::Reasoning(ReasoningContent { + text: "AAAAopaque".to_string(), + signature: Some("sig".to_string()), + signature_origin: Some("anthropic".to_string()), + redacted: true, }); assert!(normalize(&[redacted]).is_none()); } @@ -218,58 +221,18 @@ mod tests { assert_eq!(output.summary(), Some("first\n\nsecond")); } - #[test] - fn unknown_responses_content_types_remain_opaque() { - assert!( - normalize(&[openai_reasoning(json!({ - "content": [{"type": "reasoning_future", "text": "not classified"}], - }))]) - .is_none() - ); - } - #[test] fn structured_details_produce_summary_and_trace() { let output = normalize(&[reasoning_details(json!([ {"type": "reasoning.summary", "summary": "checked the parser"}, {"type": "reasoning.text", "text": "read convert.rs", "signature": "sig"}, + {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, ]))]) .unwrap(); assert_eq!(output.summary(), Some("checked the parser")); assert_eq!(output.trace(), Some("read convert.rs")); } - #[test] - fn encrypted_details_are_excluded() { - let output = normalize(&[reasoning_details(json!([ - {"type": "reasoning.encrypted", "data": "gAAAAAsecret", "format": "openai-responses-v1"}, - {"type": "reasoning.summary", "summary": "visible"}, - ])),]) - .unwrap(); - assert_eq!(output.summary(), Some("visible")); - assert!(output.trace().is_none()); - } - - #[test] - fn encrypted_only_details_produce_no_reasoning() { - assert!( - normalize(&[reasoning_details(json!([ - {"type": "reasoning.encrypted", "data": "gAAAAAsecret"}, - ]))]) - .is_none() - ); - } - - #[test] - fn unknown_detail_variants_remain_opaque() { - assert!( - normalize(&[reasoning_details(json!([ - {"type": "reasoning.future", "text": "new channel"}, - ]))]) - .is_none() - ); - } - #[test] fn malformed_details_are_ignored_without_failing() { assert!(normalize(&[reasoning_details(json!("not-an-array"))]).is_none()); @@ -303,40 +266,27 @@ mod tests { thinking("flattened"), ]) .unwrap(); - assert!(output.summary().is_none()); assert_eq!(output.trace(), Some("verbatim")); } - #[test] - fn structured_summary_keeps_a_distinct_flattened_trace() { - let output = normalize(&[ - reasoning_details(json!([ - {"type": "reasoning.summary", "summary": "short summary"}, - ])), - thinking("full verbatim trace"), - ]) - .unwrap(); - assert_eq!(output.summary(), Some("short summary")); - assert_eq!(output.trace(), Some("full verbatim trace")); - } - #[test] fn whitespace_only_fragments_do_not_create_reasoning() { assert!(normalize(&[thinking(" \n ")]).is_none()); - } - - #[test] - fn non_empty_text_is_preserved_verbatim() { let output = normalize(&[thinking(" indented thought\n")]).unwrap(); assert_eq!(output.trace(), Some(" indented thought\n")); } #[test] - fn unrelated_content_parts_are_ignored() { - let parts = vec![ContentPart::text("answer"), ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: json!({"type": "message", "content": [{"text": "answer"}]}), - }]; - assert!(normalize(&parts).is_none()); + fn opaque_openai_items_are_recognized() { + assert!(is_opaque_openai(&openai_reasoning(json!({})))); + assert!(is_opaque_openai(&ContentPart::opaque( + OPENAI_MESSAGE_KIND, + json!({}) + ))); + assert!(!is_opaque_openai(&thinking("x"))); + assert!(is_provider_part(&thinking("x"))); + assert!(!is_provider_part(&ContentPart::Text { + text: "x".to_string(), + })); } } diff --git a/lib/components/fabro-llm/src/resolver.rs b/lib/components/fabro-llm/src/resolver.rs new file mode 100644 index 000000000..50d9735a0 --- /dev/null +++ b/lib/components/fabro-llm/src/resolver.rs @@ -0,0 +1,206 @@ +//! Fabro's model resolver. +//! +//! lithos's [`CatalogResolver`] resolves selectors against the whole catalog. +//! Fabro layers policy on top: providers and models marked disabled in +//! `metadata.fabro` are unreachable through every selector shape (explicit +//! `provider/model`, alias, provider default, and the global `default`), and a +//! stand-in provider answers for the provider it stands in for when that +//! provider has no credentials of its own. + +use std::collections::BTreeSet; + +use fabro_types::catalog_policy; +use lithos_llm::catalog::{Catalog, CatalogProvider, ProviderId}; +use lithos_llm::resolver::{ + AvailableProviders, CatalogResolver, ModelResolver, ModelSelectionError, ResolvedRoute, +}; +use lithos_llm::types::Request; + +/// Rejects disabled providers and models after catalog resolution. +#[derive(Clone, Copy, Debug, Default)] +pub struct FabroResolver; + +impl FabroResolver { + fn enabled_available(catalog: &Catalog, available: &AvailableProviders) -> AvailableProviders { + AvailableProviders::new( + available + .iter() + .filter(|id| { + catalog.provider_by_id(id).is_some_and(|provider| { + catalog_policy::provider_policy(provider).is_enabled() + }) + }) + .cloned() + .collect::>(), + ) + } + + /// The provider that stands in for `provider`, when one is available. + fn stand_in<'a>( + catalog: &'a Catalog, + provider: &ProviderId, + available: &AvailableProviders, + ) -> Option<&'a CatalogProvider> { + catalog.providers().find(|candidate| { + available.contains(candidate.id()) + && catalog_policy::provider_policy(candidate) + .stands_in_for + .as_deref() + == Some(provider.as_str()) + }) + } + + fn check_route(route: ResolvedRoute) -> Result { + if !catalog_policy::provider_policy(route.provider()).is_enabled() { + return Err(ModelSelectionError::ProviderUnavailable { + provider: route.provider().id().clone(), + }); + } + if !catalog_policy::model_policy(route.model()).is_enabled() { + return Err(ModelSelectionError::ModelNotFound { + selector: route.handle().to_string(), + }); + } + Ok(route) + } +} + +impl ModelResolver for FabroResolver { + fn resolve( + &self, + request: &Request, + catalog: &Catalog, + available: &AvailableProviders, + ) -> Result { + let available = Self::enabled_available(catalog, available); + match CatalogResolver.resolve(request, catalog, &available) { + Ok(route) => Self::check_route(route), + Err(ModelSelectionError::ProviderUnavailable { provider }) => { + let Some(stand_in) = Self::stand_in(catalog, &provider, &available) else { + return Err(ModelSelectionError::ProviderUnavailable { provider }); + }; + let selector = request.model(); + let model_selector = selector + .split_once('/') + .map(|(_, model)| model) + .filter(|_| selector != provider.as_str()); + let rerouted_selector = match model_selector { + Some(model) => format!("{}/{model}", stand_in.id()), + None => stand_in.id().to_string(), + }; + let rerouted = request + .clone() + .into_builder() + .model(rerouted_selector) + .build() + .map_err(|_| ModelSelectionError::ProviderUnavailable { + provider: provider.clone(), + })?; + CatalogResolver + .resolve(&rerouted, catalog, &available) + .and_then(Self::check_route) + } + Err(error) => Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use lithos_llm::types::{Message, Role}; + + use super::*; + use crate::test_support::test_catalog; + + fn request(model: &str) -> Request { + Request::builder() + .model(model) + .message(Message::text(Role::User, "hi")) + .build() + .unwrap() + } + + fn resolve( + catalog: &Catalog, + model: &str, + available: &[&str], + ) -> Result { + let available = AvailableProviders::new(available.iter().map(|id| ProviderId::new(*id))); + FabroResolver + .resolve(&request(model), catalog, &available) + .map(|route| route.handle().to_string()) + } + + #[test] + fn disabled_model_on_enabled_provider_stays_unreachable() { + let catalog = Catalog::builder() + .with_builtin() + .toml_layer("policy", crate::FABRO_POLICY_TOML) + .unwrap() + .toml_layer( + "test", + r#" +schema_version = 1 +[providers.openai.models."gpt-5.4".metadata.fabro] +enabled = false +"#, + ) + .unwrap() + .build() + .unwrap(); + for selector in ["openai/gpt-5.4", "gpt-5.4", "codex"] { + let error = resolve(&catalog, selector, &["openai"]).unwrap_err(); + assert!( + matches!(error, ModelSelectionError::ModelNotFound { .. }), + "{selector}: {error:?}" + ); + } + assert_eq!( + resolve(&catalog, "gpt-5.4-mini", &["openai"]).unwrap(), + "openai/gpt-5.4-mini" + ); + } + + #[test] + fn disabled_provider_is_unavailable_even_when_credentialed() { + let catalog = test_catalog(); + let error = resolve(&catalog, "bedrock/claude-sonnet-5", &["bedrock"]).unwrap_err(); + assert!(matches!( + error, + ModelSelectionError::ProviderUnavailable { .. } + )); + let error = resolve(&catalog, "default", &["bedrock"]).unwrap_err(); + assert!(matches!(error, ModelSelectionError::NoDefaultModel)); + } + + #[test] + fn codex_stands_in_for_openai_without_an_api_key() { + let catalog = test_catalog(); + assert_eq!( + resolve(&catalog, "openai/gpt-5.4-mini", &["openai-codex"]).unwrap(), + "openai-codex/gpt-5.4-mini" + ); + assert_eq!( + resolve(&catalog, "openai", &["openai-codex"]).unwrap(), + "openai-codex/gpt-5.6-sol" + ); + assert_eq!( + resolve(&catalog, "openai/gpt-5.4-mini", &["openai", "openai-codex"]).unwrap(), + "openai/gpt-5.4-mini", + "the real provider wins when it is ready" + ); + } + + #[test] + fn enabled_routes_resolve_like_lithos() { + let catalog = test_catalog(); + assert_eq!( + resolve(&catalog, "sonnet", &["anthropic", "openai"]).unwrap(), + "anthropic/claude-sonnet-5" + ); + assert_eq!( + resolve(&catalog, "default", &["openai"]).unwrap(), + "openai/gpt-5.6-sol" + ); + } +} diff --git a/lib/components/fabro-llm/src/retry.rs b/lib/components/fabro-llm/src/retry.rs deleted file mode 100644 index b036c73b4..000000000 --- a/lib/components/fabro-llm/src/retry.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::future::Future; -use std::time::Duration; - -use tokio::time; -use tracing::warn; - -use crate::error::Error; -use crate::types::RetryPolicy; - -/// Retry a fallible async operation according to the given policy (Section -/// 6.6). -/// -/// - Only retries if the error is retryable. -/// - Respects Retry-After from the error if less than `max_delay`. -/// - If Retry-After exceeds `max_delay`, does NOT retry. -/// -/// # Errors -/// -/// Returns the last `Error` if all retries are exhausted or the error is -/// non-retryable. -pub async fn retry(policy: &RetryPolicy, mut operation: F) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut attempt = 0u32; - - loop { - match operation().await { - Ok(result) => return Ok(result), - Err(err) => { - if !err.retryable() || attempt >= policy.max_retries { - return Err(err); - } - - let Some(delay) = retry_delay(policy, &err, attempt) else { - return Err(err); - }; - - warn!( - attempt = attempt, - delay_secs = delay.as_secs_f64(), - error = %err, - "LLM request failed, retrying" - ); - - if let Some(ref on_retry) = policy.on_retry { - on_retry(&err, attempt, delay); - } - - time::sleep(delay).await; - - attempt += 1; - } - } - } -} - -/// Return the delay for a retryable attempt, or `None` when `Retry-After` -/// exceeds the configured maximum delay. -#[must_use] -pub fn retry_delay(policy: &RetryPolicy, err: &Error, attempt: u32) -> Option { - if let Some(retry_after) = err.retry_after() { - let retry_after_dur = Duration::from_secs_f64(retry_after); - if retry_after_dur > policy.backoff.max_delay { - return None; - } - Some(retry_after_dur) - } else { - // Convert from 0-indexed (fabro-llm convention) to 1-indexed (BackoffPolicy). - Some(policy.backoff.delay_for_attempt(attempt + 1)) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicU32, Ordering}; - - use fabro_util::backoff::BackoffPolicy; - use tokio::time::Instant; - - use super::*; - use crate::error::{ProviderErrorDetail, ProviderErrorKind}; - use crate::types::RetryPolicy; - - fn fast_backoff() -> BackoffPolicy { - BackoffPolicy { - initial_delay: Duration::from_micros(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - } - } - - #[tokio::test] - async fn retry_succeeds_first_try() { - let policy = RetryPolicy { - max_retries: 2, - backoff: BackoffPolicy { - jitter: false, - ..BackoffPolicy::default() - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Ok::<_, Error>(42) - } - }) - .await; - - assert_eq!(result.unwrap(), 42); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_succeeds_after_retries() { - let policy = RetryPolicy { - max_retries: 3, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 2 { - Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } else { - Ok(99) - } - } - }) - .await; - - assert_eq!(result.unwrap(), 99); - assert_eq!(call_count.load(Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn retry_gives_up_after_max_retries() { - let policy = RetryPolicy { - max_retries: 2, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 3); // 1 initial + 2 retries - } - - #[tokio::test] - async fn retry_does_not_retry_non_retryable() { - let policy = RetryPolicy { - max_retries: 3, - backoff: fast_backoff(), - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::Authentication, - detail: Box::new(ProviderErrorDetail { - status_code: Some(401), - ..ProviderErrorDetail::new("bad key", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_skips_when_retry_after_exceeds_max_delay() { - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_micros(1), - factor: 2.0, - max_delay: Duration::from_secs(5), - jitter: false, - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - cc.fetch_add(1, Ordering::SeqCst); - Err::(Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(100.0), // Way beyond max_delay - ..ProviderErrorDetail::new("rate limited", "test") - }), - }) - } - }) - .await; - - assert!(result.is_err()); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn retry_uses_retry_after_when_within_limit() { - let policy = RetryPolicy { - max_retries: 1, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(10), // high, but retry_after is low - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - }, - ..Default::default() - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let start = Instant::now(); - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 1 { - Err(Error::Provider { - kind: ProviderErrorKind::RateLimit, - detail: Box::new(ProviderErrorDetail { - status_code: Some(429), - retry_after: Some(0.01), - ..ProviderErrorDetail::new("rate limited", "test") - }), - }) - } else { - Ok(42) - } - } - }) - .await; - - let elapsed = start.elapsed(); - assert_eq!(result.unwrap(), 42); - assert_eq!(call_count.load(Ordering::SeqCst), 2); - // Should have waited ~0.01s, not ~10s - assert!(elapsed.as_secs_f64() < 1.0); - } - - #[tokio::test] - async fn retry_invokes_on_retry_callback() { - let retry_attempts = Arc::new(AtomicU32::new(0)); - let retry_attempts_clone = retry_attempts.clone(); - - let policy = RetryPolicy { - max_retries: 2, - backoff: fast_backoff(), - on_retry: Some(Arc::new(move |_err, _attempt, _delay| { - retry_attempts_clone.fetch_add(1, Ordering::SeqCst); - })), - }; - - let call_count = Arc::new(AtomicU32::new(0)); - let cc = call_count.clone(); - - let result = retry(&policy, || { - let cc = cc.clone(); - async move { - let count = cc.fetch_add(1, Ordering::SeqCst); - if count < 2 { - Err(Error::Provider { - kind: ProviderErrorKind::Server, - detail: Box::new(ProviderErrorDetail { - status_code: Some(500), - ..ProviderErrorDetail::new("error", "test") - }), - }) - } else { - Ok(99) - } - } - }) - .await; - - assert_eq!(result.unwrap(), 99); - assert_eq!(call_count.load(Ordering::SeqCst), 3); - // on_retry should have been called twice (before each retry) - assert_eq!(retry_attempts.load(Ordering::SeqCst), 2); - } -} diff --git a/lib/components/fabro-llm/src/selection.rs b/lib/components/fabro-llm/src/selection.rs new file mode 100644 index 000000000..791872cbf --- /dev/null +++ b/lib/components/fabro-llm/src/selection.rs @@ -0,0 +1,425 @@ +//! Model selection shared by every Fabro dispatch boundary. +//! +//! lithos resolves a request's selector at call time. Fabro also has to pick +//! a provider and model before there is a request: when a run is created, +//! when a workflow is validated, when a fallback chain is compiled. Those +//! boundaries share one passthrough policy: +//! +//! - A selector known to the catalog resolves to its canonical offering. +//! - `provider/model` pins the provider, as the lithos resolver reads it. +//! - An unknown selector pinned to a provider passes through verbatim on that +//! provider. +//! - An unqualified unknown selector passes through on the default provider. +//! - No selector picks the default offering (of the pinned provider, when one +//! is given). +//! +//! Only enabled providers and models take part. Disabled ones are invisible +//! here, exactly as they are to the client's resolver. + +use std::collections::HashSet; +use std::fmt; + +use fabro_types::{ModelId, ProviderId}; +use lithos_llm::catalog::Catalog; +use thiserror::Error; + +use crate::catalog::{self, ModelEntry}; + +/// A provider/model pair one of the selection functions chose. +/// +/// `model` is the canonical catalog id when the selector matched an offering, +/// or the caller's selector passed through verbatim when it did not. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedModel { + pub provider: ProviderId, + pub model: String, +} + +/// A resolved fallback target: provider id plus model id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FallbackTarget { + pub provider: ProviderId, + pub model: ModelId, +} + +impl FallbackTarget { + /// Builds a target from anything that renders as a provider id and model + /// id, so callers holding typed ids or bare passthrough selectors all use + /// one constructor. + pub fn new(provider: impl fmt::Display, model: impl fmt::Display) -> Self { + Self { + provider: ProviderId::new(provider.to_string()), + model: ModelId::new(model.to_string()), + } + } +} + +impl fmt::Display for FallbackTarget { + /// Renders as `provider:model`, the qualified form model references + /// accept. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.provider, self.model) + } +} + +/// Why a selection could not be made. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ModelSelectionError { + #[error("unknown model provider '{provider}'")] + UnknownProvider { provider: String }, + #[error("model provider '{provider}' is unavailable")] + ProviderUnavailable { provider: ProviderId }, + #[error("unknown model selector '{selector}'")] + UnknownSelector { selector: String }, + #[error("model selector '{selector}' is unknown on provider '{provider}'")] + UnknownSelectorOnProvider { + selector: String, + provider: ProviderId, + }, + #[error( + "model selector '{selector}' is known but has no offering on an eligible provider; available providers: {providers:?}" + )] + NoEligibleOffering { + selector: String, + providers: Vec, + }, + #[error( + "no default model is available on an eligible provider; providers with defaults: {providers:?}" + )] + NoDefaultModel { providers: Vec }, +} + +/// Canonicalizes a provider id or alias, requiring an enabled provider. +pub fn require_provider( + catalog: &Catalog, + selector: &str, +) -> Result { + catalog::canonical_provider_id(catalog, selector).ok_or_else(|| { + ModelSelectionError::UnknownProvider { + provider: selector.to_string(), + } + }) +} + +/// Canonicalizes a provider and requires it to be in the eligible set. +pub fn ready_provider( + catalog: &Catalog, + provider: &ProviderId, + eligible: &HashSet, +) -> Result { + let provider = require_provider(catalog, provider.as_str())?; + if canonical_eligible(catalog, eligible).contains(&provider) { + Ok(provider) + } else { + Err(ModelSelectionError::ProviderUnavailable { provider }) + } +} + +/// Finds `selector` as an enabled model on an enabled provider. +pub fn resolve_on_provider<'a>( + catalog: &'a Catalog, + provider: &ProviderId, + selector: &str, +) -> Result, ModelSelectionError> { + let provider = require_provider(catalog, provider.as_str())?; + catalog::model_on_provider(catalog, provider.as_str(), selector).ok_or( + ModelSelectionError::UnknownSelectorOnProvider { + selector: selector.to_string(), + provider, + }, + ) +} + +/// Selects a catalog model for `selector`, requiring a real offering. +/// +/// With an explicit provider the model must exist there. Otherwise models +/// named `selector` are preferred over aliases, and the highest-priority +/// eligible offering wins. +pub fn select<'a>( + catalog: &'a Catalog, + selector: &str, + explicit_provider: Option<&ProviderId>, + eligible: &HashSet, +) -> Result, ModelSelectionError> { + if let Some(explicit) = explicit_provider { + let provider = ready_provider(catalog, explicit, eligible)?; + return resolve_on_provider(catalog, &provider, selector); + } + // `provider/model` pins the provider, exactly as the lithos resolver reads + // it at request time. A slash whose prefix is not a provider (an + // aggregator's `vendor/model` api id) falls through to plain matching. + if let Some((prefix, rest)) = selector.split_once('/') { + if let Some(provider) = catalog::canonical_provider_id(catalog, prefix) { + let provider = ready_provider(catalog, &provider, eligible)?; + return resolve_on_provider(catalog, &provider, rest); + } + } + let matches = catalog::models_matching(catalog, selector); + if matches.is_empty() { + return Err(ModelSelectionError::UnknownSelector { + selector: selector.to_string(), + }); + } + let eligible = canonical_eligible(catalog, eligible); + let providers: Vec = matches + .iter() + .map(|entry| entry.provider.id().clone()) + .collect(); + matches + .into_iter() + .find(|entry| eligible.contains(entry.provider.id())) + .ok_or(ModelSelectionError::NoEligibleOffering { + selector: selector.to_string(), + providers, + }) +} + +/// The default offering of the highest-priority eligible provider. +pub fn select_default<'a>( + catalog: &'a Catalog, + eligible: &HashSet, +) -> Result, ModelSelectionError> { + let eligible = canonical_eligible(catalog, eligible); + let providers_with_defaults: Vec<_> = catalog::enabled_providers(catalog) + .into_iter() + .filter_map(|entry| { + catalog::default_model(catalog, entry.provider.id().as_str()) + .map(|model| (entry.provider.id().clone(), model)) + }) + .collect(); + providers_with_defaults + .iter() + .find(|(provider, _)| eligible.contains(provider)) + .map(|(_, model)| model.clone()) + .ok_or_else(|| ModelSelectionError::NoDefaultModel { + providers: providers_with_defaults + .into_iter() + .map(|(provider, _)| provider) + .collect(), + }) +} + +/// Resolves an optional selector to one provider/model pair under Fabro's +/// passthrough policy (see the module docs). +pub fn resolve_selection( + catalog: &Catalog, + selector: Option<&str>, + explicit_provider: Option<&ProviderId>, + eligible: &HashSet, +) -> Result { + let Some(selector) = selector else { + let eligible = match explicit_provider { + Some(provider) => HashSet::from([ready_provider(catalog, provider, eligible)?]), + None => eligible.clone(), + }; + let offering = select_default(catalog, &eligible)?; + return Ok(SelectedModel { + provider: offering.provider.id().clone(), + model: offering.model.id().to_string(), + }); + }; + match select(catalog, selector, explicit_provider, eligible) { + Ok(offering) => Ok(SelectedModel { + provider: offering.provider.id().clone(), + model: offering.model.id().to_string(), + }), + Err(ModelSelectionError::UnknownSelectorOnProvider { provider, selector }) => { + Ok(SelectedModel { + provider, + model: selector, + }) + } + Err(ModelSelectionError::UnknownSelector { .. }) => { + let default = select_default(catalog, eligible)?; + Ok(SelectedModel { + provider: default.provider.id().clone(), + model: selector.to_string(), + }) + } + Err(error) => Err(error), + } +} + +/// Resolves against `preferred` providers first, falling back to every enabled +/// provider only when the preferred set cannot supply the requested provider +/// or model. Semantic failures such as an unknown provider do not fall back. +pub fn resolve_selection_with_catalog_fallback( + catalog: &Catalog, + selector: Option<&str>, + explicit_provider: Option<&ProviderId>, + preferred: &HashSet, +) -> Result { + match resolve_selection(catalog, selector, explicit_provider, preferred) { + Err( + ModelSelectionError::ProviderUnavailable { .. } + | ModelSelectionError::NoEligibleOffering { .. } + | ModelSelectionError::NoDefaultModel { .. }, + ) => resolve_selection( + catalog, + selector, + explicit_provider, + &catalog::enabled_provider_ids(catalog), + ), + result => result, + } +} + +fn canonical_eligible(catalog: &Catalog, eligible: &HashSet) -> HashSet { + eligible + .iter() + .filter_map(|id| catalog::canonical_provider_id(catalog, id.as_str())) + .collect() +} + +#[cfg(test)] +mod tests { + use fabro_types::provider_ids; + + use super::*; + use crate::test_support::{test_catalog, test_catalog_with_overlay}; + + fn eligible(ids: &[&str]) -> HashSet { + ids.iter().map(|id| ProviderId::new(*id)).collect() + } + + #[test] + fn known_alias_resolves_to_canonical_offering_on_an_eligible_provider() { + let catalog = test_catalog(); + let selected = + resolve_selection(&catalog, Some("sonnet"), None, &eligible(&["anthropic"])).unwrap(); + assert_eq!(selected, SelectedModel { + provider: provider_ids::anthropic(), + model: "claude-sonnet-5".to_string(), + }); + } + + #[test] + fn unknown_selector_passes_through_on_the_default_provider() { + let catalog = test_catalog(); + let selected = resolve_selection( + &catalog, + Some("totally-new-model"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(selected.provider, provider_ids::anthropic()); + assert_eq!(selected.model, "totally-new-model"); + } + + #[test] + fn slash_qualified_selector_pins_the_provider_like_the_lithos_resolver() { + let catalog = test_catalog(); + let selected = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(selected, SelectedModel { + provider: provider_ids::openai(), + model: "gpt-5.6-sol".to_string(), + }); + + let unknown = resolve_selection( + &catalog, + Some("openai/brand-new-model"), + None, + &eligible(&["openai", "anthropic"]), + ) + .unwrap(); + assert_eq!(unknown, SelectedModel { + provider: provider_ids::openai(), + model: "brand-new-model".to_string(), + }); + + let unavailable = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + None, + &eligible(&["anthropic"]), + ); + assert_eq!( + unavailable, + Err(ModelSelectionError::ProviderUnavailable { + provider: provider_ids::openai(), + }) + ); + } + + #[test] + fn slash_selector_with_a_non_provider_prefix_matches_api_ids_on_a_pinned_provider() { + let catalog = + test_catalog_with_overlay("[providers.openrouter.metadata.fabro]\nenabled = true\n"); + let selected = resolve_selection( + &catalog, + Some("openai/gpt-5.6-sol"), + Some(&ProviderId::new("openrouter")), + &eligible(&["openrouter"]), + ) + .unwrap(); + assert_eq!(selected, SelectedModel { + provider: ProviderId::new("openrouter"), + model: "gpt-5.6-sol".to_string(), + }); + } + + #[test] + fn pinned_provider_must_be_eligible() { + let catalog = test_catalog(); + let error = resolve_selection( + &catalog, + Some("gpt-5.4"), + Some(&provider_ids::openai()), + &eligible(&["anthropic"]), + ) + .unwrap_err(); + assert!(matches!( + error, + ModelSelectionError::ProviderUnavailable { provider } if provider == provider_ids::openai() + )); + } + + #[test] + fn catalog_fallback_recovers_from_readiness_failures_only() { + let catalog = test_catalog(); + let selected = resolve_selection_with_catalog_fallback( + &catalog, + Some("gpt-5.4"), + Some(&provider_ids::openai()), + &eligible(&["anthropic"]), + ) + .unwrap(); + assert_eq!(selected.provider, provider_ids::openai()); + let error = resolve_selection_with_catalog_fallback( + &catalog, + None, + Some(&ProviderId::new("nope")), + &eligible(&["anthropic"]), + ) + .unwrap_err(); + assert!(matches!(error, ModelSelectionError::UnknownProvider { .. })); + } + + #[test] + fn disabled_providers_are_not_selectable() { + let catalog = test_catalog(); + assert!(matches!( + select(&catalog, "gpt-5.4", None, &eligible(&["openrouter"])), + Err(ModelSelectionError::NoEligibleOffering { .. }) + )); + let enabled = + test_catalog_with_overlay("[providers.openrouter.metadata.fabro]\nenabled = true\n"); + let entry = select(&enabled, "gpt-5.4", None, &eligible(&["openrouter"])).unwrap(); + assert_eq!(entry.provider.id(), &ProviderId::new("openrouter")); + } + + #[test] + fn fallback_targets_render_qualified() { + assert_eq!( + FallbackTarget::new("openai", "gpt-5.4").to_string(), + "openai:gpt-5.4" + ); + } +} diff --git a/lib/components/fabro-llm/src/structured.rs b/lib/components/fabro-llm/src/structured.rs new file mode 100644 index 000000000..3bc5b730e --- /dev/null +++ b/lib/components/fabro-llm/src/structured.rs @@ -0,0 +1,103 @@ +//! One-shot structured output. + +use lithos_llm::client::Client; +use lithos_llm::middleware::CallContext; +use lithos_llm::types::{Error, ErrorKind, Request, Response, ResponseFormat}; + +/// A completion whose text parsed as the requested JSON object. +#[derive(Debug, Clone)] +pub struct StructuredCompletion { + pub response: Response, + pub object: serde_json::Value, +} + +/// Completes `request` under a JSON schema and parses the reply. +/// +/// The schema is attached as the request's response format, so providers +/// with native structured output enforce it. The reply text must still parse +/// as JSON; a reply that does not is a `ResponseDecode` error. +pub async fn complete_object( + client: &Client, + request: Request, + schema_name: &str, + schema: serde_json::Value, +) -> Result { + complete_object_with_context(client, request, schema_name, schema, CallContext::new()).await +} + +pub async fn complete_object_with_context( + client: &Client, + request: Request, + schema_name: &str, + schema: serde_json::Value, + context: CallContext, +) -> Result { + let request = request + .into_builder() + .response_format(ResponseFormat::JsonSchema { + name: schema_name.to_string(), + schema, + }) + .build() + .map_err(|source| { + Error::new( + ErrorKind::InvalidRequest, + "structured output request is invalid", + ) + .with_source(source) + })?; + let response = client.complete_with_context(request, context).await?; + let object = parse_object(&response)?; + Ok(StructuredCompletion { response, object }) +} + +/// Parses a response's JSON output: a `Json` part when the provider returned +/// one, else the concatenated text. +pub fn parse_object(response: &Response) -> Result { + if let Some(value) = response.content.iter().find_map(|part| match part { + fabro_types::ContentPart::Json { value } => Some(value.clone()), + _ => None, + }) { + return Ok(value); + } + let text = response.text(); + serde_json::from_str(text.trim()).map_err(|source| { + Error::new( + ErrorKind::ResponseDecode, + format!("the model did not return a JSON object: {source}"), + ) + .with_provider(response.model.provider().clone()) + .with_source(source) + }) +} + +#[cfg(test)] +mod tests { + use fabro_types::{ContentPart, ModelId, ProviderId}; + use serde_json::json; + + use super::*; + + fn response(parts: Vec) -> Response { + Response::new(ProviderId::new("openai"), ModelId::new("gpt-5.4"), parts) + } + + #[test] + fn parses_text_or_json_parts() { + let text = response(vec![ContentPart::Text { + text: " {\"title\": \"x\"} ".to_string(), + }]); + assert_eq!(parse_object(&text).unwrap(), json!({"title": "x"})); + let json = response(vec![ContentPart::Json { + value: json!({"a": 1}), + }]); + assert_eq!(parse_object(&json).unwrap(), json!({"a": 1})); + let prose = response(vec![ContentPart::Text { + text: "sorry".to_string(), + }]); + assert_eq!( + parse_object(&prose).unwrap_err().kind(), + ErrorKind::ResponseDecode + ); + } +} diff --git a/lib/components/fabro-llm/src/test_support.rs b/lib/components/fabro-llm/src/test_support.rs new file mode 100644 index 000000000..b10b3ebaa --- /dev/null +++ b/lib/components/fabro-llm/src/test_support.rs @@ -0,0 +1,253 @@ +//! Test doubles for crates that drive the LLM client. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use fabro_auth::test_support::env_credential_source; +use fabro_config::LlmLayer; +use fabro_types::{ContentPart, ModelId, ProviderId, TokenCounts}; +use futures::stream; +use lithos_llm::adapter::{ProviderAdapter, ResolvedCall}; +use lithos_llm::catalog::{AdapterId, Catalog}; +use lithos_llm::client::Client; +use lithos_llm::middleware::RetryPolicy; +use lithos_llm::types::{ + ContentBlockId, ContentBlockKind, Error, FinishReason, Response, ResponseStream, StreamEvent, + ToolCallKind, +}; + +use crate::client::{ClientOptions, build_client, build_offline_client}; + +/// The lithos built-in catalog with Fabro's policy layer applied. +#[must_use] +pub fn test_catalog() -> Catalog { + crate::build_catalog(&LlmLayer::default(), &|_| None).expect("test catalog should build") +} + +/// The test catalog with an operator overlay applied. +#[must_use] +pub fn test_catalog_with_overlay(overlay: &str) -> Catalog { + let overlay = LlmLayer(toml::from_str(overlay).expect("overlay should parse")); + crate::build_catalog(&overlay, &|_| None).expect("test catalog with overlay should build") +} + +/// The test catalog with one provider's base URL pointed elsewhere. +#[must_use] +pub fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Catalog { + test_catalog_with_overlay(&format!( + "[providers.{provider}]\nbase_url = {}\n", + toml::Value::String(base_url.to_string()) + )) +} + +/// Builds a text response attributed to `provider/model`. +#[must_use] +pub fn text_response(provider: &str, model: &str, text: &str) -> Response { + let mut response = Response::new(ProviderId::new(provider), ModelId::new(model), vec![ + ContentPart::Text { + text: text.to_string(), + }, + ]); + response.usage = TokenCounts { + input: 10, + output: 5, + ..TokenCounts::default() + }; + response +} + +/// Replays a response as the event stream a codec would produce. +#[must_use] +pub fn response_to_stream(response: Response) -> ResponseStream { + let mut events: Vec> = vec![Ok(StreamEvent::Started { + id: response.id.clone(), + })]; + for (index, part) in response.content.iter().enumerate() { + let id = ContentBlockId::new(format!("block_{index}")); + match part { + ContentPart::Text { text } => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::Text, + })); + events.push(Ok(StreamEvent::TextDelta { + id: id.clone(), + text: text.clone(), + })); + } + ContentPart::Reasoning(reasoning) => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::Reasoning, + })); + events.push(Ok(StreamEvent::ReasoningDelta { + id: id.clone(), + text: reasoning.text.clone(), + })); + } + ContentPart::ToolCall(call) => { + events.push(Ok(StreamEvent::ContentBlockStart { + id: id.clone(), + kind: ContentBlockKind::ToolCall { + id: call.id.clone(), + name: Some(call.name.clone()), + kind: match call.input { + fabro_types::ToolInput::Custom(_) => ToolCallKind::Custom, + _ => ToolCallKind::Function, + }, + }, + })); + events.push(Ok(StreamEvent::ToolCallDelta { + id: id.clone(), + arguments: call.input.raw().to_string(), + })); + } + _ => {} + } + events.push(Ok(StreamEvent::ContentBlockEnd { + id, + part: part.clone(), + })); + } + events.push(Ok(StreamEvent::Usage { + usage: response.usage, + })); + events.push(Ok(StreamEvent::Ended { + response: Box::new(response), + })); + ResponseStream::new(stream::iter(events)) +} + +/// An adapter that answers from a script of responses, repeating the last. +pub struct ScriptedAdapter { + id: AdapterId, + responses: Vec, + call_index: AtomicUsize, +} + +impl ScriptedAdapter { + #[must_use] + pub fn new(responses: Vec) -> Self { + Self { + id: AdapterId::new("scripted"), + responses, + call_index: AtomicUsize::new(0), + } + } + + fn next_response(&self) -> Response { + let index = self.call_index.fetch_add(1, Ordering::SeqCst); + self.responses[index.min(self.responses.len() - 1)].clone() + } + + #[must_use] + pub fn calls(&self) -> usize { + self.call_index.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl ProviderAdapter for ScriptedAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, _call: &ResolvedCall) -> Result { + Ok(self.next_response()) + } + + async fn stream(&self, _call: &ResolvedCall) -> Result { + Ok(response_to_stream(self.next_response())) + } +} + +/// A retry policy for tests: three attempts with no delay between them, so a +/// test counts provider calls without waiting. +pub fn test_retry_policy() -> RetryPolicy { + RetryPolicy::exponential() + .max_attempts(3) + .initial_delay(Duration::ZERO) + .max_delay(Duration::ZERO) + .jitter(false) +} + +/// An adapter that fails every call with a fresh error from `factory`. +pub struct FailingAdapter { + id: AdapterId, + factory: Box Error + Send + Sync>, + calls: AtomicUsize, +} + +impl FailingAdapter { + pub fn new(factory: impl Fn() -> Error + Send + Sync + 'static) -> Self { + Self { + id: AdapterId::new("failing"), + factory: Box::new(factory), + calls: AtomicUsize::new(0), + } + } + + #[must_use] + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl ProviderAdapter for FailingAdapter { + fn id(&self) -> &AdapterId { + &self.id + } + + async fn complete(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) + } + + async fn stream(&self, _call: &ResolvedCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err((self.factory)()) + } +} + +/// A client over the test catalog that routes `provider` to `adapter`, with +/// no retries. +#[must_use] +pub fn client_with_adapter(provider: &str, adapter: Arc) -> Client { + client_with_adapters(vec![(provider, adapter)], ClientOptions::default()) +} + +/// A client over the test catalog whose providers are all served by the given +/// adapters, built with `options`. +#[must_use] +pub fn client_with_adapters( + adapters: Vec<(&str, Arc)>, + mut options: ClientOptions, +) -> Client { + for (provider, adapter) in adapters { + options.adapters.push((ProviderId::new(provider), adapter)); + } + build_offline_client(test_catalog(), options) + .expect("test client should build") + .client +} + +/// A client whose ready providers come only from `env_lookup`. +pub async fn client_from_env(catalog: Catalog, env_lookup: F, options: ClientOptions) -> Client +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + build_client(catalog, env_credential_source(env_lookup), options) + .await + .expect("test client should build") + .client +} + +/// A finished response marker for tests that need a finish reason. +#[must_use] +pub fn with_finish_reason(mut response: Response, finish_reason: FinishReason) -> Response { + response.finish_reason = finish_reason; + response +} diff --git a/lib/components/fabro-llm/src/token_count.rs b/lib/components/fabro-llm/src/token_count.rs deleted file mode 100644 index fa64abda7..000000000 --- a/lib/components/fabro-llm/src/token_count.rs +++ /dev/null @@ -1,460 +0,0 @@ -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; - -use crate::types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, Request, Role, ToolDefinition, - ToolResult, Warning, -}; - -/// Warning code emitted when the entire request was tokenized locally -/// (no provider-side count available). -pub const LOCAL_ESTIMATE_WARNING: &str = "local_token_estimate"; -/// Warning code emitted when media (image/audio/document) tokens were -/// estimated from byte counts rather than counted by the provider. -pub const MEDIA_ESTIMATE_WARNING: &str = "media_token_estimate"; -/// Warning code emitted when an opaque `ContentPart::Other` block was -/// estimated by JSON-stringifying it (e.g. OpenAI reasoning items). -pub const OPAQUE_CONTEXT_ESTIMATE_WARNING: &str = "opaque_context_estimate"; -/// Warning code emitted when provider-specific request options were -/// estimated by JSON-stringifying them. -pub const PROVIDER_OPTIONS_ESTIMATE_WARNING: &str = "provider_options_estimate"; - -/// True if a warning code is "local estimator noise" — the warning is only -/// meaningful when the displayed total comes from the local estimator. When -/// the total is provider-authoritative (e.g. scaled to `usage.input_tokens`), -/// these warnings only describe imprecision in the per-category breakdown -/// split, not in the total — and so they tend to alarm users about a number -/// that's actually correct. -#[must_use] -pub fn is_local_estimator_warning(code: &str) -> bool { - matches!( - code, - LOCAL_ESTIMATE_WARNING - | MEDIA_ESTIMATE_WARNING - | OPAQUE_CONTEXT_ESTIMATE_WARNING - | PROVIDER_OPTIONS_ESTIMATE_WARNING - ) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InputTokenCountPreference { - PreferProvider, - RequireProvider, - EstimateOnly, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum InputTokenCountMethod { - ProviderApi, - LocalEstimate, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct InputTokenCount { - pub input_tokens: i64, - pub method: InputTokenCountMethod, - pub provider: String, - pub model: String, - #[serde(default)] - pub warnings: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LocalTokenEstimate { - pub tokens: usize, - pub warnings: Vec, -} - -#[must_use] -pub fn estimate_input_tokens(request: &Request, provider: impl Into) -> InputTokenCount { - let mut estimator = Estimator::default(); - let mut tokens = 0usize; - - for message in &request.messages { - tokens += estimator.estimate_message(message); - } - - if let Some(tools) = &request.tools { - tokens += tools.iter().map(estimate_tool).sum::(); - } - - tokens += estimator.estimate_request_controls(request); - - estimator.warn( - LOCAL_ESTIMATE_WARNING, - "Provider didn't report a token count; total is approximate.", - ); - - InputTokenCount { - input_tokens: i64::try_from(tokens).unwrap_or(i64::MAX), - method: InputTokenCountMethod::LocalEstimate, - provider: provider.into(), - model: request.model.clone(), - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_text_tokens(text: &str) -> usize { - text.chars().count().div_ceil(4) -} - -#[must_use] -pub fn estimate_json_tokens(value: &serde_json::Value) -> usize { - serde_json::to_string(value).map_or(0, |json| json.len().div_ceil(4)) -} - -#[must_use] -pub fn estimate_message_tokens(message: &Message) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_message(message); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_content_part_tokens(part: &ContentPart) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_content_part(part); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[must_use] -pub fn estimate_tool_definition_tokens(tool: &ToolDefinition) -> usize { - estimate_tool(tool) -} - -#[must_use] -pub fn estimate_request_control_tokens(request: &Request) -> LocalTokenEstimate { - let mut estimator = Estimator::default(); - let tokens = estimator.estimate_request_controls(request); - LocalTokenEstimate { - tokens, - warnings: estimator.warnings, - } -} - -#[derive(Default)] -struct Estimator { - warnings: Vec, - seen_codes: HashSet<&'static str>, -} - -impl Estimator { - fn estimate_message(&mut self, message: &Message) -> usize { - let mut tokens = 4 + estimate_text_tokens(message.role_name()); - if let Some(name) = &message.name { - tokens += estimate_text_tokens(name); - } - if let Some(tool_call_id) = &message.tool_call_id { - tokens += estimate_text_tokens(tool_call_id); - } - for part in &message.content { - tokens += 1 + self.estimate_content_part(part); - } - tokens - } - - fn estimate_request_controls(&mut self, request: &Request) -> usize { - let mut tokens = 0; - if let Some(tool_choice) = &request.tool_choice { - if let Ok(value) = serde_json::to_value(tool_choice) { - tokens += estimate_json_tokens(&value); - } - } - - if let Some(response_format) = &request.response_format { - if let Ok(value) = serde_json::to_value(response_format) { - tokens += estimate_json_tokens(&value); - } - } - - if let Some(reasoning_effort) = request.reasoning_effort { - tokens += estimate_text_tokens(reasoning_effort.to_string().as_str()); - } - - if let Some(provider_options) = &request.provider_options { - tokens += estimate_json_tokens(provider_options); - self.warn( - PROVIDER_OPTIONS_ESTIMATE_WARNING, - "Provider options couldn't be precisely tokenized; total is approximate.", - ); - } - tokens - } - - fn estimate_content_part(&mut self, part: &ContentPart) -> usize { - match part { - ContentPart::Text(text) => estimate_text_tokens(text), - ContentPart::Image(image) => self.estimate_image(image), - ContentPart::Audio(audio) => self.estimate_audio(audio), - ContentPart::Document(document) => self.estimate_document(document), - ContentPart::ToolCall(tool_call) => estimate_json_tokens(&serde_json::json!(tool_call)), - ContentPart::ToolResult(result) => self.estimate_tool_result(result), - ContentPart::Thinking(thinking) => { - estimate_text_tokens(&thinking.text) - + thinking - .signature - .as_deref() - .map_or(0, estimate_text_tokens) - + usize::from(thinking.redacted) - } - ContentPart::Other { kind, data } => { - self.warn( - OPAQUE_CONTEXT_ESTIMATE_WARNING, - "Some content couldn't be precisely tokenized; total is approximate.", - ); - estimate_text_tokens(kind) + estimate_json_tokens(data) - } - } - } - - fn estimate_tool_result(&mut self, result: &ToolResult) -> usize { - let mut tokens = - estimate_text_tokens(&result.tool_call_id) + estimate_json_tokens(&result.content); - if let Some(image_data) = &result.image_data { - tokens += estimate_byte_tokens(image_data.len()); - self.warn( - MEDIA_ESTIMATE_WARNING, - "Media content couldn't be precisely tokenized; total is approximate.", - ); - } - if let Some(media_type) = &result.image_media_type { - tokens += estimate_text_tokens(media_type); - } - tokens + usize::from(result.is_error) - } - - fn estimate_image(&mut self, image: &ImageData) -> usize { - let mut tokens = - self.estimate_media_common(image.url.as_deref(), image.media_type.as_deref()); - if let Some(detail) = &image.detail { - tokens += estimate_text_tokens(detail); - } - tokens - + image - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len()).max(2000)) - } - - fn estimate_audio(&mut self, audio: &AudioData) -> usize { - let tokens = self.estimate_media_common(audio.url.as_deref(), audio.media_type.as_deref()); - tokens - + audio - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len())) - } - - fn estimate_document(&mut self, document: &DocumentData) -> usize { - let mut tokens = - self.estimate_media_common(document.url.as_deref(), document.media_type.as_deref()); - if let Some(file_name) = &document.file_name { - tokens += estimate_text_tokens(file_name); - } - tokens - + document - .data - .as_ref() - .map_or(2000, |data| estimate_byte_tokens(data.len())) - } - - fn estimate_media_common(&mut self, url: Option<&str>, media_type: Option<&str>) -> usize { - self.warn( - MEDIA_ESTIMATE_WARNING, - "Media content couldn't be precisely tokenized; total is approximate.", - ); - url.map_or(0, estimate_text_tokens) + media_type.map_or(0, estimate_text_tokens) - } - - fn warn(&mut self, code: &'static str, message: &'static str) { - if self.seen_codes.insert(code) { - self.warnings.push(Warning { - message: message.to_string(), - code: Some(code.to_string()), - }); - } - } -} - -fn estimate_tool(tool: &ToolDefinition) -> usize { - 8 + estimate_text_tokens(&tool.name) - + estimate_text_tokens(&tool.description) - + estimate_json_tokens(&tool.parameters) -} - -/// Approximate the token cost of a raw byte payload (4 bytes per token). -#[must_use] -pub fn estimate_byte_tokens(byte_len: usize) -> usize { - byte_len.div_ceil(4) -} - -trait RoleName { - fn role_name(&self) -> &'static str; -} - -impl RoleName for Message { - fn role_name(&self) -> &'static str { - match self.role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", - Role::Developer => "developer", - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - use crate::types::{ - DocumentData, ImageData, Request, ResponseFormat, ResponseFormatType, ToolDefinition, - }; - - fn request(messages: Vec) -> Request { - Request { - model: "model-a".to_string(), - messages, - provider: Some("test".to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: None, - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } - } - - fn warning_codes(count: &InputTokenCount) -> Vec<&str> { - count - .warnings - .iter() - .filter_map(|warning| warning.code.as_deref()) - .collect() - } - - #[test] - fn text_only_request_returns_positive_local_estimate() { - let count = estimate_input_tokens(&request(vec![Message::user("hello world")]), "test"); - - assert!(count.input_tokens > 0); - assert_eq!(count.method, InputTokenCountMethod::LocalEstimate); - assert!(warning_codes(&count).contains(&LOCAL_ESTIMATE_WARNING)); - } - - #[test] - fn adding_tool_increases_estimate() { - let mut with_tool = request(vec![Message::user("hello")]); - let without_tool = estimate_input_tokens(&with_tool, "test"); - - with_tool.tools = Some(vec![ToolDefinition::function( - "search", - "Search files", - json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]); - let with_tool = estimate_input_tokens(&with_tool, "test"); - - assert!(with_tool.input_tokens > without_tool.input_tokens); - } - - #[test] - fn adding_response_format_increases_estimate() { - let mut with_schema = request(vec![Message::user("hello")]); - let without_schema = estimate_input_tokens(&with_schema, "test"); - - with_schema.response_format = Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some( - json!({"type": "object", "properties": {"answer": {"type": "string"}}}), - ), - strict: true, - }); - let with_schema = estimate_input_tokens(&with_schema, "test"); - - assert!(with_schema.input_tokens > without_schema.input_tokens); - } - - #[test] - fn media_content_gets_media_warning_and_sized_estimate() { - let count = estimate_input_tokens( - &request(vec![Message { - role: Role::User, - content: vec![ - ContentPart::Image(ImageData { - url: Some("https://example.test/image.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: Some("high".to_string()), - }), - ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0; 4096]), - media_type: Some("application/pdf".to_string()), - file_name: Some("doc.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }]), - "test", - ); - - assert!(count.input_tokens >= 3000); - assert!(warning_codes(&count).contains(&MEDIA_ESTIMATE_WARNING)); - } - - #[test] - fn provider_options_produce_provider_options_warning() { - let mut req = request(vec![Message::user("hello")]); - req.provider_options = Some(json!({"gemini": {"cached_content": "cachedContents/1"}})); - - let count = estimate_input_tokens(&req, "test"); - - assert!(warning_codes(&count).contains(&PROVIDER_OPTIONS_ESTIMATE_WARNING)); - } - - #[test] - fn opaque_content_produces_opaque_warning() { - let count = estimate_input_tokens( - &request(vec![Message { - role: Role::Assistant, - content: vec![ContentPart::Other { - kind: "openai_reasoning".to_string(), - data: json!({"id": "rs_123", "summary": []}), - }], - name: None, - tool_call_id: None, - }]), - "test", - ); - - assert!(warning_codes(&count).contains(&OPAQUE_CONTEXT_ESTIMATE_WARNING)); - } - - #[test] - fn estimate_is_deterministic() { - let req = request(vec![Message::user("repeatable")]); - - assert_eq!( - estimate_input_tokens(&req, "test"), - estimate_input_tokens(&req, "test") - ); - } -} diff --git a/lib/components/fabro-llm/src/tools.rs b/lib/components/fabro-llm/src/tools.rs deleted file mode 100644 index 589488115..000000000 --- a/lib/components/fabro-llm/src/tools.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use tokio_util::sync::CancellationToken; -use tracing::{debug, warn}; - -use crate::types::{Message, ToolCall, ToolDefinition, ToolResult}; - -/// Context passed to tool execute handlers (Section 5.2). -#[derive(Clone)] -pub struct ToolContext { - pub tool_call_id: String, - pub messages: Vec, - pub abort_signal: Option, -} - -/// An execute handler for a tool. -pub type ExecuteHandler = Arc< - dyn Fn( - serde_json::Value, - ToolContext, - ) -> Pin> + Send>> - + Send - + Sync, ->; - -/// A tool with an optional execute handler (Section 5.1, 5.5). -/// "Active" tools have an execute handler and are automatically executed. -/// "Passive" tools have no handler and are returned to the caller. -pub struct Tool { - pub definition: ToolDefinition, - pub execute: Option, -} - -impl Tool { - /// Create a passive tool (no execute handler). - /// - /// # Panics - /// - /// Panics if the tool name is invalid (see [`validate_tool_name`]). - /// Tool names are always hardcoded string literals in this codebase; this - /// guards against programming errors where a constant would fail - /// validation. - #[must_use] - pub fn passive(name: &str, description: &str, parameters: serde_json::Value) -> Self { - if let Err(e) = validate_tool_name(name) { - panic!( - "tool name `{name}` must be a valid identifier ([a-zA-Z][a-zA-Z0-9_]*, ≤64 chars): {e}" - ); - } - Self { - definition: ToolDefinition { - name: name.to_string(), - description: description.to_string(), - parameters, - }, - execute: None, - } - } - - /// Create an active tool with an execute handler. - /// - /// # Panics - /// - /// Panics if the tool name is invalid (see [`validate_tool_name`]). - /// Tool names are always hardcoded string literals in this codebase; this - /// guards against programming errors where a constant would fail - /// validation. - pub fn active( - name: &str, - description: &str, - parameters: serde_json::Value, - handler: F, - ) -> Self - where - F: Fn(serde_json::Value, ToolContext) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, - { - if let Err(e) = validate_tool_name(name) { - panic!( - "tool name `{name}` must be a valid identifier ([a-zA-Z][a-zA-Z0-9_]*, ≤64 chars): {e}" - ); - } - Self { - definition: ToolDefinition { - name: name.to_string(), - description: description.to_string(), - parameters, - }, - execute: Some(Arc::new(move |args, ctx| Box::pin(handler(args, ctx)))), - } - } - - #[must_use] - pub fn is_active(&self) -> bool { - self.execute.is_some() - } -} - -/// Validate a tool name: [a-zA-Z][a-zA-Z0-9_]* max 64 chars (Section 5.1). -/// -/// # Errors -/// -/// Returns a description of the validation failure if the name is empty, -/// too long, starts with a non-letter, or contains invalid characters. -pub fn validate_tool_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("Tool name cannot be empty".to_string()); - } - if name.len() > 64 { - return Err(format!("Tool name '{name}' exceeds 64 character limit")); - } - let mut chars = name.chars(); - if let Some(first) = chars.next() { - if !first.is_ascii_alphabetic() { - return Err(format!("Tool name '{name}' must start with a letter")); - } - } - for ch in chars { - if !ch.is_ascii_alphanumeric() && ch != '_' { - return Err(format!( - "Tool name '{name}' contains invalid character '{ch}'" - )); - } - } - Ok(()) -} - -/// A callback to repair invalid tool call arguments (Section 5.8). -/// Receives the tool call and the validation error message, returns repaired -/// arguments or an error if repair is not possible. -pub type RepairToolCallFn = Arc< - dyn Fn( - ToolCall, - String, - ) -> Pin> + Send>> - + Send - + Sync, ->; - -/// Validate tool call arguments against the tool's parameter schema. -/// Performs a lightweight structural check: verifies that when the schema -/// specifies `"type": "object"`, the arguments are a JSON object, and that -/// required properties are present. -fn validate_tool_args(args: &serde_json::Value, schema: &serde_json::Value) -> Result<(), String> { - let schema_type = schema.get("type").and_then(serde_json::Value::as_str); - if schema_type == Some("object") && !args.is_object() { - return Err(format!( - "Expected object arguments, got {}", - args_type_name(args) - )); - } - if let (Some(obj), Some(required)) = ( - args.as_object(), - schema.get("required").and_then(serde_json::Value::as_array), - ) { - let missing: Vec<&str> = required - .iter() - .filter_map(serde_json::Value::as_str) - .filter(|key| !obj.contains_key(*key)) - .collect(); - if !missing.is_empty() { - return Err(format!( - "Missing required properties: {}", - missing.join(", ") - )); - } - } - Ok(()) -} - -const fn args_type_name(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "boolean", - serde_json::Value::Number(_) => "number", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", - } -} - -/// Execute all tool calls with optional schema validation and repair (Section -/// 5.8). -/// -/// Before calling a tool's execute handler, validates the arguments against the -/// tool's parameter schema. If validation fails and a `repair` callback is -/// provided, calls it to attempt repair. If repair succeeds, uses the repaired -/// arguments. If repair fails or is not configured, returns an error -/// `ToolResult`. -pub async fn execute_all_tools_with_repair( - tools: &[&Tool], - tool_calls: &[ToolCall], - messages: &[Message], - abort_signal: Option<&CancellationToken>, - repair: Option<&RepairToolCallFn>, -) -> Vec { - use futures::future::join_all; - - let futures: Vec<_> = tool_calls - .iter() - .map(|call| { - let tool = tools.iter().find(|t| t.definition.name == call.name).copied(); - let call_id = call.id.clone(); - let call_name = call.name.clone(); - let args = call.arguments.clone(); - let call_clone = call.clone(); - let ctx = ToolContext { - tool_call_id: call_id.clone(), - messages: messages.to_vec(), - abort_signal: abort_signal.cloned(), - }; - - async move { - let Some(t) = tool else { - return ToolResult::error(call_id, format!("Unknown tool: {call_name}")); - }; - - let Some(handler) = &t.execute else { - return ToolResult::error(call_id, format!("Unknown tool: {call_name}")); - }; - - let validated_args = if call_clone.tool_type == "custom" { - args - } else { - match validate_tool_args(&args, &t.definition.parameters) { - Ok(()) => args, - Err(validation_error) => { - debug!(tool = %call_name, "Tool call validation failed"); - if let Some(repair_fn) = repair { - match repair_fn(call_clone, validation_error).await { - Ok(repaired) => repaired, - Err(repair_error) => { - warn!(tool = %call_name, "Tool call repair failed"); - return ToolResult::error( - call_id, - format!("Tool call validation failed and repair failed: {repair_error}"), - ); - } - } - } else { - return ToolResult::error( - call_id, - format!("Tool call validation failed: {validation_error}"), - ); - } - } - } - }; - - match handler(validated_args, ctx).await { - Ok(result) => ToolResult::success(call_id, result), - Err(err_msg) => { - warn!(tool = %call_name, "Tool execution returned error"); - ToolResult::error(call_id, err_msg) - } - } - } - }) - .collect(); - - join_all(futures).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_tool_name_valid() { - assert!(validate_tool_name("get_weather").is_ok()); - assert!(validate_tool_name("a").is_ok()); - assert!(validate_tool_name("myTool123").is_ok()); - assert!(validate_tool_name("A_B_C").is_ok()); - } - - #[test] - fn validate_tool_name_empty() { - assert!(validate_tool_name("").is_err()); - } - - #[test] - fn validate_tool_name_starts_with_number() { - assert!(validate_tool_name("1tool").is_err()); - } - - #[test] - fn validate_tool_name_starts_with_underscore() { - assert!(validate_tool_name("_tool").is_err()); - } - - #[test] - fn validate_tool_name_contains_dash() { - assert!(validate_tool_name("my-tool").is_err()); - } - - #[test] - fn validate_tool_name_too_long() { - let name = "a".repeat(65); - assert!(validate_tool_name(&name).is_err()); - } - - #[test] - fn validate_tool_name_max_length_ok() { - let name = "a".repeat(64); - assert!(validate_tool_name(&name).is_ok()); - } - - #[test] - fn passive_tool_is_not_active() { - let tool = Tool::passive( - "test", - "test tool", - serde_json::json!({"type": "object", "properties": {}}), - ); - assert!(!tool.is_active()); - } - - #[test] - fn active_tool_is_active() { - let tool = Tool::active( - "test", - "test tool", - serde_json::json!({"type": "object", "properties": {}}), - |_args, _ctx| async { Ok(serde_json::json!("result")) }, - ); - assert!(tool.is_active()); - } - - #[test] - #[should_panic(expected = "must be a valid identifier")] - fn passive_tool_panics_on_invalid_name() { - let _ = Tool::passive( - "1invalid", - "bad name", - serde_json::json!({"type": "object"}), - ); - } - - #[test] - #[should_panic(expected = "must be a valid identifier")] - fn active_tool_panics_on_invalid_name() { - Tool::active( - "my-tool", - "bad name", - serde_json::json!({"type": "object"}), - |_args, _ctx| async { Ok(serde_json::json!("result")) }, - ); - } - - #[test] - fn validate_tool_args_valid_object() { - let schema = - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}}); - let args = serde_json::json!({"name": "Alice"}); - assert!(validate_tool_args(&args, &schema).is_ok()); - } - - #[test] - fn validate_tool_args_non_object_when_object_expected() { - let schema = serde_json::json!({"type": "object", "properties": {}}); - let args = serde_json::json!("not an object"); - let result = validate_tool_args(&args, &schema); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Expected object")); - } - - #[test] - fn validate_tool_args_missing_required_properties() { - let schema = serde_json::json!({ - "type": "object", - "properties": {"name": {"type": "string"}, "age": {"type": "number"}}, - "required": ["name", "age"] - }); - let args = serde_json::json!({"name": "Alice"}); - let result = validate_tool_args(&args, &schema); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("age")); - } - - #[test] - fn validate_tool_args_no_schema_type_passes() { - let schema = serde_json::json!({}); - let args = serde_json::json!("anything"); - assert!(validate_tool_args(&args, &schema).is_ok()); - } - - #[tokio::test] - async fn execute_with_repair_valid_args_no_repair_needed() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new( - "call_1", - "greet", - serde_json::json!({"name": "Alice"}), - )]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let results = execute_all_tools_with_repair(&tool_refs, &calls, &[], None, None).await; - assert_eq!(results.len(), 1); - assert!(!results[0].is_error); - assert_eq!(results[0].content, serde_json::json!("Hello, Alice!")); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_no_repair_fn() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let results = execute_all_tools_with_repair(&tool_refs, &calls, &[], None, None).await; - assert_eq!(results.len(), 1); - assert!(results[0].is_error); - assert!( - results[0] - .content - .as_str() - .unwrap() - .contains("validation failed") - ); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_repair_succeeds() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let repair: RepairToolCallFn = Arc::new(|_call, _error| { - Box::pin(async { Ok(serde_json::json!({"name": "Repaired"})) }) - }); - let results = - execute_all_tools_with_repair(&tool_refs, &calls, &[], None, Some(&repair)).await; - assert_eq!(results.len(), 1); - assert!(!results[0].is_error); - assert_eq!(results[0].content, serde_json::json!("Hello, Repaired!")); - } - - #[tokio::test] - async fn execute_with_repair_invalid_args_repair_fails() { - let tools = [Tool::active( - "greet", - "Greet someone", - serde_json::json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}), - |args, _ctx| async move { - let name = args["name"].as_str().unwrap_or("world"); - Ok(serde_json::json!(format!("Hello, {}!", name))) - }, - )]; - let calls = vec![ToolCall::new("call_1", "greet", serde_json::json!({}))]; - let tool_refs: Vec<&Tool> = tools.iter().collect(); - - let repair: RepairToolCallFn = - Arc::new(|_call, _error| Box::pin(async { Err("cannot repair".to_string()) })); - let results = - execute_all_tools_with_repair(&tool_refs, &calls, &[], None, Some(&repair)).await; - assert_eq!(results.len(), 1); - assert!(results[0].is_error); - assert!( - results[0] - .content - .as_str() - .unwrap() - .contains("repair failed") - ); - } - - // --- args_type_name --- - - #[test] - fn args_type_name_null() { - assert_eq!(args_type_name(&serde_json::Value::Null), "null"); - } - - #[test] - fn args_type_name_bool() { - assert_eq!(args_type_name(&serde_json::json!(true)), "boolean"); - } - - #[test] - fn args_type_name_number() { - assert_eq!(args_type_name(&serde_json::json!(42)), "number"); - } - - #[test] - fn args_type_name_string() { - assert_eq!(args_type_name(&serde_json::json!("hello")), "string"); - } - - #[test] - fn args_type_name_array() { - assert_eq!(args_type_name(&serde_json::json!([1, 2])), "array"); - } - - #[test] - fn args_type_name_object() { - assert_eq!(args_type_name(&serde_json::json!({})), "object"); - } -} diff --git a/lib/components/fabro-llm/src/transport.rs b/lib/components/fabro-llm/src/transport.rs deleted file mode 100644 index 8fc62a32f..000000000 --- a/lib/components/fabro-llm/src/transport.rs +++ /dev/null @@ -1,610 +0,0 @@ -//! The HTTP transport shared by every provider adapter: how request bytes -//! travel, not what they say. -//! -//! A transport owns the HTTP client, timeouts, the streaming byte loop, and -//! SSE framing. It knows nothing about wire dialects — bodies, endpoints, and -//! error shapes arrive from (and return to) a [`Codec`]. Adapters shrink to -//! auth + route config composed over these helpers. -//! -//! The split mirrors `codec/mod.rs`: a codec knows *what the bytes say*; this -//! module knows *how they travel*. - -use std::borrow::Cow; -use std::collections::{HashMap, VecDeque}; -use std::time::Duration; - -use fabro_http::HeaderMap; -use futures::stream; -use tokio::time; -use tracing::warn; - -use crate::codec::{Codec, CodecCtx, RawEvent, StreamDecoder}; -use crate::error::Error; -use crate::provider::StreamEventStream; -use crate::types::{AdapterTimeout, RateLimitInfo, Response, StreamEvent}; - -// --- HTTP client + configuration -// ---------------------------------------------- - -/// Shared HTTP infrastructure for provider adapters. -/// -/// Holds the API key, base URL, reqwest client, default headers, and timeout -/// configuration that every provider needs. Provider-specific fields live on -/// the adapter struct itself. -pub(crate) struct HttpTransport { - pub(crate) api_key: Option, - pub(crate) base_url: String, - pub(crate) default_headers: HashMap, - pub(crate) client: fabro_http::HttpClient, - pub(crate) request_timeout: Option, - pub(crate) stream_read_timeout: Option, -} - -impl HttpTransport { - fn build_client(timeout: AdapterTimeout) -> fabro_http::HttpClient { - fabro_http::HttpClientBuilder::new() - .connect_timeout(Duration::from_secs_f64(timeout.connect)) - .build() - .expect("LLM HTTP client should build") - } - - #[must_use] - pub(crate) fn new_optional(api_key: Option, base_url: impl Into) -> Self { - let timeout = AdapterTimeout::default(); - let client = Self::build_client(timeout); - Self { - api_key, - base_url: base_url.into(), - default_headers: HashMap::new(), - client, - request_timeout: timeout.request.map(Duration::from_secs_f64), - stream_read_timeout: timeout.stream_read.map(Duration::from_secs_f64), - } - } - - #[must_use] - pub(crate) fn with_timeout(mut self, timeout: AdapterTimeout) -> Self { - self.client = Self::build_client(timeout); - self.request_timeout = timeout.request.map(Duration::from_secs_f64); - self.stream_read_timeout = timeout.stream_read.map(Duration::from_secs_f64); - self - } - - #[must_use] - pub(crate) fn with_default_headers(mut self, headers: HashMap) -> Self { - self.default_headers = headers; - self - } -} - -// --- Response header parsing -// --------------------------------------------------- - -/// Extract the `Retry-After` header value from an HTTP response as seconds. -#[must_use] -pub fn parse_retry_after(headers: &HeaderMap) -> Option { - headers - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) -} - -/// Parse `x-ratelimit-*` headers into a `RateLimitInfo`. -/// -/// Returns `None` if no rate limit headers are present. -#[must_use] -pub fn parse_rate_limit_headers(headers: &HeaderMap) -> Option { - fn header_i64(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - } - - fn header_str(headers: &HeaderMap, name: &str) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .map(String::from) - } - - let requests_remaining = header_i64(headers, "x-ratelimit-remaining-requests"); - let requests_limit = header_i64(headers, "x-ratelimit-limit-requests"); - let tokens_remaining = header_i64(headers, "x-ratelimit-remaining-tokens"); - let tokens_limit = header_i64(headers, "x-ratelimit-limit-tokens"); - let reset_at = header_str(headers, "x-ratelimit-reset-requests") - .or_else(|| header_str(headers, "x-ratelimit-reset-tokens")); - - if requests_remaining.is_none() - && requests_limit.is_none() - && tokens_remaining.is_none() - && tokens_limit.is_none() - && reset_at.is_none() - { - return None; - } - - Some(RateLimitInfo { - requests_remaining, - requests_limit, - tokens_remaining, - tokens_limit, - reset_at, - }) -} - -// --- Blocking requests -// ----------------------------------------------------------- - -/// Send a blocking request and decode the response through the codec: -/// `send_for_body` + rate-limit headers + [`Codec::decode_response`]. -pub(crate) async fn complete_via_http( - request: fabro_http::RequestBuilder, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, -) -> Result { - let (body, headers) = send_for_body(request, "provider_request", codec, ctx).await?; - let rate_limit = parse_rate_limit_headers(&headers); - codec.decode_response(&body, ctx, rate_limit) -} - -/// Send an HTTP request and read the response body plus headers, mapping -/// non-2xx responses through [`Codec::decode_error`]. `operation` tags the -/// warning logs (`provider_request`, `input_token_count`). -pub(crate) async fn send_for_body( - request: fabro_http::RequestBuilder, - operation: &str, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, -) -> Result<(String, HeaderMap), Error> { - let provider = ctx.provider_name; - let http_resp = request.send().await.map_err(|e| { - if e.is_timeout() { - warn!(provider = %provider, operation = %operation, error = %e, "Provider request timed out"); - Error::request_timeout(format!("{provider}: {e}"), e) - } else { - warn!(provider = %provider, operation = %operation, error = %e, "Provider network error"); - Error::network(e.to_string(), e) - } - })?; - - let status = http_resp.status(); - let retry_after = parse_retry_after(http_resp.headers()); - let headers = http_resp.headers().clone(); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - if !status.is_success() { - warn!(provider = %provider, operation = %operation, status = status.as_u16(), "Provider returned error"); - return Err(codec.decode_error(status.as_u16(), &body, ctx, retry_after)); - } - - Ok((body, headers)) -} - -// --- Streaming -// ------------------------------------------------------------------- - -/// How a route frames its SSE byte stream into decoder events. -#[derive(Clone, Copy, Debug)] -pub(crate) enum SseFraming { - /// `\n\n`-delimited blocks carrying `event:` + `data:` lines (anthropic, - /// openai responses). - EventBlocks, - /// Newline-delimited `data:` lines; comments, blank lines, and non-data - /// fields are skipped (openai_compatible, gemini). - DataLines, -} - -impl SseFraming { - fn delimiter(self) -> &'static str { - match self { - Self::EventBlocks => "\n\n", - Self::DataLines => "\n", - } - } -} - -/// Send a streaming request and decode its SSE byte stream through the -/// codec's [`StreamDecoder`]. A non-2xx response is mapped through -/// [`Codec::decode_error`] before any bytes flow. -pub(crate) async fn stream_via_http( - request: fabro_http::RequestBuilder, - codec: &dyn Codec, - ctx: &CodecCtx<'_>, - framing: SseFraming, - stream_read_timeout: Option, -) -> Result { - let http_resp = request - .send() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - - let status = http_resp.status(); - if !status.is_success() { - let retry_after = parse_retry_after(http_resp.headers()); - let body = http_resp - .text() - .await - .map_err(|e| Error::network(e.to_string(), e))?; - return Err(codec.decode_error(status.as_u16(), &body, ctx, retry_after)); - } - - let rate_limit = parse_rate_limit_headers(http_resp.headers()); - let decoder = codec.stream_decoder(ctx, rate_limit); - Ok(decode_sse_stream( - http_resp, - decoder, - framing, - stream_read_timeout, - )) -} - -/// State driving the streaming byte loop: the codec's decoder plus the line -/// reader, with a buffer that flattens batched events into individual items. -struct StreamLoop { - decoder: Box, - line_reader: LineReader, - /// Events or decoder errors not yet yielded. - pending: VecDeque>, - /// Byte stream exhausted. - done: bool, - /// `finish()` already drained. - finished_emitted: bool, - /// [`StreamEvent::StreamStart`] already emitted for this stream. - stream_started: bool, -} - -/// Drive `decoder` over the SSE byte stream of `response`: frame each chunk, -/// feed it to the decoder, flatten batched events, and drain -/// [`StreamDecoder::finish`] at byte-stream end. -fn decode_sse_stream( - response: fabro_http::Response, - decoder: Box, - framing: SseFraming, - stream_read_timeout: Option, -) -> StreamEventStream { - let out = stream::unfold( - StreamLoop { - decoder, - line_reader: LineReader::new(response, stream_read_timeout), - pending: VecDeque::new(), - done: false, - finished_emitted: false, - stream_started: false, - }, - move |mut state| async move { - loop { - if let Some(event) = state.pending.pop_front() { - return Some((event, state)); - } - - if state.done { - if state.finished_emitted { - return None; - } - state.finished_emitted = true; - state - .pending - .extend(state.decoder.finish().into_iter().map(Ok)); - if state.pending.is_empty() { - return None; - } - continue; - } - - match state.line_reader.read_next_chunk(framing.delimiter()).await { - Ok(Some(chunk)) => { - let Some((event, data)) = frame_sse_chunk(framing, &chunk) else { - continue; - }; - // Provider-independent liveness edge: the first framed - // event proves the provider is responding, whatever it - // turns out to contain. Owned here rather than in each - // decoder so it cannot depend on a provider sending a - // particular opening frame. - if !state.stream_started { - state.stream_started = true; - state.pending.push_back(Ok(StreamEvent::StreamStart)); - } - match state.decoder.on_event(RawEvent { event, data: &data }) { - Ok(events) => state.pending.extend(events.into_iter().map(Ok)), - Err(error) => state.pending.push_back(Err(error)), - } - } - Ok(None) => state.done = true, - Err(e) => return Some((Err(e), state)), - } - } - }, - ); - Box::pin(out) -} - -/// Frame one delimiter-separated chunk into an SSE `(event, data)` pair. -/// Returns `None` for chunks with no payload to decode: heartbeat comments, -/// blank lines, non-data fields, and empty `data:` payloads. -fn frame_sse_chunk(framing: SseFraming, chunk: &str) -> Option<(Option<&str>, Cow<'_, str>)> { - match framing { - SseFraming::EventBlocks => parse_sse_block(chunk), - SseFraming::DataLines => { - let data = chunk.trim().strip_prefix("data:")?.trim(); - if data.is_empty() { - return None; - } - Some((None, Cow::Borrowed(data))) - } - } -} - -/// Parse an SSE event block (lines within a `\n\n`-delimited chunk) into -/// `(event_type, data)`. Multi-line `data:` payloads are joined with `\n`; -/// the common single-line case borrows from the block. Returns `None` for -/// blocks with no non-empty payload (e.g. heartbeat comments). -pub(crate) fn parse_sse_block(block: &str) -> Option<(Option<&str>, Cow<'_, str>)> { - let mut event: Option<&str> = None; - let mut data: Option> = None; - - for line in block.lines() { - if let Some(rest) = line.strip_prefix("event:") { - event = Some(rest.trim()); - } else if let Some(rest) = line.strip_prefix("data:") { - let rest = rest.trim(); - data = Some(match data { - None => Cow::Borrowed(rest), - Some(prev) => { - let mut joined = prev.into_owned(); - joined.push('\n'); - joined.push_str(rest); - Cow::Owned(joined) - } - }); - } - } - - let data = data?; - if data.is_empty() { - return None; - } - Some((event, data)) -} - -// --- Byte-stream reading ----------------------------------------------------- - -/// Shared line reader for SSE streams. -/// -/// Buffers bytes from a `fabro_http::Response` and splits them by a -/// configurable delimiter (e.g. `"\n"` for Gemini/OpenAI-compatible, `"\n\n"` -/// for Anthropic/OpenAI SSE event blocks). -pub struct LineReader { - response: fabro_http::Response, - buffer: String, - stream_read_timeout: Option, -} - -impl LineReader { - pub fn new(response: fabro_http::Response, stream_read_timeout: Option) -> Self { - Self { - response, - buffer: String::new(), - stream_read_timeout, - } - } - - /// Read the next complete segment delimited by `delimiter`. - /// - /// Returns `Ok(Some(segment))` for each complete segment, `Ok(None)` when - /// the stream is exhausted, or `Err` on I/O or timeout errors. When the - /// stream ends with data remaining in the buffer, the leftover is returned - /// as a final segment. - pub async fn read_next_chunk(&mut self, delimiter: &str) -> Result, Error> { - loop { - if let Some(pos) = self.buffer.find(delimiter) { - let segment = self.buffer[..pos].to_string(); - self.buffer = self.buffer[pos + delimiter.len()..].to_string(); - return Ok(Some(segment)); - } - - let chunk_result = match self.stream_read_timeout { - Some(timeout) => time::timeout(timeout, self.response.chunk()).await, - None => Ok(self.response.chunk().await), - }; - match chunk_result { - Ok(Ok(Some(bytes))) => { - let text = String::from_utf8_lossy(&bytes); - self.buffer.push_str(&text); - } - Ok(Ok(None)) => { - if self.buffer.is_empty() { - return Ok(None); - } - let remaining = std::mem::take(&mut self.buffer); - return Ok(Some(remaining)); - } - Ok(Err(e)) => { - return Err(Error::stream_error(e.to_string(), e)); - } - Err(_) => { - warn!("Stream read timed out waiting for next event"); - return Err(Error::Stream { - message: "stream read timed out waiting for next event".to_string(), - source: None, - }); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_rate_limit_headers_all_present() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-remaining-requests", "99".parse().unwrap()); - headers.insert("x-ratelimit-limit-requests", "100".parse().unwrap()); - headers.insert("x-ratelimit-remaining-tokens", "9000".parse().unwrap()); - headers.insert("x-ratelimit-limit-tokens", "10000".parse().unwrap()); - headers.insert( - "x-ratelimit-reset-requests", - "2024-01-01T00:00:00Z".parse().unwrap(), - ); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, Some(99)); - assert_eq!(info.requests_limit, Some(100)); - assert_eq!(info.tokens_remaining, Some(9000)); - assert_eq!(info.tokens_limit, Some(10000)); - assert_eq!(info.reset_at.as_deref(), Some("2024-01-01T00:00:00Z")); - } - - #[test] - fn parse_rate_limit_headers_none_present() { - let headers = HeaderMap::new(); - assert!(parse_rate_limit_headers(&headers).is_none()); - } - - #[test] - fn parse_rate_limit_headers_partial() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-remaining-requests", "50".parse().unwrap()); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, Some(50)); - assert_eq!(info.requests_limit, None); - assert_eq!(info.tokens_remaining, None); - assert_eq!(info.tokens_limit, None); - assert_eq!(info.reset_at, None); - } - - #[test] - fn parse_rate_limit_headers_reset_tokens_fallback() { - let mut headers = HeaderMap::new(); - headers.insert("x-ratelimit-limit-tokens", "5000".parse().unwrap()); - headers.insert( - "x-ratelimit-reset-tokens", - "2024-06-01T12:00:00Z".parse().unwrap(), - ); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.tokens_limit, Some(5000)); - assert_eq!(info.reset_at.as_deref(), Some("2024-06-01T12:00:00Z")); - } - - #[test] - fn parse_rate_limit_headers_invalid_values_ignored() { - let mut headers = HeaderMap::new(); - headers.insert( - "x-ratelimit-remaining-requests", - "not-a-number".parse().unwrap(), - ); - headers.insert("x-ratelimit-limit-tokens", "10000".parse().unwrap()); - - let info = parse_rate_limit_headers(&headers).unwrap(); - assert_eq!(info.requests_remaining, None); - assert_eq!(info.tokens_limit, Some(10000)); - } - - // --- parse_retry_after --- - - #[test] - fn parse_retry_after_valid() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "2.5".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), Some(2.5)); - } - - #[test] - fn parse_retry_after_missing() { - let headers = HeaderMap::new(); - assert_eq!(parse_retry_after(&headers), None); - } - - #[test] - fn parse_retry_after_invalid() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "not-a-number".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), None); - } - - #[test] - fn parse_retry_after_integer() { - let mut headers = HeaderMap::new(); - headers.insert("retry-after", "5".parse().unwrap()); - assert_eq!(parse_retry_after(&headers), Some(5.0)); - } - - // --- frame_sse_chunk: event blocks --- - - #[test] - fn parse_sse_block_event_and_data() { - let block = "event: message_start\ndata: {\"a\":1}"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("message_start")); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn parse_sse_block_data_without_event() { - let block = "data: {\"a\":1}"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, None); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn parse_sse_block_joins_multiple_data_lines() { - let block = "event: e\ndata: line1\ndata: line2"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("e")); - assert_eq!(data, "line1\nline2"); - } - - #[test] - fn parse_sse_block_skips_comment_only_block() { - assert!(parse_sse_block(": heartbeat").is_none()); - assert!(parse_sse_block("event: ping").is_none()); - assert!(parse_sse_block("").is_none()); - } - - #[test] - fn parse_sse_block_skips_empty_data_payload() { - assert!(parse_sse_block("data:").is_none()); - assert!(parse_sse_block("event: e\ndata: ").is_none()); - } - - #[test] - fn parse_sse_block_trims_crlf() { - let block = "event: e\r\ndata: {\"a\":1}\r"; - let (event, data) = parse_sse_block(block).unwrap(); - assert_eq!(event, Some("e")); - assert_eq!(data, "{\"a\":1}"); - } - - // --- frame_sse_chunk: data lines --- - - #[test] - fn data_lines_strips_prefix_and_trims() { - let (event, data) = frame_sse_chunk(SseFraming::DataLines, "data: {\"a\":1}\r").unwrap(); - assert_eq!(event, None); - assert_eq!(data, "{\"a\":1}"); - } - - #[test] - fn data_lines_passes_done_sentinel() { - let (_, data) = frame_sse_chunk(SseFraming::DataLines, "data: [DONE]").unwrap(); - assert_eq!(data, "[DONE]"); - } - - #[test] - fn data_lines_skips_comments_blanks_and_other_fields() { - assert!(frame_sse_chunk(SseFraming::DataLines, ": keep-alive").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "event: x").is_none()); - assert!(frame_sse_chunk(SseFraming::DataLines, "data:").is_none()); - } -} diff --git a/lib/components/fabro-llm/src/types.rs b/lib/components/fabro-llm/src/types.rs deleted file mode 100644 index 9c41263fd..000000000 --- a/lib/components/fabro-llm/src/types.rs +++ /dev/null @@ -1,1041 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -// --- 3.1 / 3.2 / 3.5 Canonical chat + content data structures --- -// -// `Message`, `Role`, `ContentPart`, `ImageData`, `AudioData`, -// `DocumentData`, `ThinkingData`, `ToolCall`, and `ToolResult` are the -// canonical provider-neutral replay primitives. They live in `fabro-types` -// so the event stream, API responses, and runtime history can share one -// model. They are re-exported here so existing `fabro_llm::types::*` -// imports keep working. -pub use fabro_types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, ReasoningOutput, Role, ThinkingData, - ToolCall, ToolResult, -}; -use fabro_util::backoff::BackoffPolicy; -use serde::{Deserialize, Serialize}; - -use crate::error::Error; -use crate::reasoning; - -// --- 3.8 FinishReason --- - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum FinishReason { - Stop, - Length, - ToolCalls, - ContentFilter, - Error, - Other(String), -} - -impl FinishReason { - #[must_use] - pub const fn as_str(&self) -> &str { - match self { - Self::Stop => "stop", - Self::Length => "length", - Self::ToolCalls => "tool_calls", - Self::ContentFilter => "content_filter", - Self::Error => "error", - Self::Other(s) => s.as_str(), - } - } -} - -impl Serialize for FinishReason { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for FinishReason { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Ok(match s.as_str() { - "stop" => Self::Stop, - "length" => Self::Length, - "tool_calls" => Self::ToolCalls, - "content_filter" => Self::ContentFilter, - "error" => Self::Error, - _ => Self::Other(s), - }) - } -} - -// --- 3.9 TokenCounts --- - -pub use fabro_model::{Speed, TokenCounts}; - -// --- 3.10 ResponseFormat --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResponseFormatType { - Text, - #[serde(rename = "json")] - JsonObject, - JsonSchema, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResponseFormat { - #[serde(rename = "type")] - pub kind: ResponseFormatType, - pub json_schema: Option, - #[serde(default)] - pub strict: bool, -} - -// --- 3.11 Warning --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Warning { - pub message: String, - pub code: Option, -} - -// --- 3.12 RateLimitInfo --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RateLimitInfo { - pub requests_remaining: Option, - pub requests_limit: Option, - pub tokens_remaining: Option, - pub tokens_limit: Option, - pub reset_at: Option, -} - -// --- 3.8 ReasoningEffort --- -// -// Re-exported from `fabro-model` so catalog data, request validation, OpenAPI -// replacement types, and the LLM client share one enum. -pub use fabro_model::ReasoningEffort; - -// --- 3.6 Request --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Request { - pub model: String, - pub messages: Vec, - pub provider: Option, - pub tools: Option>, - pub tool_choice: Option, - pub response_format: Option, - pub temperature: Option, - pub top_p: Option, - pub max_tokens: Option, - pub stop_sequences: Option>, - pub reasoning_effort: Option, - pub speed: Option, - pub metadata: Option>, - pub provider_options: Option, -} - -// --- 5.1 ToolDefinition --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -const CUSTOM_TOOL_TYPE_KEY: &str = "x-fabro-tool-type"; -const CUSTOM_TOOL_FORMAT_KEY: &str = "x-fabro-custom-tool-format"; - -impl ToolDefinition { - #[must_use] - pub fn function( - name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - Self { - name: name.into(), - description: description.into(), - parameters, - } - } - - #[must_use] - pub fn custom( - name: impl Into, - description: impl Into, - format: impl Into, - ) -> Self { - Self { - name: name.into(), - description: description.into(), - parameters: serde_json::json!({ - CUSTOM_TOOL_TYPE_KEY: "custom", - CUSTOM_TOOL_FORMAT_KEY: format.into(), - }), - } - } - - #[must_use] - pub fn is_custom(&self) -> bool { - self.parameters - .get(CUSTOM_TOOL_TYPE_KEY) - .and_then(serde_json::Value::as_str) - == Some("custom") - } - - #[must_use] - pub fn custom_format(&self) -> Option<&serde_json::Value> { - self.parameters.get(CUSTOM_TOOL_FORMAT_KEY) - } -} - -// --- 5.3 ToolChoice --- - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "mode", rename_all = "snake_case")] -pub enum ToolChoice { - Auto, - None, - Required, - Named { tool_name: String }, -} - -impl ToolChoice { - pub fn named(name: impl Into) -> Self { - Self::Named { - tool_name: name.into(), - } - } - - /// Return the mode string used by `ProviderAdapter::supports_tool_choice`. - #[must_use] - pub const fn mode_str(&self) -> &'static str { - match self { - Self::Auto => "auto", - Self::None => "none", - Self::Required => "required", - Self::Named { .. } => "named", - } - } -} - -// --- 3.7 Response --- - -// Billing vocabulary shared with the catalog/billing layer and the API -// surface; re-exported here so `fabro_llm::types::*` imports keep working. -pub use fabro_model::CostSource; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Response { - pub id: String, - pub model: String, - pub provider: String, - pub message: Message, - pub finish_reason: FinishReason, - pub usage: TokenCounts, - pub raw: Option, - pub warnings: Vec, - pub rate_limit: Option, - /// USD cost of this completion, when known or estimable. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_usd: Option, - /// Whether `cost_usd` came from provider billing data or a catalog - /// estimate. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost_source: Option, -} - -impl Response { - #[must_use] - pub fn text(&self) -> String { - self.message.text() - } - - #[must_use] - pub fn tool_calls(&self) -> Vec { - self.message - .content - .iter() - .filter_map(|part| match part { - ContentPart::ToolCall(tc) => Some(tc.clone()), - _ => None, - }) - .collect() - } - - #[must_use] - pub fn reasoning(&self) -> Option { - let reasoning: String = self - .message - .content - .iter() - .filter_map(|part| match part { - ContentPart::Thinking(t) => Some(t.text.as_str()), - _ => None, - }) - .collect(); - - if reasoning.is_empty() { - None - } else { - Some(reasoning) - } - } - - /// Readable reasoning normalized from this response's canonical message - /// content, or `None` when the provider returned none. - /// - /// The message is the single source of truth: opaque provider reasoning - /// items are already preserved there, so normalization needs no second - /// stored field and cannot drift from what will be replayed. Deriving it - /// from the final response also keeps retried or replaced streaming - /// buffers out of the durable result. - #[must_use] - pub fn reasoning_output(&self) -> Option { - reasoning::normalize(&self.message.content) - } -} - -// --- 3.13 StreamEvent --- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum StreamEvent { - StreamStart, - TextStart { - text_id: Option, - }, - TextDelta { - delta: String, - text_id: Option, - }, - TextEnd { - text_id: Option, - }, - ReasoningStart, - ReasoningDelta { - delta: String, - }, - ReasoningEnd, - ToolCallStart { - tool_call: ToolCall, - }, - ToolCallDelta { - tool_call: ToolCall, - }, - ToolCallEnd { - tool_call: ToolCall, - }, - StepFinish { - finish_reason: FinishReason, - usage: TokenCounts, - response: Box, - tool_calls: Vec, - tool_results: Vec, - }, - Finish { - finish_reason: FinishReason, - usage: TokenCounts, - response: Box, - }, - Error { - error: Error, - raw: Option, - }, -} - -impl StreamEvent { - pub fn text_delta(delta: impl Into, text_id: Option) -> Self { - Self::TextDelta { - delta: delta.into(), - text_id, - } - } - - #[must_use] - pub fn step_finish( - reason: FinishReason, - usage: TokenCounts, - response: Response, - tool_calls: Vec, - tool_results: Vec, - ) -> Self { - Self::StepFinish { - finish_reason: reason, - usage, - response: Box::new(response), - tool_calls, - tool_results, - } - } - - #[must_use] - pub fn finish(reason: FinishReason, usage: TokenCounts, response: Response) -> Self { - Self::Finish { - finish_reason: reason, - usage, - response: Box::new(response), - } - } - - #[must_use] - pub const fn error(error: Error) -> Self { - Self::Error { error, raw: None } - } -} - -// --- 2.9 Model (re-exported from fabro-model) --- - -pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; - -// --- 4.7 Timeouts --- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TimeoutOptions { - pub total: Option, - pub per_step: Option, -} - -impl From for TimeoutOptions { - fn from(total: f64) -> Self { - Self { - total: Some(total), - per_step: None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct AdapterTimeout { - pub connect: f64, - pub request: Option, - pub stream_read: Option, -} - -impl Default for AdapterTimeout { - fn default() -> Self { - Self { - connect: 30.0, - request: None, - stream_read: Some(300.0), - } - } -} - -// --- 6.6 RetryPolicy --- - -/// Callback invoked before each retry attempt with (error, attempt, delay as -/// Duration). -pub type OnRetryCallback = Arc; - -#[derive(Clone)] -pub struct RetryPolicy { - pub max_retries: u32, - pub backoff: BackoffPolicy, - /// Called before each retry with (error, attempt number, delay). - pub on_retry: Option, -} - -impl std::fmt::Debug for RetryPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RetryPolicy") - .field("max_retries", &self.max_retries) - .field("backoff", &self.backoff) - .field("on_retry", &self.on_retry.as_ref().map(|_| "...")) - .finish() - } -} - -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_retries: 2, - backoff: BackoffPolicy { - initial_delay: std::time::Duration::from_secs(1), - factor: 2.0, - max_delay: std::time::Duration::from_mins(1), - jitter: true, - }, - on_retry: None, - } - } -} - -// --- 4.6 ObjectStreamEvent --- - -/// Events yielded by `stream_object()` for streaming structured output. -#[derive(Debug, Clone)] -pub enum ObjectStreamEvent { - /// A new partial parse of the accumulated JSON text. - Partial { object: serde_json::Value }, - /// A raw stream event from the underlying provider stream. - Delta { event: StreamEvent }, - /// The stream completed with a fully parsed object and response. - Complete { - object: serde_json::Value, - response: Box, - }, -} - -// --- 4.3 GenerateResult / StepResult --- - -#[derive(Debug, Clone)] -pub struct GenerateResult { - pub response: Response, - pub tool_results: Vec, - pub total_usage: TokenCounts, - pub steps: Vec, - pub output: Option, -} - -impl std::ops::Deref for GenerateResult { - type Target = Response; - fn deref(&self) -> &Response { - &self.response - } -} - -#[derive(Debug, Clone)] -pub struct StepResult { - pub response: Response, - pub tool_results: Vec, -} - -impl std::ops::Deref for StepResult { - type Target = Response; - fn deref(&self) -> &Response { - &self.response - } -} - -#[cfg(test)] -mod tests { - use fabro_util::backoff::BackoffPolicy; - - use super::*; - - #[test] - fn message_system_constructor() { - let msg = Message::system("You are helpful."); - assert_eq!(msg.role, Role::System); - assert_eq!(msg.text(), "You are helpful."); - } - - #[test] - fn message_user_constructor() { - let msg = Message::user("Hello"); - assert_eq!(msg.role, Role::User); - assert_eq!(msg.text(), "Hello"); - } - - #[test] - fn message_assistant_constructor() { - let msg = Message::assistant("Hi there"); - assert_eq!(msg.role, Role::Assistant); - assert_eq!(msg.text(), "Hi there"); - } - - #[test] - fn message_tool_result_constructor() { - let msg = Message::tool_result( - "call_123", - serde_json::Value::String("72F and sunny".into()), - false, - ); - assert_eq!(msg.role, Role::Tool); - assert_eq!(msg.tool_call_id, Some("call_123".to_string())); - match &msg.content[0] { - ContentPart::ToolResult(tr) => { - assert_eq!(tr.tool_call_id, "call_123"); - assert!(!tr.is_error); - } - other => panic!("Expected ToolResult, got {other:?}"), - } - } - - #[test] - fn message_text_concatenates_text_parts() { - let msg = Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Hello "), - ContentPart::ToolCall(ToolCall::new("c1", "test", serde_json::json!({}))), - ContentPart::text("world"), - ], - name: None, - tool_call_id: None, - }; - assert_eq!(msg.text(), "Hello world"); - } - - #[test] - fn message_text_returns_empty_for_no_text_parts() { - let msg = Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "c1", - "test", - serde_json::json!({}), - ))], - name: None, - tool_call_id: None, - }; - assert_eq!(msg.text(), ""); - } - - #[test] - fn finish_reason_variants() { - assert_eq!(FinishReason::Stop.as_str(), "stop"); - assert_eq!(FinishReason::Length.as_str(), "length"); - assert_eq!(FinishReason::ToolCalls.as_str(), "tool_calls"); - assert_eq!(FinishReason::ContentFilter.as_str(), "content_filter"); - assert_eq!(FinishReason::Error.as_str(), "error"); - assert_eq!( - FinishReason::Other("custom_reason".into()).as_str(), - "custom_reason" - ); - } - - #[test] - fn finish_reason_serde_roundtrip() { - let reasons = vec![ - FinishReason::Stop, - FinishReason::Length, - FinishReason::ToolCalls, - FinishReason::Other("custom".into()), - ]; - for reason in &reasons { - let json = serde_json::to_string(reason).unwrap(); - let deserialized: FinishReason = serde_json::from_str(&json).unwrap(); - assert_eq!(&deserialized, reason); - } - } - - #[test] - fn usage_serialization_skips_none_optional_fields() { - let usage = TokenCounts { - input_tokens: 100, - output_tokens: 50, - ..TokenCounts::default() - }; - insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" - { - "input_tokens": 100, - "output_tokens": 50, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - } - "#); - } - - #[test] - fn usage_serialization_includes_present_optional_fields() { - let usage = TokenCounts { - input_tokens: 100, - output_tokens: 30, - reasoning_tokens: 20, - cache_read_tokens: 80, - cache_write_tokens: 10, - }; - insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#" - { - "input_tokens": 100, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 10 - } - "#); - } - - #[test] - fn usage_deserialization_without_optional_fields() { - let json = r#"{"input_tokens":100,"output_tokens":50}"#; - let usage: TokenCounts = serde_json::from_str(json).unwrap(); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.cache_read_tokens, 0); - assert_eq!(usage.total_tokens(), 150); - } - - #[test] - fn usage_addition_both_filled() { - let a = TokenCounts { - input_tokens: 10, - output_tokens: 15, - reasoning_tokens: 5, - cache_read_tokens: 3, - cache_write_tokens: 1, - }; - let b = TokenCounts { - input_tokens: 15, - output_tokens: 15, - reasoning_tokens: 10, - cache_read_tokens: 7, - cache_write_tokens: 2, - }; - let sum = a + b; - assert_eq!(sum.input_tokens, 25); - assert_eq!(sum.output_tokens, 30); - assert_eq!(sum.total_tokens(), 83); - assert_eq!(sum.reasoning_tokens, 15); - assert_eq!(sum.cache_read_tokens, 10); - assert_eq!(sum.cache_write_tokens, 3); - } - - #[test] - fn usage_addition_one_none() { - let a = TokenCounts { - input_tokens: 10, - output_tokens: 15, - reasoning_tokens: 5, - ..TokenCounts::default() - }; - let b = TokenCounts { - input_tokens: 15, - output_tokens: 25, - cache_read_tokens: 7, - ..TokenCounts::default() - }; - let sum = a + b; - assert_eq!(sum.reasoning_tokens, 5); - assert_eq!(sum.cache_read_tokens, 7); - assert_eq!(sum.cache_write_tokens, 0); - } - - #[test] - fn tool_choice_variants() { - assert_eq!(ToolChoice::Auto, ToolChoice::Auto); - assert_eq!(ToolChoice::None, ToolChoice::None); - assert_eq!(ToolChoice::Required, ToolChoice::Required); - let named = ToolChoice::named("get_weather"); - assert_eq!(named, ToolChoice::Named { - tool_name: "get_weather".to_string(), - }); - } - - #[test] - fn response_text_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("Hello world"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.text(), "Hello world"); - } - - #[test] - fn response_tool_calls_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check"), - ContentPart::ToolCall(ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )), - ], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let calls = response.tool_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "get_weather"); - assert_eq!(calls[0].id, "call_1"); - } - - #[test] - fn response_reasoning_accessor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "Let me think...".into(), - signature: Some("sig_123".into()), - redacted: false, - }), - ContentPart::text("The answer is 42."), - ], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.reasoning(), Some("Let me think...".to_string())); - assert_eq!(response.text(), "The answer is 42."); - } - - #[test] - fn response_reasoning_returns_none_when_absent() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("Hello"), - finish_reason: FinishReason::Stop, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - assert_eq!(response.reasoning(), None); - } - - #[test] - fn stream_event_text_delta() { - let event = StreamEvent::text_delta("hello", Some("t1".into())); - match &event { - StreamEvent::TextDelta { delta, text_id } => { - assert_eq!(delta, "hello"); - assert_eq!(text_id, &Some("t1".to_string())); - } - other => panic!("Expected TextDelta, got {other:?}"), - } - } - - #[test] - fn stream_event_error() { - let event = StreamEvent::error(Error::Stream { - message: "something went wrong".into(), - source: None, - }); - match &event { - StreamEvent::Error { error, .. } => { - assert_eq!(error.to_string(), "Stream error: something went wrong"); - } - other => panic!("Expected Error, got {other:?}"), - } - } - - #[test] - fn retry_policy_delay_no_jitter() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: false, - }, - ..Default::default() - }; - // BackoffPolicy is 1-indexed: attempt 1 = base, attempt 2 = base*factor, etc. - assert_eq!(policy.backoff.delay_for_attempt(1), Duration::from_secs(1)); - assert_eq!(policy.backoff.delay_for_attempt(2), Duration::from_secs(2)); - assert_eq!(policy.backoff.delay_for_attempt(3), Duration::from_secs(4)); - assert_eq!(policy.backoff.delay_for_attempt(4), Duration::from_secs(8)); - } - - #[test] - fn retry_policy_delay_respects_max() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 10, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_secs(5), - jitter: false, - }, - ..Default::default() - }; - assert_eq!(policy.backoff.delay_for_attempt(6), Duration::from_secs(5)); - } - - #[test] - fn retry_policy_delay_with_jitter_in_range() { - use std::time::Duration; - let policy = RetryPolicy { - max_retries: 3, - backoff: BackoffPolicy { - initial_delay: Duration::from_secs(1), - factor: 2.0, - max_delay: Duration::from_mins(1), - jitter: true, - }, - ..Default::default() - }; - let delay = policy.backoff.delay_for_attempt(1); - // base * 0.5 to base * 1.5 => 0.5s to 1.5s - assert!(delay >= Duration::from_millis(500)); - assert!(delay <= Duration::from_millis(1500)); - } - - #[test] - fn adapter_timeout_defaults() { - let timeout = AdapterTimeout::default(); - assert!((timeout.connect - 30.0).abs() < f64::EPSILON); - assert!(timeout.request.is_none()); - assert!((timeout.stream_read.unwrap() - 300.0).abs() < f64::EPSILON); - } - - #[test] - fn content_part_text_constructor() { - let part = ContentPart::text("hello"); - assert_eq!(part, ContentPart::Text("hello".to_string())); - } - - #[test] - fn content_part_image_constructor() { - let part = ContentPart::Image(ImageData { - url: Some("https://example.com/img.png".into()), - data: None, - media_type: None, - detail: None, - }); - assert!(matches!(part, ContentPart::Image(_))); - } - - #[test] - fn tool_call_serde_roundtrip() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - let json = serde_json::to_string(&tc).unwrap(); - let deserialized: ToolCall = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, tc); - } - - #[test] - fn tool_result_with_image_data() { - let result = ToolResult { - tool_call_id: "call_1".into(), - content: serde_json::json!("screenshot taken"), - is_error: false, - image_data: Some(vec![0x89, 0x50, 0x4E, 0x47]), - image_media_type: Some("image/png".into()), - }; - assert!(result.image_data.is_some()); - assert_eq!(result.image_media_type.as_deref(), Some("image/png")); - } - - #[test] - fn tool_call_new_constructor() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - assert_eq!(tc.id, "c1"); - assert_eq!(tc.name, "test"); - assert_eq!(tc.tool_type, "function"); - assert_eq!(tc.raw_arguments, None); - } - - #[test] - fn tool_call_deserialize_without_type_defaults_to_function() { - let json = r#"{"id":"c1","name":"test","arguments":{}}"#; - let tc: ToolCall = serde_json::from_str(json).unwrap(); - assert_eq!(tc.tool_type, "function"); - } - - #[test] - fn tool_call_serializes_type_field() { - let tc = ToolCall::new("c1", "test", serde_json::json!({})); - let json = serde_json::to_value(&tc).unwrap(); - assert_eq!(json["type"], "function"); - } - - #[test] - fn stream_event_step_finish_constructor() { - let response = Response { - id: "resp_1".into(), - model: "test-model".into(), - provider: "test".into(), - message: Message::assistant("tool response"), - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts { - input_tokens: 10, - output_tokens: 5, - ..Default::default() - }, - raw: None, - warnings: vec![], - rate_limit: None, - cost_usd: None, - cost_source: None, - }; - let tool_calls = vec![ToolCall::new( - "call_1", - "get_weather", - serde_json::json!({"city": "SF"}), - )]; - let tool_results = vec![ToolResult::success("call_1", serde_json::json!("72F"))]; - - let event = StreamEvent::step_finish( - FinishReason::ToolCalls, - response.usage.clone(), - response, - tool_calls, - tool_results, - ); - - match &event { - StreamEvent::StepFinish { - finish_reason, - usage, - tool_calls, - tool_results, - .. - } => { - assert_eq!(*finish_reason, FinishReason::ToolCalls); - assert_eq!(usage.input_tokens, 10); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].name, "get_weather"); - assert_eq!(tool_results.len(), 1); - assert_eq!(tool_results[0].tool_call_id, "call_1"); - } - other => panic!("Expected StepFinish, got {other:?}"), - } - } - - #[test] - fn tool_choice_mode_str_auto() { - assert_eq!(ToolChoice::Auto.mode_str(), "auto"); - } - - #[test] - fn tool_choice_mode_str_none() { - assert_eq!(ToolChoice::None.mode_str(), "none"); - } - - #[test] - fn tool_choice_mode_str_required() { - assert_eq!(ToolChoice::Required.mode_str(), "required"); - } - - #[test] - fn tool_choice_mode_str_named() { - assert_eq!(ToolChoice::named("get_weather").mode_str(), "named"); - } -} diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs deleted file mode 100644 index c91cad136..000000000 --- a/lib/components/fabro-llm/tests/integration.rs +++ /dev/null @@ -1,814 +0,0 @@ -#![expect( - clippy::disallowed_methods, - reason = "Live provider integration tests read required API keys from process env." -)] - -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_auth::ApiCredential; -use fabro_llm::client::Client; -use fabro_llm::error::ProviderErrorKind; -use fabro_llm::model_test::{ModelTestStatus, run_model_test}; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::{ - AnthropicAdapter, BedrockAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter, -}; -use fabro_llm::types::{ - CostSource, FinishReason, Message, ReasoningEffort, Request, ToolChoice, ToolDefinition, -}; -use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{Catalog, ModelTestMode, ProviderId}; -use fabro_static::EnvVars; - -fn make_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Say hello in exactly one word")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: Some(0.0), - top_p: None, - max_tokens: Some(50), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } -} - -/// Build the built-in catalog with `provider` enabled, plus an operator base -/// URL for providers such as Modal that do not ship one. -fn enabled_provider_catalog(provider: &ProviderId, base_url: Option) -> Arc { - let mut settings = LlmCatalogSettings::default(); - settings - .providers - .insert(provider.to_string(), ProviderCatalogSettings { - enabled: Some(true), - base_url, - ..ProviderCatalogSettings::default() - }); - Arc::new( - Catalog::from_builtin_with_overrides(&settings) - .unwrap_or_else(|err| panic!("enabled {provider} catalog should build: {err}")), - ) -} - -/// Drive the shared deep tool round trip for one catalog offering. -async fn assert_deep_tool_round_trip( - catalog: &Arc, - provider: &ProviderId, - model_id: &str, - credential: ApiCredential, -) { - let client = Arc::new( - Client::from_credentials(vec![credential], Arc::clone(catalog)) - .await - .unwrap_or_else(|err| panic!("{provider} client should build from the catalog: {err}")), - ); - let model = catalog - .get_on_provider(provider, model_id) - .unwrap_or_else(|| panic!("{provider} {model_id} should be present")); - - let outcome = run_model_test(model, ModelTestMode::Deep, None, client).await; - assert_eq!( - outcome.status, - ModelTestStatus::Ok, - "{provider} {model_id} deep test failed: {:?}", - outcome.error_message - ); -} - -#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] -async fn anthropic_complete() { - let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set"); - let adapter = AnthropicAdapter::new(api_key); - let request = make_request("claude-haiku-4-5"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "anthropic"); -} - -#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))] -async fn openai_complete() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = Request { - temperature: None, - ..make_request("gpt-5.2") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(twin, live("OPENAI_API_KEY"))] -async fn openai_gpt_5_3_codex_complete() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = make_request("gpt-5.3-codex"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] -async fn openai_gpt_5_5_complete() { - let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - let request = Request { - temperature: None, - ..make_request("gpt-5.5") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("OPENAI_GPT_5_5_PRO_API_KEY"))] -async fn openai_gpt_5_5_pro_complete() { - let api_key = std::env::var("OPENAI_GPT_5_5_PRO_API_KEY") - .expect("OPENAI_GPT_5_5_PRO_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - let request = Request { - temperature: None, - ..make_request("gpt-5.5-pro") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openai"); -} - -#[fabro_macros::e2e_test(live("KIMI_API_KEY"))] -async fn kimi_k3_reasoning_tool_round_trip() { - let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1") - .with_name("moonshot") - .with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "kimi-k3".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::Low), - ..make_request("kimi-k3") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - assert!( - tool_response.reasoning().is_some(), - "K3 should return reasoning content before its tool call" - ); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("K3 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "kimi-k3".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::Low), - ..make_request("kimi-k3") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "K3 should incorporate the replayed tool result" - ); -} - -#[fabro_macros::e2e_test(twin)] -async fn openai_server_error() { - let (base_url, api_key) = fabro_test::e2e_openai!(); - let admin_url = base_url - .strip_suffix("/v1") - .expect("OpenAI base URL should end with /v1"); - - fabro_test::test_http_client() - .post(format!("{admin_url}/__admin/scenarios")) - .bearer_auth(&api_key) - .json(&serde_json::json!({ - "scenarios": [{ - "matcher": { "endpoint": "responses" }, - "script": { - "kind": "error", - "status": 500, - "message": "internal server error", - "error_type": "server_error", - "code": "server_error" - } - }] - })) - .send() - .await - .unwrap(); - - let adapter = OpenAiAdapter::new(api_key).with_base_url(base_url); - let request = make_request("gpt-4o-mini"); - let err = adapter.complete(&request).await.unwrap_err(); - - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::Server)); - assert_eq!(err.status_code(), Some(500)); -} - -#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] -async fn gemini_complete() { - let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set"); - let adapter = GeminiAdapter::new(api_key); - let request = make_request("gemini-2.5-flash"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert_eq!(response.finish_reason, FinishReason::Stop); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "gemini"); -} - -#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))] -async fn bedrock_complete_with_api_key() { - let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK) - .expect("AWS_BEARER_TOKEN_BEDROCK must be set"); - let adapter = - BedrockAdapter::new_api_key(token, "https://bedrock-runtime.us-east-1.amazonaws.com") - .unwrap() - .with_name("bedrock"); - // Amazon Nova: first-party, no Anthropic-approval gate and no third-party - // marketplace subscription, so this runs on any Bedrock-enabled account. - let request = make_request("us.amazon.nova-2-lite-v1:0"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "bedrock"); -} - -#[fabro_macros::e2e_test(live("AWS_ACCESS_KEY_ID"))] -async fn bedrock_complete_with_sigv4() { - let adapter = BedrockAdapter::new_sigv4("https://bedrock-runtime.us-east-1.amazonaws.com") - .unwrap() - .with_name("bedrock"); - // First-party Nova — see bedrock_complete_with_api_key for why. - let request = make_request("us.amazon.nova-2-lite-v1:0"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert_eq!(response.provider, "bedrock"); -} - -#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))] -async fn bedrock_openai_frontier_complete() { - let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK) - .expect("AWS_BEARER_TOKEN_BEDROCK must be set"); - // GPT-5.x on Bedrock is the bedrock-mantle Responses surface: the plain - // openai adapter pointed at the mantle endpoint with the Bedrock key as - // the bearer token. - let adapter = OpenAiAdapter::new(token) - .with_base_url("https://bedrock-mantle.us-east-1.api.aws/openai/v1") - .with_name("bedrock-openai"); - let request = Request { - temperature: None, - ..make_request("openai.gpt-5.5") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert_eq!(response.provider, "bedrock-openai"); -} - -#[fabro_macros::e2e_test(live("POOLSIDE_API_KEY"))] -async fn poolside_laguna_xs_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::POOLSIDE_API_KEY).expect("POOLSIDE_API_KEY must be set"); - let provider = ProviderId::new("poolside"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Poolside credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "laguna-xs-2.1", credential).await; -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_complete() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter"); - let request = make_request("deepseek/deepseek-v4-flash-0731"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openrouter"); - assert!( - response.cost_usd.is_some(), - "OpenRouter responses should carry an authoritative usage.cost", - ); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("ZAI_API_KEY"))] -async fn zai_glm_5_2_reasoning_tool_round_trip() { - let api_key = std::env::var(EnvVars::ZAI_API_KEY).expect("ZAI_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.z.ai/api/coding/paas/v4") - .with_name("zai") - .with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "glm-5.2".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("glm-5.2") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - let raw_message_keys = tool_response - .raw - .as_ref() - .and_then(|raw| raw.pointer("/choices/0/message")) - .and_then(serde_json::Value::as_object) - .map(|message| message.keys().cloned().collect::>()) - .unwrap_or_default(); - assert!( - tool_response.reasoning().is_some(), - "GLM 5.2 should return reasoning content before its tool call; raw message keys: \ - {raw_message_keys:?}" - ); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("GLM 5.2 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "glm-5.2".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("glm-5.2") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "GLM 5.2 should incorporate the replayed tool result" - ); -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_glm_5_2_reasoning_tool_round_trip() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let overrides: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .expect("OpenRouter catalog override should parse"); - let catalog = Catalog::from_builtin_with_overrides(&overrides) - .expect("enabled OpenRouter catalog should build"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter") - .with_catalog(Arc::new(catalog)); - let tool = ToolDefinition::function( - "multiply", - "Multiply two integers", - serde_json::json!({ - "type": "object", - "properties": { - "a": {"type": "integer"}, - "b": {"type": "integer"} - }, - "required": ["a", "b"] - }), - ); - let request = Request { - model: "z-ai/glm-5.2".to_string(), - messages: vec![Message::user( - "Use the multiply tool to calculate 19 times 23. Do not calculate it yourself.", - )], - tools: Some(vec![tool]), - tool_choice: Some(ToolChoice::Required), - temperature: Some(0.0), - max_tokens: Some(4096), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("z-ai/glm-5.2") - }; - - let tool_response = adapter.complete(&request).await.unwrap(); - assert_eq!(tool_response.finish_reason, FinishReason::ToolCalls); - let raw_message_keys = tool_response - .raw - .as_ref() - .and_then(|raw| raw.pointer("/choices/0/message")) - .and_then(serde_json::Value::as_object) - .map(|message| message.keys().cloned().collect::>()) - .unwrap_or_default(); - assert!( - tool_response.reasoning().is_some(), - "GLM 5.2 should return reasoning content before its tool call; raw message keys: \ - {raw_message_keys:?}" - ); - assert_eq!(tool_response.cost_source, Some(CostSource::Authoritative)); - let tool_call = tool_response - .tool_calls() - .into_iter() - .next() - .expect("GLM 5.2 should call the required tool"); - assert_eq!(tool_call.name, "multiply"); - - let mut messages = request.messages.clone(); - messages.push(tool_response.message); - messages.push(Message::tool_result( - tool_call.id, - serde_json::json!({"product": 437}), - false, - )); - let final_request = Request { - model: "z-ai/glm-5.2".to_string(), - messages, - temperature: Some(0.0), - max_tokens: Some(2048), - reasoning_effort: Some(ReasoningEffort::High), - ..make_request("z-ai/glm-5.2") - }; - - let final_response = adapter.complete(&final_request).await.unwrap(); - assert_eq!(final_response.finish_reason, FinishReason::Stop); - assert!( - final_response.text().contains("437"), - "GLM 5.2 should incorporate the replayed tool result" - ); - assert_eq!(final_response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_poolside_laguna_complete() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter"); - let request = make_request("poolside/laguna-xs-2.1"); - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "openrouter"); - assert!( - response.cost_usd.is_some(), - "OpenRouter responses should carry an authoritative usage.cost", - ); - assert_eq!(response.cost_source, Some(CostSource::Authoritative)); -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_complete() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.fireworks.ai/inference/v1") - .with_name("fireworks"); - // gpt-oss models spend reasoning tokens before the final text, so the - // completion budget must cover both. - let request = Request { - max_tokens: Some(2048), - ..make_request("accounts/fireworks/models/gpt-oss-20b") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0); - assert_eq!(response.provider, "fireworks"); -} - -#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] -async fn deepseek_complete() { - let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); - let adapter = - OpenAiCompatibleAdapter::new(api_key, "https://api.deepseek.com").with_name("deepseek"); - let request = Request { - // Thinking mode is enabled by default and shares this budget with the - // visible answer. - max_tokens: Some(1024), - ..make_request("deepseek-v4-flash") - }; - let response = adapter.complete(&request).await.unwrap(); - - assert!( - !response.text().is_empty(), - "response text should not be empty" - ); - assert!(response.usage.input_tokens > 0); - assert!(response.usage.output_tokens > 0 || response.usage.reasoning_tokens > 0); - assert_eq!(response.provider, "deepseek"); -} - -#[fabro_macros::e2e_test(live("DEEPSEEK_API_KEY"))] -async fn deepseek_v4_flash_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::DEEPSEEK_API_KEY).expect("DEEPSEEK_API_KEY must be set"); - let provider = ProviderId::new("deepseek"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("DeepSeek credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "deepseek-v4-flash", credential).await; -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_kimi_k2_7_code_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let provider = ProviderId::new("fireworks"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Fireworks credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k2.7-code", credential).await; -} - -#[fabro_macros::e2e_test(live("FIREWORKS_API_KEY"))] -async fn fireworks_kimi_k3_fast_deep_tool_round_trip() { - let api_key = std::env::var(EnvVars::FIREWORKS_API_KEY).expect("FIREWORKS_API_KEY must be set"); - let provider = ProviderId::new("fireworks"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("Fireworks credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3-fast", credential).await; -} - -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_kimi_k3_deep_tool_round_trip() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let provider = ProviderId::new("openrouter"); - let catalog = enabled_provider_catalog(&provider, None); - let credential = ApiCredential::from_api_key(provider.clone(), api_key, &catalog) - .expect("OpenRouter credential should resolve from the catalog"); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await; -} - -#[fabro_macros::e2e_test( - live("MODAL_KIMI_K3_BASE_URL"), - live("MODAL_TOKEN_ID"), - live("MODAL_TOKEN_SECRET") -)] -async fn modal_kimi_k3_deep_tool_round_trip() { - let base_url = - std::env::var("MODAL_KIMI_K3_BASE_URL").expect("MODAL_KIMI_K3_BASE_URL must be set"); - let token_id = std::env::var(EnvVars::MODAL_TOKEN_ID).expect("MODAL_TOKEN_ID must be set"); - let token_secret = - std::env::var(EnvVars::MODAL_TOKEN_SECRET).expect("MODAL_TOKEN_SECRET must be set"); - let provider = ProviderId::new("modal"); - let catalog = enabled_provider_catalog(&provider, Some(base_url)); - let credential = ApiCredential::with_extra_headers( - provider.clone(), - HashMap::from([ - ("Modal-Key".to_string(), token_id), - ("Modal-Secret".to_string(), token_secret), - ]), - ); - - assert_deep_tool_round_trip(&catalog, &provider, "kimi-k3", credential).await; -} - -async fn run_multi_turn_cache_test( - adapter: &dyn ProviderAdapter, - model: &str, - min_cache_ratio: f64, - temperature: Option, -) { - // Claude Haiku 4.5 requires 4096 tokens minimum for prompt caching. - // Each repeat is ~78 tokens; 70 repeats ≈ 5460 tokens, safely above the - // threshold. - let padding = "This is a detailed context paragraph that provides background information \ - about the conversation. It contains various facts and details that the model should \ - remember throughout the multi-turn interaction. The purpose of this padding is to \ - ensure the system prompt exceeds the minimum cache threshold for the provider. \ - We include information about mathematics, science, history, and general knowledge. \ - The model should use this context when answering questions. " - .repeat(70); - - let system_message = Message::system(format!( - "You are a helpful math assistant. Answer briefly.\n\n{padding}" - )); - - let questions = [ - "What is 1+1?", - "What is 2+2?", - "What is 3+3?", - "What is 4+4?", - "What is 5+5?", - "What is 6+6?", - ]; - - let mut messages = vec![system_message, Message::user(questions[0])]; - let mut best_cache_ratio = 0.0_f64; - - for turn in 0..6 { - let request = Request { - model: model.to_string(), - messages: messages.clone(), - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature, - top_p: None, - max_tokens: Some(100), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - - let response = adapter - .complete(&request) - .await - .expect("provider adapter should return a response"); - let text = response.text(); - assert!( - !text.is_empty(), - "response text should not be empty on turn {turn}" - ); - - let cache_read = response.usage.cache_read_tokens as f64; - let input = response.usage.input_tokens as f64; - let ratio = cache_read / input; - best_cache_ratio = best_cache_ratio.max(ratio); - - messages.push(Message::assistant(text)); - if turn < 5 { - messages.push(Message::user(questions[turn + 1])); - } - } - - assert!( - best_cache_ratio >= min_cache_ratio, - "best cache ratio {best_cache_ratio:.3} should be at least {min_cache_ratio} across all turns" - ); -} - -#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] -async fn anthropic_multi_turn_cache() { - let api_key = std::env::var(EnvVars::ANTHROPIC_API_KEY).expect("ANTHROPIC_API_KEY must be set"); - let adapter = - AnthropicAdapter::new(api_key).with_catalog(Arc::new(Catalog::from_builtin().unwrap())); - run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await; -} - -#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] -async fn openai_multi_turn_cache() { - let api_key = std::env::var(EnvVars::OPENAI_API_KEY).expect("OPENAI_API_KEY must be set"); - let adapter = OpenAiAdapter::new(api_key); - run_multi_turn_cache_test(&adapter, "gpt-5.2", 0.5, None).await; -} - -#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] -async fn gemini_multi_turn_cache() { - let api_key = std::env::var(EnvVars::GEMINI_API_KEY).expect("GEMINI_API_KEY must be set"); - let adapter = GeminiAdapter::new(api_key); - run_multi_turn_cache_test(&adapter, "gemini-2.5-flash", 0.5, Some(0.0)).await; -} - -/// Prompt caching for Claude routed through OpenRouter: the catalog row opts -/// into explicit `cache_control` breakpoints, and OpenRouter must forward -/// them to Anthropic for cache reads to appear. Guards the end-to-end -/// passthrough the wire tests can't see. -#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))] -async fn openrouter_claude_multi_turn_cache() { - let api_key = - std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let overrides: LlmCatalogSettings = toml::from_str( - r" -[providers.openrouter] -enabled = true -", - ) - .expect("OpenRouter catalog override should parse"); - let catalog = Catalog::from_builtin_with_overrides(&overrides) - .expect("enabled OpenRouter catalog should build"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter") - .with_catalog(Arc::new(catalog)); - run_multi_turn_cache_test(&adapter, "claude-haiku-4-5", 0.5, Some(0.0)).await; -} diff --git a/lib/components/fabro-llm/tests/it/main.rs b/lib/components/fabro-llm/tests/it/main.rs deleted file mode 100644 index 66dc027b4..000000000 --- a/lib/components/fabro-llm/tests/it/main.rs +++ /dev/null @@ -1,7 +0,0 @@ -#![allow( - clippy::absolute_paths, - reason = "This test module prefers explicit type paths over extra imports." -)] - -mod support; -mod wire; diff --git a/lib/components/fabro-llm/tests/it/support.rs b/lib/components/fabro-llm/tests/it/support.rs deleted file mode 100644 index 3c1e3a62c..000000000 --- a/lib/components/fabro-llm/tests/it/support.rs +++ /dev/null @@ -1,482 +0,0 @@ -//! Shared helpers for capturing the wire requests adapters send, plus the -//! canonical request corpus pinned across all four provider dialects. - -use std::sync::{Arc, Mutex}; - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::types::{ - AudioData, ContentPart, DocumentData, ImageData, Message, Request, ResponseFormat, Role, - ThinkingData, ToolCall, ToolChoice, ToolDefinition, ToolResult, -}; -use fabro_model::Catalog; -use fabro_model::catalog::LlmCatalogSettings; -use httpmock::prelude::*; - -// --------------------------------------------------------------------------- -// Wire capture -// --------------------------------------------------------------------------- - -/// One captured wire request, normalized for snapshot stability. -#[derive(Debug, Clone, serde::Serialize)] -pub(crate) struct WireCapture { - pub(crate) method: String, - pub(crate) path: String, - pub(crate) headers: Vec<(String, String)>, - pub(crate) body: serde_json::Value, -} - -/// Shared slot the matcher closure writes the captured request into. -pub(crate) type CaptureSlot = Arc>>; - -fn capture_request(req: &HttpMockRequest) -> WireCapture { - let mut headers: Vec<(String, String)> = req - .headers_vec() - .iter() - .map(|(name, value)| { - let name = name.to_ascii_lowercase(); - let value = match name.as_str() { - // The mock server binds a random port. - "host" => "[host]".to_string(), - // Carries a client version that would churn snapshots. - "user-agent" => "[user-agent]".to_string(), - _ => value.clone(), - }; - (name, value) - }) - .collect(); - headers.sort(); - - let path = match req.uri().query() { - Some(query) => format!("{}?{}", req.uri().path(), query), - None => req.uri().path().to_string(), - }; - - WireCapture { - method: req.method_str().to_string(), - path, - headers, - body: serde_json::from_str(&req.body_string()).expect("request body should be JSON"), - } -} - -/// Mounts a mock on `path` that captures the full request into the returned -/// slot and responds with the JSON `response_body`. -pub(crate) fn mount_capture<'a>( - server: &'a MockServer, - path: &'static str, - response_body: serde_json::Value, -) -> (httpmock::Mock<'a>, CaptureSlot) { - let slot: CaptureSlot = Arc::new(Mutex::new(None)); - let writer = Arc::clone(&slot); - let mock = server.mock(move |when, then| { - when.method(POST) - .path(path) - .is_true(move |req: &HttpMockRequest| { - *writer.lock().unwrap() = Some(capture_request(req)); - true - }); - then.status(200) - .header("content-type", "application/json") - .json_body(response_body); - }); - (mock, slot) -} - -/// Like [`mount_capture`] but responds with a raw SSE transcript. -pub(crate) fn mount_capture_sse<'a>( - server: &'a MockServer, - path: &'static str, - sse_body: &str, -) -> (httpmock::Mock<'a>, CaptureSlot) { - let slot: CaptureSlot = Arc::new(Mutex::new(None)); - let writer = Arc::clone(&slot); - let body = sse_body.to_string(); - let mock = server.mock(move |when, then| { - when.method(POST) - .path(path) - .is_true(move |req: &HttpMockRequest| { - *writer.lock().unwrap() = Some(capture_request(req)); - true - }); - then.status(200) - .header("content-type", "text/event-stream") - .body(body.clone()); - }); - (mock, slot) -} - -pub(crate) fn take_capture(slot: &CaptureSlot) -> WireCapture { - slot.lock() - .unwrap() - .take() - .expect("matcher should have captured the request") -} - -/// Drives `adapter.stream(request)` to completion and returns every emitted -/// item as JSON: `Ok` events serialize verbatim (the public SSE wire shape); -/// `Err` items pin the message plus the failover/retry flags consumers key on. -pub(crate) async fn collect_stream_events( - adapter: &dyn ProviderAdapter, - request: &Request, -) -> Vec { - use futures::StreamExt; - - let mut stream = adapter.stream(request).await.expect("stream should start"); - let mut events = Vec::new(); - while let Some(item) = stream.next().await { - events.push(match item { - Ok(event) => serde_json::to_value(&event).expect("event should serialize"), - Err(error) => serde_json::json!({ - "stream_item_error": error.to_string(), - "retryable": error.retryable(), - "failover_eligible": error.failover_eligible(), - }), - }); - } - events -} - -/// Pin the transport-level liveness contract independently of snapshots. -pub(crate) fn assert_stream_starts(events: &[serde_json::Value]) { - assert_eq!( - events - .first() - .and_then(|event| event.get("type")) - .and_then(serde_json::Value::as_str), - Some("stream_start"), - "the first decoded provider frame must open with stream_start" - ); -} - -/// Builds a catalog from inline TOML (same `LlmCatalogSettings` schema as the -/// shipped catalog files). -pub(crate) fn catalog_from_toml(source: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(source).expect("catalog TOML should parse"); - Arc::new(Catalog::from_settings(&settings).expect("catalog should build")) -} - -fn is_uuid(s: &str) -> bool { - s.len() == 36 - && s.bytes().enumerate().all(|(i, b)| match i { - 8 | 13 | 18 | 23 => b == b'-', - _ => b.is_ascii_hexdigit(), - }) -} - -/// Replaces UUID-shaped strings with `[UUID]` for snapshot stability — the -/// Gemini decoder mints synthetic `Uuid::new_v4()` tool-call ids. -pub(crate) fn normalize_uuids(value: &mut serde_json::Value) { - match value { - serde_json::Value::String(s) if is_uuid(s) => "[UUID]".clone_into(s), - serde_json::Value::Array(items) => items.iter_mut().for_each(normalize_uuids), - serde_json::Value::Object(map) => map.values_mut().for_each(normalize_uuids), - _ => {} - } -} - -/// Renders `(event, data)` pairs as an SSE transcript with `event:` lines -/// (the Anthropic framing). -pub(crate) fn sse_transcript(events: &[(&str, &str)]) -> String { - use std::fmt::Write; - - events.iter().fold(String::new(), |mut out, (event, data)| { - let _ = writeln!(out, "event: {event}\ndata: {data}\n"); - out - }) -} - -/// Renders data-only SSE lines (the OpenAI/Gemini framing). -pub(crate) fn sse_data_transcript(lines: &[&str]) -> String { - use std::fmt::Write; - - lines.iter().fold(String::new(), |mut out, data| { - let _ = writeln!(out, "data: {data}\n"); - out - }) -} - -// --------------------------------------------------------------------------- -// Canonical request corpus -// -// Each constructor returns one canonical `Request` shape that every dialect -// file pins through its own adapter. Keep these stable: editing a corpus -// request invalidates the pinned wire snapshots in all four dialect files. -// --------------------------------------------------------------------------- - -pub(crate) fn base_request(model: &str) -> Request { - Request { - model: model.to_string(), - messages: vec![Message::user("Hello")], - provider: None, - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(128), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - } -} - -/// Multi-turn conversation: system + user/assistant/user. -pub(crate) fn corpus_multi_turn(model: &str) -> Request { - Request { - messages: vec![ - Message::system("You are a terse assistant."), - Message::user("What is the capital of France?"), - Message::assistant("Paris."), - Message::user("And of Spain?"), - ], - ..base_request(model) - } -} - -/// Two tools plus an optional tool choice. -pub(crate) fn corpus_tools(model: &str, tool_choice: Option) -> Request { - Request { - tools: Some(vec![ - ToolDefinition::function( - "search", - "Search files", - serde_json::json!({ - "type": "object", - "properties": {"query": {"type": "string"}}, - "required": ["query"] - }), - ), - ToolDefinition::function( - "read_file", - "Read a file by path", - serde_json::json!({ - "type": "object", - "properties": {"path": {"type": "string"}} - }), - ), - ]), - tool_choice, - ..base_request(model) - } -} - -/// A full tool round trip: assistant emits two tool calls, the tool turn -/// returns one success carrying an image and one error result. -pub(crate) fn corpus_tool_round_trip(model: &str) -> Request { - let mut image_result = ToolResult::success("call_1", serde_json::json!({"matches": 2})); - image_result.image_data = Some(b"fake-screenshot-bytes".to_vec()); - image_result.image_media_type = Some("image/png".to_string()); - - let mut request = corpus_tools(model, None); - request.messages = vec![ - Message::user("Find foo and read /tmp/x"), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check."), - ContentPart::ToolCall(ToolCall::new( - "call_1", - "search", - serde_json::json!({"query": "foo"}), - )), - ContentPart::ToolCall(ToolCall::new( - "call_2", - "read_file", - serde_json::json!({"path": "/tmp/x"}), - )), - ], - name: None, - tool_call_id: None, - }, - Message { - role: Role::Tool, - content: vec![ContentPart::ToolResult(image_result)], - name: None, - tool_call_id: Some("call_1".to_string()), - }, - Message::tool_result( - "call_2", - serde_json::Value::String("file not found".to_string()), - true, - ), - ]; - request -} - -/// Assistant thinking block with a signature, round-tripped back as history. -pub(crate) fn corpus_thinking_round_trip(model: &str) -> Request { - Request { - messages: vec![ - Message::user("Think step by step: what is 2+2?"), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::Thinking(ThinkingData { - text: "The user wants 2+2, which is 4.".to_string(), - signature: Some("sig_test_abc123".to_string()), - redacted: false, - }), - ContentPart::text("4."), - ], - name: None, - tool_call_id: None, - }, - Message::user("Now 3+3?"), - ], - ..base_request(model) - } -} - -/// Image and document attachments as inline bytes (no file I/O involved). -pub(crate) fn corpus_inline_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: None, - data: Some(b"fake-png-bytes".to_vec()), - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: None, - data: Some(b"fake-pdf-bytes".to_vec()), - media_type: Some("application/pdf".to_string()), - file_name: Some("report.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Image and document attachments as non-file https URLs. Each dialect has -/// its own URL-passthrough wire shape; resolving these to inline data would -/// be a wire change. -pub(crate) fn corpus_url_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: Some("https://example.com/picture.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: Some("https://example.com/report.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("report.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Attachments referencing file paths that do not exist. Today every adapter -/// silently drops the part on load failure (`Err(_) => None`) and sends the -/// rest of the request; these requests pin that contract. -pub(crate) fn corpus_bad_file_path_attachments(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Describe these attachments."), - ContentPart::Image(ImageData { - url: Some("/nonexistent/fabro-wire-pin.png".to_string()), - data: None, - media_type: Some("image/png".to_string()), - detail: None, - }), - ContentPart::Document(DocumentData { - url: Some("/nonexistent/fabro-wire-pin.pdf".to_string()), - data: None, - media_type: Some("application/pdf".to_string()), - file_name: Some("missing.pdf".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Inline audio attachment (support differs per dialect: gemini sends it, -/// openai-responses falls back to text, anthropic/compat drop or warn). -pub(crate) fn corpus_audio_attachment(model: &str) -> Request { - Request { - messages: vec![Message { - role: Role::User, - content: vec![ - ContentPart::text("Transcribe this."), - ContentPart::Audio(AudioData { - url: None, - data: Some(b"fake-wav-bytes".to_vec()), - media_type: Some("audio/wav".to_string()), - }), - ], - name: None, - tool_call_id: None, - }], - ..base_request(model) - } -} - -/// Response-format request (callers pass each of the three kinds). -pub(crate) fn corpus_response_format(model: &str, format: ResponseFormat) -> Request { - Request { - response_format: Some(format), - ..base_request(model) - } -} - -/// A JSON-schema response format with `strict` set. The schema is passed raw -/// (no name/schema wrapper) — the shape `generate_object` produces. -pub(crate) fn json_schema_format() -> ResponseFormat { - ResponseFormat { - kind: fabro_llm::types::ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"answer": {"type": "string"}}, - "required": ["answer"] - })), - strict: true, - } -} - -/// Sampling parameters: temperature, top_p, stop sequences, and metadata. -/// Metadata deliberately holds a single key — `HashMap` iteration order would -/// make multi-key snapshots nondeterministic. -pub(crate) fn corpus_sampling_params(model: &str) -> Request { - Request { - temperature: Some(0.7), - top_p: Some(0.9), - stop_sequences: Some(vec!["END".to_string()]), - metadata: Some(std::collections::HashMap::from([( - "trace_id".to_string(), - "trace-123".to_string(), - )])), - ..base_request(model) - } -} - -/// Provider-options escape hatch (callers pass the dialect's namespace key). -pub(crate) fn corpus_provider_options(model: &str, options: serde_json::Value) -> Request { - Request { - provider_options: Some(options), - ..base_request(model) - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/anthropic.rs b/lib/components/fabro-llm/tests/it/wire/anthropic.rs deleted file mode 100644 index 706893f16..000000000 --- a/lib/components/fabro-llm/tests/it/wire/anthropic.rs +++ /dev/null @@ -1,912 +0,0 @@ -//! Wire snapshots for the Anthropic Messages dialect. - -use std::sync::Arc; - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::AnthropicAdapter; -use fabro_llm::types::{ - Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, StreamEvent, ToolChoice, - ToolDefinition, -}; -use fabro_llm::{Error, ProviderErrorKind}; -use fabro_model::Catalog; -use futures::StreamExt; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "claude-sonnet-4-20250514"; - -/// Minimal valid Messages API body for encode-side tests that only assert on -/// the captured request. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [{"type": "text", "text": "ok"}], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": {"input_tokens": 1, "output_tokens": 1} - }) -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: AnthropicAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/messages", minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - adapter: AnthropicAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/messages", sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -fn adapter() -> AnthropicAdapter { - AnthropicAdapter::new("test-key") -} - -fn builtin_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("built-in catalog should build")) -} - -fn header_value<'a>(capture: &'a WireCapture, name: &str) -> Option<&'a str> { - capture - .headers - .iter() - .find(|(header, _)| header == name) - .map(|(_, value)| value.as_str()) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip: runs `complete()` against a -/// canned response and returns both the captured request and decoded response -/// so the encode and decode halves can be pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/messages", - serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [{"type": "text", "text": "Hello back"}], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "cache_read_input_tokens": 10, - "cache_creation_input_tokens": 3 - } - }), - ); - - let adapter = AnthropicAdapter::new("test-key").with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request("claude-sonnet-4-20250514") - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_anthropic_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options(MODEL, serde_json::json!({"anthropic": {"top_k": 5}})), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -prompt_cache = false -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request("test-claude") - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_fable_uses_api_id_effort_and_omits_1m_beta() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::XHigh), - temperature: Some(0.0), - top_p: Some(0.5), - ..base_request("fable") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert_eq!(capture.body["model"], "claude-fable-5"); - assert_eq!(capture.body["output_config"]["effort"], "xhigh"); - assert!(capture.body.get("thinking").is_none()); - assert!(capture.body.get("temperature").is_none()); - assert!(capture.body.get("top_p").is_none()); - assert!( - !header_value(&capture, "anthropic-beta") - .unwrap_or("") - .contains("context-1m-2025-08-07"), - "Fable has 1M context by default and must not receive the legacy beta header" - ); -} - -#[tokio::test] -async fn encode_opus_omits_1m_beta_header() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-opus-4-8"), - ) - .await; - - assert!( - !header_value(&capture, "anthropic-beta") - .unwrap_or("") - .contains("context-1m-2025-08-07"), - "1M context is GA on opus; the legacy beta opt-in must not be sent" - ); -} - -#[tokio::test] -async fn encode_opus_drops_sampling_params() { - let request = Request { - temperature: Some(0.0), - top_p: Some(0.5), - ..base_request("claude-opus-4-8") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert!( - capture.body.get("temperature").is_none(), - "Opus 4.7/4.8 reject temperature; it must not be sent" - ); - assert!( - capture.body.get("top_p").is_none(), - "Opus 4.7/4.8 reject top_p; it must not be sent" - ); -} - -#[tokio::test] -async fn encode_opus_effort_keeps_adaptive_thinking() { - let request = Request { - reasoning_effort: Some(ReasoningEffort::High), - ..base_request("claude-opus-4-8") - }; - - let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await; - - assert_eq!(capture.body["output_config"]["effort"], "high"); - assert_eq!( - capture.body["thinking"]["type"], "adaptive", - "asking for effort must not turn thinking off; Opus 4.7/4.8 run without thinking unless adaptive is sent" - ); -} - -#[tokio::test] -async fn encode_opus_without_effort_injects_adaptive_thinking() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-opus-4-8"), - ) - .await; - - assert_eq!(capture.body["thinking"]["type"], "adaptive"); -} - -#[tokio::test] -async fn encode_fable_without_effort_omits_default_thinking() { - let capture = encode_capture( - adapter().with_catalog(builtin_catalog()), - &base_request("claude-fable-5"), - ) - .await; - - assert_eq!(capture.body["model"], "claude-fable-5"); - assert!(capture.body.get("thinking").is_none()); -} - -#[test] -fn fable_rejects_manual_enabled_or_disabled_thinking() { - let adapter = adapter().with_catalog(builtin_catalog()); - - for kind in ["enabled", "disabled"] { - let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": kind, "budget_tokens": 1024} - } - })), - ..base_request("claude-fable-5") - }; - - let err = adapter - .validate_request(&request) - .expect_err("manual Fable thinking mode should be rejected locally"); - assert!( - err.to_string().contains("Claude Fable 5") - && err.to_string().contains("thinking") - && err.to_string().contains(kind), - "unexpected error: {err}" - ); - } -} - -#[tokio::test] -async fn encode_prompt_cache_with_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -prompt_cache = true -"#, - ); - let request = Request { - messages: vec![ - Message::system("You are a careful reviewer."), - Message::user("Review this."), - ], - ..corpus_tools("test-claude", None) - }; - // Full capture: the prompt-cache path also controls the beta header. - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/messages/count_tokens", - serde_json::json!({"input_tokens": 123}), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("anthropic should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -/// Runs `complete()` against a canned body and returns the decoded response. -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/messages", body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// Runs `complete()` against a canned body and returns the adapter result. -async fn complete_result(body: serde_json::Value) -> Result { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/messages", body); - let adapter = adapter().with_base_url(server.base_url()); - let result = adapter.complete(&base_request(MODEL)).await; - mock.assert(); - result -} - -#[tokio::test] -async fn decode_tool_use_stop_reason() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [ - {"type": "text", "text": "Let me search."}, - { - "type": "tool_use", - "id": "toolu_01", - "name": "search", - "input": {"query": "foo"} - } - ], - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_thinking_and_redacted_thinking() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [ - {"type": "thinking", "thinking": "Step one.", "signature": "sig_decode_abc"}, - {"type": "redacted_thinking", "data": "opaque-blob"}, - {"type": "text", "text": "Done."} - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": {"input_tokens": 25, "output_tokens": 40} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_max_tokens_stop_reason() { - let response = decode_response(serde_json::json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": MODEL, - "content": [{"type": "text", "text": "Truncated answe"}], - "stop_reason": "max_tokens", - "stop_sequence": null, - "usage": {"input_tokens": 10, "output_tokens": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_refusal_returns_failover_eligible_content_filter_error() { - let err = complete_result(serde_json::json!({ - "id": "msg_refusal", - "type": "message", - "role": "assistant", - "model": "claude-fable-5", - "content": [], - "stop_reason": "refusal", - "stop_details": { - "type": "refusal", - "category": "cyber", - "explanation": "This request was declined because it could enable cyber harm." - }, - "usage": {"input_tokens": 412, "output_tokens": 0} - })) - .await - .expect_err("refusal should be returned as an LLM error"); - - assert!(err.failover_eligible()); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::ContentFilter); - assert_eq!(detail.provider, "anthropic"); - assert_eq!(detail.error_code.as_deref(), Some("refusal")); - assert!(detail.message.contains("claude-fable-5")); - assert!(detail.message.contains("declined")); - assert_eq!( - detail.raw.as_ref().unwrap()["stop_details"]["category"], - "cyber" - ); - } - other => panic!("expected provider content-filter error, got {other:?}"), - } -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_test","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":2,"cache_creation_input_tokens":1,"output_tokens":0}}}"#, - ), - ("ping", r#"{"type":"ping"}"#), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins the stream flag on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_tool","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":20,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"search","input":{}}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"qu"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ery\":\"foo\"}"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_thinking_with_signature_delta() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_think","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":15,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think"}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_stream_xyz"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"4."}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":1}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_error_event_mid_stream() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_err","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}"#, - ), - ( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_refusal_returns_error_without_final_response() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_refusal","type":"message","role":"assistant","model":"claude-fable-5","content":[],"usage":{"input_tokens":412,"output_tokens":0}}}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"cyber","explanation":"This request was declined."}},"usage":{"output_tokens":0}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - let server = MockServer::start(); - let (mock, _slot) = mount_capture_sse(&server, "/messages", &sse); - let adapter = adapter().with_base_url(server.base_url()); - let mut stream = adapter - .stream(&base_request("claude-fable-5")) - .await - .expect("stream should start"); - - let mut saw_finish = false; - let mut refusal = None; - while let Some(item) = stream.next().await { - match item { - Ok(StreamEvent::Finish { .. }) => saw_finish = true, - Ok(_) => {} - Err(err) => { - refusal = Some(err); - break; - } - } - } - mock.assert(); - - assert!(!saw_finish, "refusal stream must not emit a final response"); - let err = refusal.expect("stream should yield a refusal error"); - assert!(err.failover_eligible()); - match &err { - Error::Provider { kind, detail } => { - assert_eq!(*kind, ProviderErrorKind::ContentFilter); - assert_eq!(detail.error_code.as_deref(), Some("refusal")); - assert!(detail.message.contains("claude-fable-5")); - assert_eq!( - detail.raw.as_ref().unwrap()["stop_details"]["category"], - "cyber" - ); - } - other => panic!("expected provider content-filter error, got {other:?}"), - } -} - -/// The Anthropic decoder never synthesizes a `Finish` on byte-stream end: -/// `message_stop` is the only finisher. A transcript that ends without it -/// must produce no `Finish` event. -#[tokio::test] -async fn stream_without_message_stop_emits_no_finish() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_stream_cut","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":11,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}"#, - ), - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route (the Moonshot Kimi-over-anthropic shape) -// --------------------------------------------------------------------------- - -/// Shared setup for a custom-named Moonshot Kimi stream route using the -/// Anthropic dialect; separate tests pin the request and event halves. -async fn custom_named_stream_capture() -> (WireCapture, Vec) { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_kimi","type":"message","role":"assistant","model":"kimi-test","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}"#, - ), - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, - ), - ( - "content_block_stop", - r#"{"type":"content_block_stop","index":0}"#, - ), - ( - "message_delta", - r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - stream_capture( - adapter().with_name("moonshot"), - &base_request("kimi-test"), - &sse, - ) - .await -} - -/// A custom-named Moonshot route authenticates with a bearer token and sends -/// no `anthropic-version` header. This pins that route shape on the wire. -#[tokio::test] -async fn custom_named_stream_route() { - let (capture, _) = custom_named_stream_capture().await; - fabro_test::fabro_json_snapshot!(capture); -} - -/// Since provider-identity normalization, the streamed `Response.provider` -/// carries the configured name. -#[tokio::test] -async fn custom_named_stream_identity() { - let (_, events) = custom_named_stream_capture().await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Error events on a custom-named route carry the configured name in the -/// error detail (normalize-both decision). -#[tokio::test] -async fn custom_named_stream_error_identity() { - let sse = support::sse_transcript(&[ - ( - "message_start", - r#"{"type":"message_start","message":{"id":"msg_kimi_err","type":"message","role":"assistant","model":"kimi-test","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}"#, - ), - ( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ), - ]); - let (_capture, events) = stream_capture( - adapter().with_name("moonshot"), - &base_request("kimi-test"), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} diff --git a/lib/components/fabro-llm/tests/it/wire/gemini.rs b/lib/components/fabro-llm/tests/it/wire/gemini.rs deleted file mode 100644 index 2d1cc2e5e..000000000 --- a/lib/components/fabro-llm/tests/it/wire/gemini.rs +++ /dev/null @@ -1,536 +0,0 @@ -//! Wire snapshots for the Gemini `generateContent` dialect. The model is -//! part of the URL path, auth is the `x-goog-api-key` header, and the -//! decoder mints synthetic UUID tool-call ids (normalized to `[UUID]` in -//! these snapshots). - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::GeminiAdapter; -use fabro_llm::types::{ - Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition, -}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "gemini-test"; -const COMPLETE_PATH: &str = "/models/gemini-test:generateContent"; -const STREAM_PATH: &str = "/models/gemini-test:streamGenerateContent"; - -/// Minimal valid generateContent body for encode-side tests. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "ok"}]}, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1} - }) -} - -fn adapter() -> GeminiAdapter { - GeminiAdapter::new("test-key") -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: GeminiAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, COMPLETE_PATH, minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON (UUIDs normalized). -async fn stream_capture( - adapter: GeminiAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, STREAM_PATH, sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let mut events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - events.iter_mut().for_each(support::normalize_uuids); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip. The decoded response is -/// returned as a UUID-normalized JSON value (gemini mints a synthetic UUID -/// for the response id); the encode and decode halves are pinned separately. -async fn system_and_tools_roundtrip() -> (WireCapture, serde_json::Value) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - COMPLETE_PATH, - serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "Hello back"}]}, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 42, - "candidatesTokenCount": 7, - "cachedContentTokenCount": 10 - } - }), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - let mut response_value = serde_json::to_value(&response).expect("response should serialize"); - support::normalize_uuids(&mut response_value); - (take_capture(&slot), response_value) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Gemini sends inline audio (the only dialect that does). -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The "gemini"-namespaced provider_options merge — and the default -/// safety_settings injection it can override. -#[tokio::test] -async fn encode_provider_options_gemini_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options( - MODEL, - serde_json::json!({"gemini": {"cached_content": "cachedContents/abc"}}), - ), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_can_override_safety_settings() { - let capture = encode_capture( - adapter(), - &corpus_provider_options( - MODEL, - serde_json::json!({"gemini": {"safety_settings": []}}), - ), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.gemini] -display_name = "Gemini" -adapter = "gemini" -agent_profile = "gemini" - -[models."gemini-test"] -provider = "gemini" -display_name = "Test Gemini" -family = "gemini" -default = true - -[models."gemini-test".limits] -context_window = 200000 -max_output = 4096 - -[models."gemini-test".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request(MODEL) - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/models/gemini-test:countTokens", - serde_json::json!({"totalTokens": 123}), - ); - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("gemini should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -/// Runs `complete()` against a canned body and returns the decoded response -/// as JSON with synthetic UUIDs normalized. -async fn decode_response(body: serde_json::Value) -> serde_json::Value { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, COMPLETE_PATH, body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - let mut value = serde_json::to_value(&response).expect("response should serialize"); - support::normalize_uuids(&mut value); - value -} - -/// functionCall parts get synthetic UUID ids, preserve `thoughtSignature`, -/// and force the finish reason to ToolCalls regardless of `finishReason`. -#[tokio::test] -async fn decode_function_call_with_thought_signature() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - {"text": "Let me search."}, - { - "functionCall": {"name": "search", "args": {"query": "foo"}}, - "thoughtSignature": "sig_gemini_xyz" - } - ] - }, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 30, "candidatesTokenCount": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// The Gemini usage arithmetic: input = (prompt - cached) + tool_use_prompt; -/// thoughts become reasoning tokens. -#[tokio::test] -async fn decode_usage_arithmetic() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "ok"}]}, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 100, - "candidatesTokenCount": 50, - "thoughtsTokenCount": 8, - "cachedContentTokenCount": 30, - "toolUsePromptTokenCount": 5 - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// `thought: true` text parts decode as Thinking content. -#[tokio::test] -async fn decode_thought_parts() { - let response = decode_response(serde_json::json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - {"text": "Adding the numbers.", "thought": true}, - {"text": "4."} - ] - }, - "finishReason": "STOP" - }], - "usageMetadata": {"promptTokenCount": 25, "candidatesTokenCount": 40} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_max_tokens_finish_reason() { - let length = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": "Trunc"}]}, - "finishReason": "MAX_TOKENS" - }], - "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(length["finish_reason"]); -} - -#[tokio::test] -async fn decode_safety_finish_reason() { - let safety = decode_response(serde_json::json!({ - "candidates": [{ - "content": {"role": "model", "parts": [{"text": ""}]}, - "finishReason": "SAFETY" - }], - "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 0} - })) - .await; - fabro_test::fabro_json_snapshot!(safety["finish_reason"]); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}"#, - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":5}}"#, - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins model-in-URL and `?alt=sse` on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_function_call() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"search","args":{"query":"foo"}},"thoughtSignature":"sig_stream_g"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9}}"#, - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_thought_parts() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Let me think","thought":true}]}}]}"#, - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"4."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":15,"candidatesTokenCount":12,"thoughtsTokenCount":6}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// The Gemini decoder synthesizes a `Finish` on byte-stream end -/// unconditionally — even when no chunk carried a `finishReason`. -#[tokio::test] -async fn stream_end_synthesizes_finish_without_finish_reason() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}]}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// Streamed responses stamp the configured provider name (previously -/// hardcoded "gemini"). -#[tokio::test] -async fn custom_named_stream_identity() { - let sse = support::sse_data_transcript(&[ - r#"{"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}"#, - ]); - let (_capture, events) = stream_capture( - adapter().with_name("gemini-proxy"), - &base_request(MODEL), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -/// HTTP-level errors carry the configured name in the error detail -/// (normalize-both decision). -#[tokio::test] -async fn custom_named_http_error_identity() { - let server = MockServer::start(); - let mock = server.mock(|when, then| { - when.method(POST).path(COMPLETE_PATH); - then.status(500) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "error": {"message": "backend exploded", "status": "INTERNAL", "code": 500} - })); - }); - let adapter = adapter() - .with_name("gemini-proxy") - .with_base_url(server.base_url()); - let error = adapter - .complete(&base_request(MODEL)) - .await - .expect_err("complete should fail"); - mock.assert(); - fabro_test::fabro_json_snapshot!(serde_json::json!({ - "error": error.to_string(), - "retryable": error.retryable(), - "failover_eligible": error.failover_eligible(), - })); -} diff --git a/lib/components/fabro-llm/tests/it/wire/mod.rs b/lib/components/fabro-llm/tests/it/wire/mod.rs deleted file mode 100644 index 16ad18987..000000000 --- a/lib/components/fabro-llm/tests/it/wire/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Wire snapshot tests pinning per-dialect encode/decode behavior. -//! -//! Each test points a real adapter at a local httpmock server, side-channels -//! the full received request (method, path, headers, body) out of an -//! `is_true` matcher closure, responds with a canned provider body, and -//! snapshots both the captured wire request (encode) and the decoded -//! canonical `Response` (decode). The codec extraction PRs must keep these -//! snapshot values identical. -//! -//! The anthropic/gemini dialects have no twin coverage, so these snapshots -//! are the only behavior net for those extractions. -//! -//! Snapshots are stored externally under `snapshots/` (via -//! `fabro_test::fabro_json_snapshot!(value)` with no inline literal) to keep -//! these source files small. Review and accept with `cargo insta` -//! (`pending-snapshots` then `accept`), per CLAUDE.md. A few tests assert two -//! snapshots in one function; insta names the second `-2.snap`. - -mod anthropic; -mod gemini; -mod openai_compatible; -mod openai_responses; diff --git a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs b/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs deleted file mode 100644 index 22615dcd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/openai_compatible.rs +++ /dev/null @@ -1,953 +0,0 @@ -//! Wire snapshots for the OpenAI Chat Completions dialect served by -//! `OpenAiCompatibleAdapter` (kimi, zai, minimax, venice, inception, ollama, -//! litellm — all config-only routes over this adapter). - -use std::sync::Arc; - -use fabro_llm::generate::StreamAccumulator; -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiCompatibleAdapter; -use fabro_llm::types::{ - Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, ToolChoice, - ToolDefinition, -}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ProviderId}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "test-model"; - -/// Fixed `created` timestamp for canned bodies (named to satisfy clippy's -/// unreadable-literal lint without touching the JSON wire value). -const CREATED_TS: i64 = 1_700_000_000; - -/// Minimal valid Chat Completions body for encode-side tests. -fn minimal_body() -> serde_json::Value { - body_with_message(&serde_json::json!({"role": "assistant", "content": "ok"})) -} - -/// Wraps an assistant message in a complete Chat Completions body. -fn body_with_message(message: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": message, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} - }) -} - -fn adapter(server: &MockServer) -> OpenAiCompatibleAdapter { - OpenAiCompatibleAdapter::new("test-key", server.base_url()) -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture_with( - request: &Request, - configure: impl FnOnce(OpenAiCompatibleAdapter) -> OpenAiCompatibleAdapter, -) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/chat/completions", minimal_body()); - let adapter = configure(adapter(&server)); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -async fn encode_capture(request: &Request) -> WireCapture { - encode_capture_with(request, |adapter| adapter).await -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/chat/completions", sse_body); - let adapter = adapter(&server); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip; the encode and decode halves -/// are pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/chat/completions", - serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "Hello back"}, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49} - }), - ); - - let adapter = adapter(&server); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(&corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::named("search")))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(&corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(&corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Assistant thinking parts echo back as `reasoning_content` (required by -/// Kimi and DeepSeek during tool-call continuations). -#[tokio::test] -async fn encode_thinking_round_trip_as_reasoning_content() { - let capture = encode_capture(&corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The compat encoder performs no attachment I/O: images are dropped -/// outright, documents become fallback text. -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(&corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(&corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments() { - let capture = encode_capture(&corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(&corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(&corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture(&corpus_response_format(MODEL, json_schema_format())).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(&corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() { - let catalog = Arc::new(Catalog::from_builtin().expect("built-in catalog should build")); - let request = Request { - model: "kimi-k3".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - temperature: Some(0.7), - top_p: Some(0.9), - ..base_request(MODEL) - }; - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("moonshot").with_catalog(catalog) - }) - .await; - - assert_eq!(capture.body["model"], "kimi-k3"); - assert_eq!(capture.body["reasoning_effort"], "high"); - assert!(capture.body.get("temperature").is_none()); - assert!(capture.body.get("top_p").is_none()); -} - -/// Counts JSON objects anywhere in `value` carrying a `cache_control` key. -fn count_cache_control_breakpoints(value: &serde_json::Value) -> usize { - match value { - serde_json::Value::Object(map) => { - usize::from(map.contains_key("cache_control")) - + map - .values() - .map(count_cache_control_breakpoints) - .sum::() - } - serde_json::Value::Array(items) => items.iter().map(count_cache_control_breakpoints).sum(), - _ => 0, - } -} - -/// Builtin catalog with the opt-in OpenRouter provider enabled. -fn openrouter_catalog() -> Arc { - let overrides: LlmCatalogSettings = toml::from_str("[providers.openrouter]\nenabled = true\n") - .expect("override TOML should parse"); - Arc::new( - Catalog::from_builtin_with_overrides(&overrides) - .expect("catalog with OpenRouter enabled should build"), - ) -} - -/// System + tools + two user turns against an OpenRouter model. -fn openrouter_multi_turn(model: &str) -> Request { - Request { - messages: vec![ - Message::system("You are a careful reviewer."), - Message::user("Review this."), - Message::assistant("Looking now."), - Message::user("Focus on the tests."), - ], - ..corpus_tools(model, None) - } -} - -/// OpenRouter serves Claude through this adapter, and Anthropic prompt -/// caching is opt-in per request: OpenRouter only forwards a cache write when -/// the body carries explicit ephemeral `cache_control` breakpoints (OpenAI -/// models cache implicitly; Anthropic models never do). The catalog row -/// declares `cache_control_breakpoints`, so the encoded request must mark the -/// cacheable prefix — otherwise every turn bills at the full uncached input -/// rate. -#[tokio::test] -async fn encode_openrouter_claude_marks_prompt_cache_breakpoints() { - let catalog = openrouter_catalog(); - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "claude-fable-5") - .expect("OpenRouter Claude row should exist in the built-in catalog"); - assert!(model.features.prompt_cache); - assert!(model.features.cache_control_breakpoints); - - let request = openrouter_multi_turn("claude-fable-5"); - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("openrouter").with_catalog(catalog) - }) - .await; - - assert_eq!(capture.body["model"], "anthropic/claude-fable-5"); - let messages = &capture.body["messages"]; - // The system prompt converts to parts form carrying a breakpoint; it - // covers the tool definitions too (tools precede system upstream). - assert_eq!(messages[0]["content"][0]["type"], "text"); - assert_eq!( - messages[0]["content"][0]["text"], - "You are a careful reviewer." - ); - assert_eq!( - messages[0]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - // The second-to-last user turn carries the conversation breakpoint... - assert_eq!(messages[1]["content"][0]["text"], "Review this."); - assert_eq!( - messages[1]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - // ...and the newest turn stays in plain-string form. - assert_eq!(messages[3]["content"], "Focus on the tests."); - assert_eq!(count_cache_control_breakpoints(&capture.body), 2); -} - -/// Models with implicit (server-side) caching must NOT get breakpoints even -/// though they support prompt caching — the annotation is an Anthropic-ism -/// the catalog row has to opt into. -#[tokio::test] -async fn encode_openrouter_implicit_cache_model_stays_plain() { - let catalog = openrouter_catalog(); - let model = catalog - .get_on_provider(&ProviderId::new("openrouter"), "gpt-5.6-luna") - .expect("OpenRouter GPT row should exist in the built-in catalog"); - assert!(model.features.prompt_cache); - assert!(!model.features.cache_control_breakpoints); - - let request = openrouter_multi_turn("gpt-5.6-luna"); - let capture = encode_capture_with(&request, move |adapter| { - adapter.with_name("openrouter").with_catalog(catalog) - }) - .await; - - assert_eq!(count_cache_control_breakpoints(&capture.body), 0); - assert_eq!( - capture.body["messages"][0]["content"], - "You are a careful reviewer." - ); -} - -/// `provider_options.openrouter.auto_cache = false` disables the breakpoints, -/// and the control key is consumed rather than merged into the body. -#[tokio::test] -async fn encode_openrouter_claude_auto_cache_opt_out() { - let request = Request { - provider_options: Some(serde_json::json!({"openrouter": {"auto_cache": false}})), - ..openrouter_multi_turn("claude-fable-5") - }; - let capture = encode_capture_with(&request, move |adapter| { - adapter - .with_name("openrouter") - .with_catalog(openrouter_catalog()) - }) - .await; - - assert_eq!(count_cache_control_breakpoints(&capture.body), 0); - assert!(capture.body.get("auto_cache").is_none()); -} - -/// The provider_options namespace key is the runtime adapter NAME, not a -/// static "openai_compatible" key (pinned in-module by -/// `provider_options_uses_adapter_name`; this pins it from outside). -#[tokio::test] -async fn encode_provider_options_keyed_by_adapter_name() { - let request = corpus_provider_options( - MODEL, - serde_json::json!({"moonshot": {"repetition_penalty": 1.2}}), - ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Options under a key that does not match the adapter name must not merge. -#[tokio::test] -async fn encode_provider_options_other_namespace_ignored() { - let request = corpus_provider_options( - MODEL, - serde_json::json!({"openai": {"repetition_penalty": 1.2}}), - ); - let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// The compat adapter has no count-tokens wire route. -#[tokio::test] -async fn count_input_tokens_unavailable() { - let server = MockServer::start(); - let adapter = adapter(&server); - let count = adapter - .count_input_tokens(&base_request(MODEL)) - .await - .unwrap(); - assert!(count.is_none()); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/chat/completions", body); - let adapter = adapter(&server); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// Streams an SSE transcript and returns the final accumulated response. -async fn stream_final_response(sse_body: &str) -> fabro_llm::types::Response { - use futures::StreamExt; - - let server = MockServer::start(); - let (mock, _slot) = mount_capture_sse(&server, "/chat/completions", sse_body); - let adapter = adapter(&server); - let mut stream = adapter - .stream(&base_request(MODEL)) - .await - .expect("stream should start"); - let mut accumulator = StreamAccumulator::new(); - while let Some(item) = stream.next().await { - accumulator.process(&item.expect("stream event should decode")); - } - mock.assert(); - accumulator - .response() - .cloned() - .expect("stream should emit a finish event") -} - -#[tokio::test] -async fn decode_tool_calls_with_string_arguments() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": {"name": "search", "arguments": "{\"query\":\"foo\"}"} - }] - }, - "finish_reason": "tool_calls" - }], - "usage": {"prompt_tokens": 30, "completion_tokens": 12, "total_tokens": 42} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -#[tokio::test] -async fn decode_reasoning_content_as_thinking() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2." - }, - "finish_reason": "stop" - }], - "usage": {"prompt_tokens": 25, "completion_tokens": 40, "total_tokens": 65} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Structured reasoning details -// --------------------------------------------------------------------------- - -/// The structured channel classifies summary and trace independently. -#[tokio::test] -async fn decode_reasoning_details_normalize_summary_and_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - {"type": "reasoning.text", "text": "2 plus 2 is 4", "index": 1}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4")); -} - -/// Encrypted entries stay in the opaque provider part for future replay but -/// never reach the normalized output. -#[tokio::test] -async fn decode_reasoning_details_preserve_encrypted_entries_opaquely() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.encrypted", "data": "gAAAAAopaque", "index": 0}, - {"type": "reasoning.summary", "summary": "visible", "index": 1}, - ] - }))) - .await; - - let opaque = response - .message - .content - .iter() - .find_map(|part| match part { - fabro_llm::types::ContentPart::Other { kind, data } - if kind == fabro_llm::types::ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { - Some(data) - } - _ => None, - }) - .expect("opaque reasoning details preserved"); - assert_eq!(opaque[0]["data"], "gAAAAAopaque"); - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("visible")); - assert!(reasoning.trace().is_none()); -} - -/// Complete-response details are already assembled and must retain their -/// received block boundaries. -#[tokio::test] -async fn decode_reasoning_details_preserves_complete_entries_verbatim() { - let details = serde_json::json!([ - {"type": "reasoning.summary", "summary": "first"}, - {"type": "reasoning.summary", "summary": "second"}, - ]); - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": details, - }))) - .await; - - let opaque = response - .message - .content - .iter() - .find_map(|part| match part { - fabro_llm::types::ContentPart::Other { kind, data } - if kind == fabro_llm::types::ContentPart::OPENAI_COMPAT_REASONING_DETAILS => - { - Some(data) - } - _ => None, - }) - .expect("opaque reasoning details preserved"); - assert_eq!(opaque, &details); - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("first\n\nsecond")); -} - -/// Unknown and malformed detail entries must not fail an otherwise valid -/// completion. -#[tokio::test] -async fn decode_tolerates_unknown_and_malformed_reasoning_details() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.future", "text": "new channel", "extra": {"nested": true}}, - {"type": "reasoning.summary", "summary": 7}, - "not-an-object", - 42, - ] - }))) - .await; - - assert_eq!(response.text(), "4."); - assert!(response.reasoning_output().is_none()); -} - -/// A scalar `reasoning_details` carries nothing replayable and is dropped -/// without disturbing the rest of the response. -#[tokio::test] -async fn decode_ignores_scalar_reasoning_details() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": "unexpected" - }))) - .await; - - assert_eq!(response.text(), "4."); - assert!(response.reasoning_output().is_none()); -} - -/// OpenRouter returns both the structured channel and a flattened copy of -/// the same material; the summary must not appear twice. -#[tokio::test] -async fn decode_structured_details_suppress_the_duplicate_flattened_value() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "the user wants 2+2", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert!(reasoning.trace().is_none()); -} - -/// A structured trace takes precedence over the flattened trace channel. -#[tokio::test] -async fn decode_structured_trace_takes_precedence_over_flattened_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "flattened trace", - "reasoning_details": [{"type": "reasoning.text", "text": "verbatim trace", "index": 0}] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert!(reasoning.summary().is_none()); - assert_eq!(reasoning.trace(), Some("verbatim trace")); -} - -/// A structured summary and distinct flattened trace are both retained. -#[tokio::test] -async fn decode_structured_summary_keeps_distinct_flattened_trace() { - let response = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning": "full verbatim trace", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "short summary", "index": 0}, - ] - }))) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("short summary")); - assert_eq!(reasoning.trace(), Some("full verbatim trace")); -} - -/// Streamed detail fragments coalesce back into the same normalized output -/// the non-streaming body produces. -#[tokio::test] -async fn stream_reasoning_details_normalize_like_the_non_streaming_body() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_details":[{"type":"reasoning.summary","summary":"the user ","index":0},{"type":"reasoning.text","text":"2 plus ","index":1}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"reasoning.summary","summary":"wants 2+2","index":0},{"type":"reasoning.text","text":"2 is 4","index":1}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"4."},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - let streamed = stream_final_response(&sse).await; - - let non_streamed = decode_response(body_with_message(&serde_json::json!({ - "role": "assistant", - "content": "4.", - "reasoning_details": [ - {"type": "reasoning.summary", "summary": "the user wants 2+2", "index": 0}, - {"type": "reasoning.text", "text": "2 plus 2 is 4", "index": 1}, - ] - }))) - .await; - - assert_eq!(streamed.reasoning_output(), non_streamed.reasoning_output()); - let reasoning = streamed.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("the user wants 2+2")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4")); -} - -/// Providers may omit the optional index after the first fragment; the type -/// still identifies the logical detail being continued. -#[tokio::test] -async fn stream_reasoning_details_coalesce_when_a_later_fragment_omits_index() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_details":[{"type":"reasoning.text","text":"first ","index":0}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_details":[{"type":"reasoning.text","text":"second"}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"done"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - - let response = stream_final_response(&sse).await; - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.trace(), Some("first second")); -} - -/// Cached and reasoning detail tokens are split into their own disjoint -/// buckets and subtracted out of input/output. -#[tokio::test] -async fn decode_usage_parses_token_details() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "length" - }], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150, - "prompt_tokens_details": {"cached_tokens": 80}, - "completion_tokens_details": {"reasoning_tokens": 20} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// OpenRouter usage superset: in-band `cost` becomes an authoritative -/// `cost_usd`, and `cache_write_tokens` lands in its own disjoint bucket. -/// Unmodeled fields (`cost_details`, `audio_tokens`, top-level `provider`, -/// `native_finish_reason`) are tolerated and ignored. -#[tokio::test] -async fn decode_usage_openrouter_cost_and_cache_write() { - let response = decode_response(serde_json::json!({ - "id": "gen_or_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "provider": "Anthropic", - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "stop", - "native_finish_reason": "end_turn" - }], - "usage": { - "prompt_tokens": 200, - "completion_tokens": 10, - "total_tokens": 210, - "cost": 0.0042, - "cost_details": {"upstream_inference_cost": null}, - "prompt_tokens_details": {"cached_tokens": 50, "cache_write_tokens": 100, "audio_tokens": 0}, - "completion_tokens_details": {"reasoning_tokens": 0} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// Venice reports authoritative USD cost in a top-level object rather than -/// the OpenRouter `usage.cost` field. -#[tokio::test] -async fn decode_usage_venice_top_level_cost() { - let response = decode_response(serde_json::json!({ - "id": "chatcmpl_venice_test", - "object": "chat.completion", - "created": CREATED_TS, - "model": MODEL, - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "stop" - }], - "cost": {"usd": 0.00042, "diem": 0.0}, - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 14 - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}"#, - "[DONE]", - ]); - stream_capture(&base_request(MODEL), &sse).await -} - -/// The captured request pins the streaming request shape, including the usage -/// opt-in required for the trailing usage chunk. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -/// OpenRouter streams report `cost` in the usage chunk; the Finish response -/// carries it as authoritative, with cached tokens in their own bucket. -#[tokio::test] -async fn stream_usage_openrouter_cost() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}"#, - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"gen_or_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14,"cost":0.00031,"prompt_tokens_details":{"cached_tokens":4,"cache_write_tokens":0}}}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Venice streams authoritative USD cost in a top-level object on the usage -/// chunk. -#[tokio::test] -async fn stream_usage_venice_top_level_cost() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"cost":{"usd":0.00031,"diem":0.0},"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14}}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"qu"}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ery\":\"foo\"}"}}]},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}}"#, - "[DONE]", - ]); - let (_capture, events) = - stream_capture(&corpus_tools(MODEL, Some(ToolChoice::Auto)), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_reasoning_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Let me "},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"reasoning_content":"think"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"content":"4."},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - "[DONE]", - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// Minimax tolerance: a stream that ends without `[DONE]` still synthesizes -/// the finish — but only because content was started. -#[tokio::test] -async fn stream_without_done_synthesizes_finish_when_content_started() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// The other half of the minimax contract: no content started and no -/// `[DONE]` — nothing is synthesized. `StreamStart` is not synthesis: the -/// provider did send a chunk, so the liveness edge is a fact about this -/// stream even though nothing usable followed. -#[tokio::test] -async fn stream_without_done_or_content_synthesizes_nothing() { - let sse = support::sse_data_transcript(&[ - r#"{"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}"#, - ]); - let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// The compat adapter already stamped the configured name; pinned here to -/// complete the per-dialect identity matrix. -#[tokio::test] -async fn custom_named_complete_identity() { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/chat/completions", minimal_body()); - let adapter = adapter(&server).with_name("moonshot"); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - assert_eq!(response.provider, "moonshot"); -} diff --git a/lib/components/fabro-llm/tests/it/wire/openai_responses.rs b/lib/components/fabro-llm/tests/it/wire/openai_responses.rs deleted file mode 100644 index 079a05455..000000000 --- a/lib/components/fabro-llm/tests/it/wire/openai_responses.rs +++ /dev/null @@ -1,655 +0,0 @@ -//! Wire snapshots for the OpenAI Responses API dialect (`POST /responses`). - -use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::OpenAiAdapter; -use fabro_llm::types::{ - ContentPart, Message, Request, ResponseFormat, ResponseFormatType, Role, ToolCall, ToolChoice, - ToolDefinition, -}; -use httpmock::prelude::*; - -use crate::support::{ - self, WireCapture, base_request, corpus_audio_attachment, corpus_bad_file_path_attachments, - corpus_inline_attachments, corpus_multi_turn, corpus_provider_options, corpus_response_format, - corpus_sampling_params, corpus_thinking_round_trip, corpus_tool_round_trip, corpus_tools, - corpus_url_attachments, json_schema_format, mount_capture, mount_capture_sse, take_capture, -}; - -const MODEL: &str = "gpt-test"; - -/// Minimal valid Responses API body for encode-side tests. -fn minimal_body() -> serde_json::Value { - serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "ok"}] - }], - "usage": {"input_tokens": 1, "output_tokens": 1} - }) -} - -fn adapter() -> OpenAiAdapter { - OpenAiAdapter::new("test-key") -} - -/// Runs `complete()` against a capture mock and returns the captured wire -/// request. -async fn encode_capture(adapter: OpenAiAdapter, request: &Request) -> WireCapture { - let server = MockServer::start(); - let (mock, slot) = mount_capture(&server, "/responses", minimal_body()); - let adapter = adapter.with_base_url(server.base_url()); - adapter - .complete(request) - .await - .expect("complete should succeed"); - mock.assert(); - take_capture(&slot) -} - -/// Runs `stream()` against an SSE transcript and returns the captured wire -/// request plus every emitted stream item as JSON. -async fn stream_capture( - adapter: OpenAiAdapter, - request: &Request, - sse_body: &str, -) -> (WireCapture, Vec) { - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/responses", sse_body); - let adapter = adapter.with_base_url(server.base_url()); - let events = support::collect_stream_events(&adapter, request).await; - mock.assert(); - (take_capture(&slot), events) -} - -// --------------------------------------------------------------------------- -// Round trip (encode + decode) -// --------------------------------------------------------------------------- - -/// Shared setup for the system+tools round trip; the encode and decode halves -/// are pinned by separate tests. -async fn system_and_tools_roundtrip() -> (WireCapture, fabro_llm::types::Response) { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/responses", - serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "Hello back"}] - }], - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "input_tokens_details": {"cached_tokens": 10}, - "output_tokens_details": {"reasoning_tokens": 3} - } - }), - ); - - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - tools: Some(vec![ToolDefinition::function( - "search", - "Search files", - serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}), - )]), - temperature: Some(0.5), - ..base_request(MODEL) - }; - - let response = adapter - .complete(&request) - .await - .expect("complete should succeed"); - mock.assert(); - (take_capture(&slot), response) -} - -#[tokio::test] -async fn system_and_tools_encode() { - let (capture, _) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(capture); -} - -#[tokio::test] -async fn system_and_tools_decode() { - let (_, response) = system_and_tools_roundtrip().await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Encode -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn encode_multi_turn() { - let capture = encode_capture(adapter(), &corpus_multi_turn(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_auto() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Auto))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_required() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::Required))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_named() { - let capture = encode_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::named("search"))), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_choice_none() { - let capture = encode_capture(adapter(), &corpus_tools(MODEL, Some(ToolChoice::None))).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_tool_round_trip() { - let capture = encode_capture(adapter(), &corpus_tool_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// A tool call that decoded with an item-level id (`fc_…`) in -/// provider_metadata re-encodes with the dual ids split correctly. -#[tokio::test] -async fn encode_dual_id_tool_round_trip() { - let mut tool_call = ToolCall::new("call_abc", "search", serde_json::json!({"query": "foo"})); - tool_call.provider_metadata = Some(serde_json::json!({"id": "fc_123"})); - let mut request = corpus_tools(MODEL, None); - request.messages = vec![ - Message::user("Find foo"), - Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(tool_call)], - name: None, - tool_call_id: None, - }, - Message::tool_result( - "call_abc", - serde_json::Value::String("2 matches".to_string()), - false, - ), - ]; - let capture = encode_capture(adapter(), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Opaque OpenAI items (reasoning / message) round-trip verbatim into the -/// input array. -#[tokio::test] -async fn encode_opaque_items_round_trip() { - let request = Request { - messages: vec![ - Message::user("Think about 2+2."), - Message { - role: Role::Assistant, - content: vec![ - ContentPart::Other { - kind: ContentPart::OPENAI_REASONING.to_string(), - data: serde_json::json!({ - "type": "reasoning", - "id": "rs_1", - "summary": [{"type": "summary_text", "text": "Adding."}] - }), - }, - ContentPart::Other { - kind: ContentPart::OPENAI_MESSAGE.to_string(), - data: serde_json::json!({ - "type": "message", - "role": "assistant", - "id": "msg_1", - "content": [{"type": "output_text", "text": "4."}] - }), - }, - ], - name: None, - tool_call_id: None, - }, - Message::user("Now 3+3?"), - ], - ..base_request(MODEL) - }; - let capture = encode_capture(adapter(), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Canonical Thinking parts (anthropic-style) — distinct from the opaque -/// reasoning round-trip above. -#[tokio::test] -async fn encode_thinking_round_trip() { - let capture = encode_capture(adapter(), &corpus_thinking_round_trip(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_inline_attachments() { - let capture = encode_capture(adapter(), &corpus_inline_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_url_attachments() { - let capture = encode_capture(adapter(), &corpus_url_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_bad_file_path_attachments_dropped() { - let capture = encode_capture(adapter(), &corpus_bad_file_path_attachments(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_audio_attachment() { - let capture = encode_capture(adapter(), &corpus_audio_attachment(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_object() { - let format = ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }; - let capture = encode_capture(adapter(), &corpus_response_format(MODEL, format)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_response_format_json_schema() { - let capture = encode_capture( - adapter(), - &corpus_response_format(MODEL, json_schema_format()), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_sampling_params() { - let capture = encode_capture(adapter(), &corpus_sampling_params(MODEL)).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_provider_options_openai_namespace() { - let capture = encode_capture( - adapter(), - &corpus_provider_options(MODEL, serde_json::json!({"openai": {"seed": 42}})), - ) - .await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn encode_reasoning_effort_with_levels_catalog() { - let catalog = support::catalog_from_toml( - r#" -[providers.openai] -display_name = "OpenAI" -adapter = "openai" -agent_profile = "openai" - -[models."test-gpt"] -provider = "openai" -display_name = "Test GPT" -family = "gpt" -default = true - -[models."test-gpt".limits] -context_window = 200000 -max_output = 4096 - -[models."test-gpt".features] -tools = true -vision = true -reasoning = true -reasoning_effort = "levels" -"#, - ); - let request = Request { - reasoning_effort: Some(fabro_llm::types::ReasoningEffort::High), - ..base_request("test-gpt") - }; - let capture = encode_capture(adapter().with_catalog(catalog), &request).await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -/// Codex mode forces streaming for `complete()` and omits sampling params -/// from the encoded body. -#[tokio::test] -async fn encode_codex_mode_forces_streaming_and_omits_params() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_codex","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"ok"}"#, - r#"{"type":"response.completed","response":{"id":"resp_codex","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":2}}}"#, - ]); - let server = MockServer::start(); - let (mock, slot) = mount_capture_sse(&server, "/responses", &sse); - let adapter = OpenAiAdapter::new("test-key") - .with_codex_mode() - .with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - temperature: Some(0.5), - top_p: Some(0.9), - ..base_request(MODEL) - }; - adapter.complete(&request).await.unwrap(); - mock.assert(); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -#[tokio::test] -async fn count_tokens_wire_shape() { - let server = MockServer::start(); - let (mock, slot) = mount_capture( - &server, - "/responses/input_tokens", - serde_json::json!({"input_tokens": 123, "object": "response.input_tokens"}), - ); - let adapter = adapter().with_base_url(server.base_url()); - let request = Request { - messages: vec![Message::system("Be concise"), Message::user("Hello")], - ..corpus_tools(MODEL, None) - }; - let count = adapter - .count_input_tokens(&request) - .await - .unwrap() - .expect("openai should count tokens"); - - mock.assert(); - assert_eq!(count.input_tokens, 123); - fabro_test::fabro_json_snapshot!(take_capture(&slot)); -} - -// --------------------------------------------------------------------------- -// Decode -// --------------------------------------------------------------------------- - -async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/responses", body); - let adapter = adapter().with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - response -} - -/// The Responses usage arithmetic: cached tokens are subtracted from input, -/// reasoning tokens from output. -#[tokio::test] -async fn decode_usage_subtracts_cached_and_reasoning() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "ok"}] - }], - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "input_tokens_details": {"cached_tokens": 80}, - "output_tokens_details": {"reasoning_tokens": 20} - } - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// Reasoning and function_call output items: reasoning becomes an opaque -/// round-trip part, function_call splits dual ids into id + metadata. -#[tokio::test] -async fn decode_reasoning_and_function_call_items() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "summary": [{"type": "summary_text", "text": "Searching."}] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - ], - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -/// A reasoning item carrying both readable channels normalizes into both -/// fields while its encrypted payload stays opaque. -#[tokio::test] -async fn decode_reasoning_item_normalizes_summary_and_trace() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "encrypted_content": "gAAAAAopaque", - "summary": [{"type": "summary_text", "text": "Adding two numbers."}], - "content": [{"type": "reasoning_text", "text": "2 plus 2 is 4."}] - }, - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "4."}] - } - ], - "usage": {"input_tokens": 30, "output_tokens": 12} - })) - .await; - - let reasoning = response.reasoning_output().expect("reasoning present"); - assert_eq!(reasoning.summary(), Some("Adding two numbers.")); - assert_eq!(reasoning.trace(), Some("2 plus 2 is 4.")); - - let normalized = serde_json::to_string(&reasoning).unwrap(); - assert!(!normalized.contains("gAAAAAopaque")); -} - -#[tokio::test] -async fn decode_incomplete_status_maps_to_length() { - let response = decode_response(serde_json::json!({ - "id": "resp_test", - "object": "response", - "model": MODEL, - "status": "incomplete", - "output": [{ - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [{"type": "output_text", "text": "Truncated"}] - }], - "usage": {"input_tokens": 10, "output_tokens": 128} - })) - .await; - fabro_test::fabro_json_snapshot!(response); -} - -// --------------------------------------------------------------------------- -// Stream -// --------------------------------------------------------------------------- - -/// Shared setup for the happy-path text stream; the request and event halves -/// are pinned by separate tests. -async fn stream_text_happy_path_capture() -> (WireCapture, Vec) { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"Hel"}"#, - r#"{"type":"response.output_text.delta","delta":"lo"}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":11,"output_tokens":5,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}}}"#, - ]); - stream_capture(adapter(), &base_request(MODEL), &sse).await -} - -/// The captured request pins the stream flag (and `include`) on the wire. -#[tokio::test] -async fn stream_text_happy_path_request() { - let (capture, _) = stream_text_happy_path_capture().await; - fabro_test::fabro_json_snapshot!(capture.body); -} - -#[tokio::test] -async fn stream_text_happy_path_events() { - let (_, events) = stream_text_happy_path_capture().await; - support::assert_stream_starts(&events); - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_first_frame_error_still_opens_with_stream_start() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - - support::assert_stream_starts(&events); - assert!( - events - .get(1) - .and_then(|event| event.get("stream_item_error")) - .is_some(), - "the decoder error should follow stream_start: {events:?}" - ); -} - -#[tokio::test] -async fn stream_tool_call_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","name":"search","delta":"{\"qu"}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","call_id":"call_abc","delta":"ery\":\"foo\"}"}"#, - r#"{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_123","call_id":"call_abc","name":"search","arguments":"{\"query\":\"foo\"}"}}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":20,"output_tokens":9}}}"#, - ]); - let (_capture, events) = stream_capture( - adapter(), - &corpus_tools(MODEL, Some(ToolChoice::Auto)), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_reasoning_summary_deltas() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.reasoning_summary_text.delta","delta":"Let me "}"#, - r#"{"type":"response.reasoning_summary_text.delta","delta":"think"}"#, - r#"{"type":"response.output_text.delta","delta":"4."}"#, - r#"{"type":"response.completed","response":{"id":"resp_stream","model":"gpt-test","status":"completed","output":[],"usage":{"input_tokens":15,"output_tokens":12,"output_tokens_details":{"reasoning_tokens":8}}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -#[tokio::test] -async fn stream_failed_event_maps_to_error() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -/// `response.incomplete` finishes the stream with `Length`. -#[tokio::test] -async fn stream_incomplete_maps_to_length() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.output_text.delta","delta":"Trunc"}"#, - r#"{"type":"response.incomplete","response":{"id":"resp_stream","model":"gpt-test","status":"incomplete","output":[],"usage":{"input_tokens":10,"output_tokens":128}}}"#, - ]); - let (_capture, events) = stream_capture(adapter(), &base_request(MODEL), &sse).await; - fabro_test::fabro_json_snapshot!(events); -} - -// --------------------------------------------------------------------------- -// Custom-named route identity -// --------------------------------------------------------------------------- - -/// Non-stream responses stamp the configured provider name (previously -/// hardcoded "openai"). -#[tokio::test] -async fn custom_named_complete_identity() { - let server = MockServer::start(); - let (mock, _slot) = mount_capture(&server, "/responses", minimal_body()); - let adapter = OpenAiAdapter::new("test-key") - .with_name("openai-proxy") - .with_base_url(server.base_url()); - let response = adapter - .complete(&base_request(MODEL)) - .await - .expect("complete should succeed"); - mock.assert(); - assert_eq!(response.provider, "openai-proxy"); -} - -/// Stream failure events carry the configured name in the error detail -/// (normalize-both decision). -#[tokio::test] -async fn custom_named_stream_failed_event_identity() { - let sse = support::sse_data_transcript(&[ - r#"{"type":"response.created","response":{"id":"resp_stream","model":"gpt-test"}}"#, - r#"{"type":"response.failed","response":{"id":"resp_stream","error":{"code":"server_error","message":"boom"}}}"#, - ]); - let (_capture, events) = stream_capture( - adapter().with_name("openai-proxy"), - &base_request(MODEL), - &sse, - ) - .await; - fabro_test::fabro_json_snapshot!(events); -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap deleted file mode 100644 index c3b630136..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__count_tokens_wire_shape.snap +++ /dev/null @@ -1,78 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages/count_tokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "412" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "system": "Be concise", - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap deleted file mode 100644 index d9e90487b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_error_identity.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from moonshot: Overloaded", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap deleted file mode 100644 index 83360e789..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_identity.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_kimi", - "model": "kimi-test", - "provider": "moonshot", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap deleted file mode 100644 index 4620d7897..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__custom_named_stream_route.snap +++ /dev/null @@ -1,47 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "144" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "kimi-test", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "stream": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap deleted file mode 100644 index b10bff658..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_max_tokens_stop_reason.snap +++ /dev/null @@ -1,46 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Truncated answe" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Truncated answe" - } - ], - "stop_reason": "max_tokens", - "stop_sequence": null, - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap deleted file mode 100644 index bf89303f3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_thinking_and_redacted_thinking.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Step one.", - "signature": "sig_decode_abc", - "redacted": false - } - }, - { - "kind": "redacted_thinking", - "data": { - "text": "opaque-blob", - "signature": null, - "redacted": true - } - }, - { - "kind": "text", - "data": "Done." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "thinking", - "thinking": "Step one.", - "signature": "sig_decode_abc" - }, - { - "type": "redacted_thinking", - "data": "opaque-blob" - }, - { - "type": "text", - "text": "Done." - } - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 25, - "output_tokens": 40 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap deleted file mode 100644 index 2c1ec7d19..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__decode_tool_use_stop_reason.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Let me search." - }, - { - "kind": "tool_call", - "data": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Let me search." - }, - { - "type": "tool_use", - "id": "toolu_01", - "name": "search", - "input": { - "query": "foo" - } - } - ], - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { - "input_tokens": 30, - "output_tokens": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap deleted file mode 100644 index 54793a2b6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_audio_attachment.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Transcribe this." - }, - { - "type": "text", - "text": "[Audio content not supported by this provider]" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index c2c0acfb3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap deleted file mode 100644 index 5fda9d667..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_inline_attachments.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - }, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "ZmFrZS1wbmctYnl0ZXM=" - } - }, - { - "type": "document", - "source": { - "type": "base64", - "media_type": "application/pdf", - "data": "ZmFrZS1wZGYtYnl0ZXM=" - } - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap deleted file mode 100644 index 6042822b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_multi_turn.snap +++ /dev/null @@ -1,69 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "340" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "What is the capital of France?" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Paris." - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "And of Spain?" - } - ] - } - ], - "max_tokens": 128, - "system": "You are a terse assistant.", - "stop_sequences": [] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap deleted file mode 100644 index 5a6979bd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_prompt_cache_with_catalog.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-beta", - "prompt-caching-2024-07-31" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "559" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "test-claude", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Review this." - } - ] - } - ], - "max_tokens": 128, - "system": [ - { - "type": "text", - "text": "You are a careful reviewer.", - "cache_control": { - "type": "ephemeral" - } - } - ], - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - }, - "cache_control": { - "type": "ephemeral" - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap deleted file mode 100644 index 5c5107443..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_provider_options_anthropic_namespace.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "top_k": 5 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 45d4779ed..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "test-claude", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "thinking": { - "type": "adaptive" - }, - "output_config": { - "effort": "high" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap deleted file mode 100644 index f505a20d9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_object.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "system": "You must respond with valid JSON only, no other text.", - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap deleted file mode 100644 index b8c0aa6a1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_response_format_json_schema.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "json_output", - "description": "Output the requested structured data", - "input_schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - ], - "tool_choice": { - "type": "tool", - "name": "json_output" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap deleted file mode 100644 index c4abd0eda..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_sampling_params.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "temperature": 0.7, - "top_p": 0.9, - "stop_sequences": [ - "END" - ], - "metadata": { - "trace_id": "trace-123" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap deleted file mode 100644 index 1619de37c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_thinking_round_trip.snap +++ /dev/null @@ -1,43 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "The user wants 2+2, which is 4.", - "signature": "sig_test_abc123" - }, - { - "type": "text", - "text": "4." - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Now 3+3?" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap deleted file mode 100644 index 552c709ce..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_auto.snap +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "auto" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap deleted file mode 100644 index 868f115d1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_named.snap +++ /dev/null @@ -1,53 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "tool", - "name": "search" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap deleted file mode 100644 index b9119a960..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_none.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap deleted file mode 100644 index 35eb0a281..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_choice_required.snap +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "any" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap deleted file mode 100644 index 50cce34a4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_tool_round_trip.snap +++ /dev/null @@ -1,91 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "Let me check." - }, - { - "type": "tool_use", - "id": "call_1", - "name": "search", - "input": { - "query": "foo" - } - }, - { - "type": "tool_use", - "id": "call_2", - "name": "read_file", - "input": { - "path": "/tmp/x" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "call_1", - "content": "{\"matches\":2}", - "is_error": false - }, - { - "type": "tool_result", - "tool_use_id": "call_2", - "content": "file not found", - "is_error": true - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "input_schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap deleted file mode 100644 index 438fbc222..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__encode_url_attachments.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe these attachments." - }, - { - "type": "image", - "source": { - "type": "url", - "url": "https://example.com/picture.png" - } - }, - { - "type": "document", - "source": { - "type": "url", - "url": "https://example.com/report.pdf" - } - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap deleted file mode 100644 index 6c8cc6f1a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_error_event_mid_stream.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from anthropic: Overloaded", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap deleted file mode 100644 index 04d15c0b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 2, - "cache_write_tokens": 1 - }, - "response": { - "id": "msg_stream_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 2, - "cache_write_tokens": 1 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap deleted file mode 100644 index 508c9c998..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_text_happy_path_request.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "stop_sequences": [], - "stream": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap deleted file mode 100644 index 68e599c83..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_thinking_with_signature_delta.snap +++ /dev/null @@ -1,76 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me think" - }, - { - "type": "reasoning_end" - }, - { - "type": "text_start", - "text_id": "block_1" - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": "block_1" - }, - { - "type": "text_end", - "text_id": "block_1" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_stream_think", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": "sig_stream_xyz", - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap deleted file mode 100644 index 18c46f075..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_tool_call_deltas.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": "{\"qu", - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": "ery\":\"foo\"}", - "raw_arguments": null - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "msg_stream_tool", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "toolu_01", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap deleted file mode 100644 index d2f4d0df0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__stream_without_message_stop_emits_no_finish.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "block_0" - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": "block_0" - }, - { - "type": "text_end", - "text_id": "block_0" - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap deleted file mode 100644 index f4afb357d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_decode.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "id": "msg_test", - "model": "claude-sonnet-4-20250514", - "provider": "anthropic", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 10, - "cache_write_tokens": 3 - }, - "raw": { - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-20250514", - "content": [ - { - "type": "text", - "text": "Hello back" - } - ], - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "cache_read_input_tokens": 10, - "cache_creation_input_tokens": 3 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap deleted file mode 100644 index 50f5a4167..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__anthropic__system_and_tools_encode.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/anthropic.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/messages", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "anthropic-version", - "2023-06-01" - ], - [ - "content-length", - "316" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-api-key", - "test-key" - ] - ], - "body": { - "model": "claude-sonnet-4-20250514", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Hello" - } - ] - } - ], - "max_tokens": 128, - "system": "Be concise", - "temperature": 0.5, - "stop_sequences": [], - "tools": [ - { - "name": "search", - "description": "Search files", - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap deleted file mode 100644 index 2d75a657c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__count_tokens_wire_shape.snap +++ /dev/null @@ -1,93 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:countTokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "583" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "generateContentRequest": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Be concise" - } - ] - }, - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap deleted file mode 100644 index 3f983d663..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_http_error_identity.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "error": "Server error from gemini-proxy: backend exploded", - "retryable": true, - "failover_eligible": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap deleted file mode 100644 index 121057409..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__custom_named_stream_identity.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini-proxy", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 5, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap deleted file mode 100644 index 751e5782b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_function_call_with_thought_signature.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Let me search." - }, - { - "kind": "tool_call", - "data": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_gemini_xyz" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Let me search." - }, - { - "functionCall": { - "name": "search", - "args": { - "query": "foo" - } - }, - "thoughtSignature": "sig_gemini_xyz" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 30, - "candidatesTokenCount": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap deleted file mode 100644 index a8a887691..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_max_tokens_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"length" diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap deleted file mode 100644 index 627e69083..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_safety_finish_reason.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -"content_filter" diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap deleted file mode 100644 index b20aec438..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_thought_parts.snap +++ /dev/null @@ -1,59 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Adding the numbers.", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Adding the numbers.", - "thought": true - }, - { - "text": "4." - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 25, - "candidatesTokenCount": 40 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap deleted file mode 100644 index d32b27d72..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__decode_usage_arithmetic.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 75, - "output_tokens": 50, - "reasoning_tokens": 8, - "cache_read_tokens": 30, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "ok" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 100, - "candidatesTokenCount": 50, - "thoughtsTokenCount": 8, - "cachedContentTokenCount": 30, - "toolUsePromptTokenCount": 5 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap deleted file mode 100644 index 03246da8b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_audio_attachment.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Transcribe this." - }, - { - "inlineData": { - "mimeType": "audio/wav", - "data": "ZmFrZS13YXYtYnl0ZXM=" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index a27b54528..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap deleted file mode 100644 index 2db4f67e3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_inline_attachments.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - }, - { - "inlineData": { - "mimeType": "image/png", - "data": "ZmFrZS1wbmctYnl0ZXM=" - } - }, - { - "inlineData": { - "mimeType": "application/pdf", - "data": "ZmFrZS1wZGYtYnl0ZXM=" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap deleted file mode 100644 index a8a547cd6..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_multi_turn.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "What is the capital of France?" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Paris." - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "And of Spain?" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "You are a terse assistant." - } - ] - }, - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap deleted file mode 100644 index 6e2744c76..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_can_override_safety_settings.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap deleted file mode 100644 index 71baf9f7a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_provider_options_gemini_namespace.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "cached_content": "cachedContents/abc", - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 2e71f5623..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap deleted file mode 100644 index e04165dfd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_object.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128, - "responseMimeType": "application/json" - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap deleted file mode 100644 index e8fc16720..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_response_format_json_schema.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128, - "responseMimeType": "application/json", - "responseSchema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap deleted file mode 100644 index 87e6756df..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_sampling_params.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "temperature": 0.7, - "maxOutputTokens": 128, - "topP": 0.9, - "stopSequences": [ - "END" - ] - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap deleted file mode 100644 index 20f0d4724..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_thinking_round_trip.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "4." - } - ] - }, - { - "role": "user", - "parts": [ - { - "text": "Now 3+3?" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap deleted file mode 100644 index 9682d4bd0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_auto.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "AUTO" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap deleted file mode 100644 index ebab86778..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_named.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY", - "allowedFunctionNames": [ - "search" - ] - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap deleted file mode 100644 index 9e7fe12ce..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_none.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "NONE" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap deleted file mode 100644 index 0fee7b81d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_choice_required.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "toolConfig": { - "functionCallingConfig": { - "mode": "ANY" - } - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap deleted file mode 100644 index 245c3cd85..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_tool_round_trip.snap +++ /dev/null @@ -1,108 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "role": "model", - "parts": [ - { - "text": "Let me check." - }, - { - "functionCall": { - "name": "search", - "args": { - "query": "foo" - } - } - }, - { - "functionCall": { - "name": "read_file", - "args": { - "path": "/tmp/x" - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "name": "search", - "response": { - "matches": 2 - } - } - } - ] - }, - { - "role": "user", - "parts": [ - { - "functionResponse": { - "name": "read_file", - "response": { - "result": "file not found" - } - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap deleted file mode 100644 index c3f9bdee7..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__encode_url_attachments.snap +++ /dev/null @@ -1,37 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Describe these attachments." - }, - { - "fileData": { - "mimeType": "image/png", - "fileUri": "https://example.com/picture.png" - } - }, - { - "fileData": { - "mimeType": "application/pdf", - "fileUri": "https://example.com/report.pdf" - } - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap deleted file mode 100644 index 5ceb7e1d0..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_end_synthesizes_finish_without_finish_reason.snap +++ /dev/null @@ -1,54 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap deleted file mode 100644 index 22807bad4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_function_call.snap +++ /dev/null @@ -1,86 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "[UUID]", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": null, - "provider_metadata": { - "thoughtSignature": "sig_stream_g" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap deleted file mode 100644 index f240b1c76..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap deleted file mode 100644 index 90edf2a99..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_text_happy_path_request.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:streamGenerateContent?alt=sse", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "197" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "generationConfig": { - "maxOutputTokens": 128 - }, - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap deleted file mode 100644 index bf9e067fd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__stream_thought_parts.snap +++ /dev/null @@ -1,76 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me think" - }, - { - "type": "reasoning_end" - }, - { - "type": "text_start", - "text_id": "[UUID]" - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": "[UUID]" - }, - { - "type": "text_end", - "text_id": "[UUID]" - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 6, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "reasoning_tokens": 6, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap deleted file mode 100644 index f5e0bd096..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_decode.snap +++ /dev/null @@ -1,48 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "id": "[UUID]", - "model": "gemini-test", - "provider": "gemini", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 32, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 10, - "cache_write_tokens": 0 - }, - "raw": { - "candidates": [ - { - "content": { - "role": "model", - "parts": [ - { - "text": "Hello back" - } - ] - }, - "finishReason": "STOP" - } - ], - "usageMetadata": { - "promptTokenCount": 42, - "candidatesTokenCount": 7, - "cachedContentTokenCount": 10 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap deleted file mode 100644 index 2a445751f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__gemini__system_and_tools_encode.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/gemini.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/models/gemini-test:generateContent", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "content-length", - "425" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ], - [ - "x-goog-api-key", - "test-key" - ] - ], - "body": { - "contents": [ - { - "role": "user", - "parts": [ - { - "text": "Hello" - } - ] - } - ], - "systemInstruction": { - "parts": [ - { - "text": "Be concise" - } - ] - }, - "generationConfig": { - "temperature": 0.5, - "maxOutputTokens": 128 - }, - "tools": [ - { - "functionDeclarations": [ - { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ] - } - ], - "safety_settings": [ - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_ONLY_HIGH" - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap deleted file mode 100644 index bd1327b8f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_reasoning_content_as_thinking.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "The user wants 2+2.", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 25, - "output_tokens": 40, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 25, - "completion_tokens": 40, - "total_tokens": 65 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap deleted file mode 100644 index 0d52c2d73..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_tool_calls_with_string_arguments.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [ - { - "id": "call_abc", - "type": "function", - "function": { - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls" - } - ], - "usage": { - "prompt_tokens": 30, - "completion_tokens": 12, - "total_tokens": 42 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap deleted file mode 100644 index cff1eaf40..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_openrouter_cost_and_cache_write.snap +++ /dev/null @@ -1,65 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "gen_or_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 50, - "output_tokens": 10, - "reasoning_tokens": 0, - "cache_read_tokens": 50, - "cache_write_tokens": 100 - }, - "raw": { - "id": "gen_or_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "provider": "Anthropic", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "stop", - "native_finish_reason": "end_turn" - } - ], - "usage": { - "prompt_tokens": 200, - "completion_tokens": 10, - "total_tokens": 210, - "cost": 0.0042, - "cost_details": { - "upstream_inference_cost": null - }, - "prompt_tokens_details": { - "cached_tokens": 50, - "cache_write_tokens": 100, - "audio_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - } - }, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.0042, - "cost_source": "authoritative" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap deleted file mode 100644 index 8e2239f87..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_parses_token_details.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 20, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "length" - } - ], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150, - "prompt_tokens_details": { - "cached_tokens": 80 - }, - "completion_tokens_details": { - "reasoning_tokens": 20 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap deleted file mode 100644 index 7236fc7c8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__decode_usage_venice_top_level_cost.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_venice_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_venice_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "ok" - }, - "finish_reason": "stop" - } - ], - "cost": { - "usd": 0.00042, - "diem": 0.0 - }, - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 14 - } - }, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00042, - "cost_source": "authoritative" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap deleted file mode 100644 index 101179520..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_audio_attachment.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Transcribe this.[Audio content not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap deleted file mode 100644 index 485ae8446..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_bad_file_path_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'missing.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap deleted file mode 100644 index 10995e1e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_inline_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap deleted file mode 100644 index cbb42e14d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_multi_turn.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "You are a terse assistant." - }, - { - "role": "user", - "content": "What is the capital of France?" - }, - { - "role": "assistant", - "content": "Paris." - }, - { - "role": "user", - "content": "And of Spain?" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap deleted file mode 100644 index 75a4a2634..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_keyed_by_adapter_name.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "repetition_penalty": 1.2 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap deleted file mode 100644 index f3e189bfe..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_provider_options_other_namespace_ignored.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap deleted file mode 100644 index 24763c82f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_object.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "response_format": { - "type": "json_object" - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap deleted file mode 100644 index 7e35babc2..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_response_format_json_schema.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "response", - "strict": true, - "schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap deleted file mode 100644 index 74e26fcd1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_sampling_params.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "temperature": 0.7, - "max_tokens": 128, - "top_p": 0.9, - "stop": [ - "END" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap deleted file mode 100644 index 86c83a213..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_thinking_round_trip_as_reasoning_content.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Think step by step: what is 2+2?" - }, - { - "role": "assistant", - "content": "4.", - "reasoning_content": "The user wants 2+2, which is 4." - }, - { - "role": "user", - "content": "Now 3+3?" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap deleted file mode 100644 index ea913ebfb..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_auto.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "auto" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap deleted file mode 100644 index 442e8a42d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_named.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": { - "type": "function", - "function": { - "name": "search" - } - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap deleted file mode 100644 index 919c82db5..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_none.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "none" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap deleted file mode 100644 index 665fa3c5a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_choice_required.snap +++ /dev/null @@ -1,50 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ], - "tool_choice": "required" -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap deleted file mode 100644 index d601c24dc..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_tool_round_trip.snap +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Find foo and read /tmp/x" - }, - { - "role": "assistant", - "content": "Let me check.", - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - }, - { - "id": "call_2", - "type": "function", - "function": { - "name": "read_file", - "arguments": "{\"path\":\"/tmp/x\"}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"matches\":2}", - "tool_call_id": "call_1" - }, - { - "role": "tool", - "content": "file not found", - "tool_call_id": "call_2" - } - ], - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - } - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - } - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap deleted file mode 100644 index 10995e1e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__encode_url_attachments.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Describe these attachments.[Document 'report.pdf': content type not supported by this provider]" - } - ], - "max_tokens": 128 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap deleted file mode 100644 index 9aaca3762..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_reasoning_deltas.snap +++ /dev/null @@ -1,66 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "thinking", - "data": { - "text": "Let me think", - "signature": null, - "redacted": false - } - }, - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap deleted file mode 100644 index 34dd07b3f..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_events.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": null - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap deleted file mode 100644 index 297b156fa..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_text_happy_path_request.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "model": "test-model", - "messages": [ - { - "role": "user", - "content": "Hello" - } - ], - "max_tokens": 128, - "stream": true, - "stream_options": { - "include_usage": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap deleted file mode 100644 index e1bb0f279..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_tool_call_deltas.snap +++ /dev/null @@ -1,95 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": null, - "raw_arguments": null - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}" - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap deleted file mode 100644 index d00f07703..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_openrouter_cost.snap +++ /dev/null @@ -1,60 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 8, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 4, - "cache_write_tokens": 0 - }, - "response": { - "id": "gen_or_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 8, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 4, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00031, - "cost_source": "authoritative" - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap deleted file mode 100644 index b2ac8b212..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_usage_venice_top_level_cost.snap +++ /dev/null @@ -1,60 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hi", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_venice_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hi" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 12, - "output_tokens": 2, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null, - "cost_usd": 0.00031, - "cost_source": "authoritative" - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap deleted file mode 100644 index b54da4946..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_or_content_synthesizes_nothing.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap deleted file mode 100644 index 18f1feb19..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__stream_without_done_synthesizes_finish_when_content_started.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hello", - "text_id": null - }, - { - "type": "text_end", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "chatcmpl_stream", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 0, - "output_tokens": 0, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": null, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap deleted file mode 100644 index eb24f04e8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_decode.snap +++ /dev/null @@ -1,49 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "id": "chatcmpl_test", - "model": "test-model", - "provider": "openai-compatible", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "chatcmpl_test", - "object": "chat.completion", - "created": 1700000000, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello back" - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 42, - "completion_tokens": 7, - "total_tokens": 49 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap deleted file mode 100644 index 675c550b4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_compatible__system_and_tools_encode.snap +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/chat/completions", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "305" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "test-model", - "messages": [ - { - "role": "system", - "content": "Be concise" - }, - { - "role": "user", - "content": "Hello" - } - ], - "temperature": 0.5, - "max_tokens": 128, - "tools": [ - { - "type": "function", - "function": { - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap deleted file mode 100644 index 6491674c9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__count_tokens_wire_shape.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses/input_tokens", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "454" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ] - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap deleted file mode 100644 index 3fb9cbd0b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__custom_named_stream_failed_event_identity.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from openai-proxy: boom", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap deleted file mode 100644 index fa27da4b8..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_incomplete_status_maps_to_length.snap +++ /dev/null @@ -1,65 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Truncated" - } - ] - } - }, - { - "kind": "text", - "data": "Truncated" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "incomplete", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Truncated" - } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap deleted file mode 100644 index e4597626a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_reasoning_and_function_call_items.snap +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_reasoning", - "data": { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Searching." - } - ] - } - }, - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 30, - "output_tokens": 12, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Searching." - } - ] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - } - ], - "usage": { - "input_tokens": 30, - "output_tokens": 12 - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap deleted file mode 100644 index 98813798b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__decode_usage_subtracts_cached_and_reasoning.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "ok" - } - ] - } - }, - { - "kind": "text", - "data": "ok" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 20, - "output_tokens": 30, - "reasoning_tokens": 20, - "cache_read_tokens": 80, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "ok" - } - ] - } - ], - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "input_tokens_details": { - "cached_tokens": 80 - }, - "output_tokens_details": { - "reasoning_tokens": 20 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap deleted file mode 100644 index 52d5331bf..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_audio_attachment.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Transcribe this." - }, - { - "type": "input_text", - "text": "[Audio content not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap deleted file mode 100644 index 5fd2a64fd..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_bad_file_path_attachments_dropped.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_text", - "text": "[Document 'missing.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap deleted file mode 100644 index f57e05f8b..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_codex_mode_forces_streaming_and_omits_params.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "210" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "stream": true - } -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap deleted file mode 100644 index 5c98c82d4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_dual_id_tool_round_trip.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Find foo" - } - ] - }, - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - }, - { - "type": "function_call_output", - "call_id": "call_abc", - "output": "2 matches" - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap deleted file mode 100644 index 172b57110..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_inline_attachments.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_image", - "image_url": "data:image/png;base64,ZmFrZS1wbmctYnl0ZXM=" - }, - { - "type": "input_text", - "text": "[Document 'report.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap deleted file mode 100644 index 473ce2c5d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_multi_turn.snap +++ /dev/null @@ -1,45 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "What is the capital of France?" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "Paris." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "And of Spain?" - } - ] - } - ], - "instructions": "You are a terse assistant.", - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap deleted file mode 100644 index d672689b1..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_opaque_items_round_trip.snap +++ /dev/null @@ -1,55 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Think about 2+2." - } - ] - }, - { - "type": "reasoning", - "id": "rs_1", - "summary": [ - { - "type": "summary_text", - "text": "Adding." - } - ] - }, - { - "type": "message", - "role": "assistant", - "id": "msg_1", - "content": [ - { - "type": "output_text", - "text": "4." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Now 3+3?" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap deleted file mode 100644 index c2f03af2c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_provider_options_openai_namespace.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "seed": 42 -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap deleted file mode 100644 index 57d01b2f9..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_reasoning_effort_with_levels_catalog.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "test-gpt", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "reasoning": { - "effort": "high" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap deleted file mode 100644 index 05ffc5a7d..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_object.snap +++ /dev/null @@ -1,29 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "text": { - "format": { - "type": "json_object" - } - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap deleted file mode 100644 index 1fe952e6c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_response_format_json_schema.snap +++ /dev/null @@ -1,42 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "text": { - "format": { - "type": "json_schema", - "name": "response", - "strict": true, - "schema": { - "type": "object", - "properties": { - "answer": { - "type": "string" - } - }, - "required": [ - "answer" - ] - } - } - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap deleted file mode 100644 index 783c7ab18..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_sampling_params.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "temperature": 0.7, - "max_output_tokens": 128, - "top_p": 0.9, - "stop": [ - "END" - ], - "metadata": { - "trace_id": "trace-123" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap deleted file mode 100644 index a232e2e6a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_thinking_round_trip.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Think step by step: what is 2+2?" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "4." - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Now 3+3?" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap deleted file mode 100644 index b4b6aef6c..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_auto.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "auto", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap deleted file mode 100644 index cb462f295..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_named.snap +++ /dev/null @@ -1,59 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": { - "type": "function", - "name": "search" - }, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap deleted file mode 100644 index 9c11e0609..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_none.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "none", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap deleted file mode 100644 index 3b9ad1745..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_choice_required.snap +++ /dev/null @@ -1,56 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "tool_choice": "required", - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap deleted file mode 100644 index f542622fb..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_tool_round_trip.snap +++ /dev/null @@ -1,90 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Find foo and read /tmp/x" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "Let me check." - } - ] - }, - { - "type": "function_call", - "id": "call_1", - "call_id": "call_1", - "name": "search", - "arguments": "{\"query\":\"foo\"}" - }, - { - "type": "function_call", - "id": "call_2", - "call_id": "call_2", - "name": "read_file", - "arguments": "{\"path\":\"/tmp/x\"}" - }, - { - "type": "function_call_output", - "call_id": "call_1", - "output": "{\"matches\":2}" - }, - { - "type": "function_call_output", - "call_id": "call_2", - "output": "file not found", - "status": "incomplete" - } - ], - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - }, - "required": [ - "query" - ] - } - }, - { - "type": "function", - "name": "read_file", - "description": "Read a file by path", - "parameters": { - "type": "object", - "properties": { - "path": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap deleted file mode 100644 index b85c3da20..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__encode_url_attachments.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Describe these attachments." - }, - { - "type": "input_image", - "image_url": "https://example.com/picture.png" - }, - { - "type": "input_text", - "text": "[Document 'report.pdf': content type not supported by this provider]" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ] -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap deleted file mode 100644 index 191a90c06..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_failed_event_maps_to_error.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "stream_item_error": "Server error from openai: boom", - "retryable": true, - "failover_eligible": true - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap deleted file mode 100644 index e274eb534..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_incomplete_maps_to_length.snap +++ /dev/null @@ -1,63 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Trunc", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Trunc" - } - ] - }, - "finish_reason": "length", - "usage": { - "input_tokens": 10, - "output_tokens": 128, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "incomplete", - "output": [], - "usage": { - "input_tokens": 10, - "output_tokens": 128 - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap deleted file mode 100644 index 47f067f13..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_reasoning_summary_deltas.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "reasoning_start" - }, - { - "type": "reasoning_delta", - "delta": "Let me " - }, - { - "type": "reasoning_delta", - "delta": "think" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "4.", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 4, - "reasoning_tokens": 8, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "4." - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 15, - "output_tokens": 4, - "reasoning_tokens": 8, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 15, - "output_tokens": 12, - "output_tokens_details": { - "reasoning_tokens": 8 - } - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap deleted file mode 100644 index cde7ca175..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_events.snap +++ /dev/null @@ -1,74 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "text_start", - "text_id": null - }, - { - "type": "text_delta", - "delta": "Hel", - "text_id": null - }, - { - "type": "text_delta", - "delta": "lo", - "text_id": null - }, - { - "type": "finish", - "finish_reason": "stop", - "usage": { - "input_tokens": 9, - "output_tokens": 4, - "reasoning_tokens": 1, - "cache_read_tokens": 2, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "text", - "data": "Hello" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 9, - "output_tokens": 4, - "reasoning_tokens": 1, - "cache_read_tokens": 2, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 11, - "output_tokens": 5, - "input_tokens_details": { - "cached_tokens": 2 - }, - "output_tokens_details": { - "reasoning_tokens": 1 - } - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap deleted file mode 100644 index 1dbf93c1a..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_text_happy_path_request.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "max_output_tokens": 128, - "store": false, - "include": [ - "reasoning.encrypted_content" - ], - "stream": true -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap deleted file mode 100644 index a4cc2e686..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__stream_tool_call_deltas.snap +++ /dev/null @@ -1,119 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -[ - { - "type": "stream_start" - }, - { - "type": "tool_call_start", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "{\"qu", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "{\"qu", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_delta", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": {}, - "raw_arguments": "ery\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "tool_call_end", - "tool_call": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - }, - { - "type": "finish", - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "response": { - "id": "resp_stream", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "tool_call", - "data": { - "id": "call_abc", - "name": "search", - "type": "function", - "arguments": { - "query": "foo" - }, - "raw_arguments": "{\"query\":\"foo\"}", - "provider_metadata": { - "id": "fc_123" - } - } - } - ] - }, - "finish_reason": "tool_calls", - "usage": { - "input_tokens": 20, - "output_tokens": 9, - "reasoning_tokens": 0, - "cache_read_tokens": 0, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_stream", - "model": "gpt-test", - "status": "completed", - "output": [], - "usage": { - "input_tokens": 20, - "output_tokens": 9 - } - }, - "warnings": [], - "rate_limit": null - } - } -] diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap deleted file mode 100644 index 01d4e4fd4..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_decode.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "id": "resp_test", - "model": "gpt-test", - "provider": "openai", - "message": { - "role": "assistant", - "content": [ - { - "kind": "openai_message", - "data": { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Hello back" - } - ] - } - }, - { - "kind": "text", - "data": "Hello back" - } - ] - }, - "finish_reason": "stop", - "usage": { - "input_tokens": 32, - "output_tokens": 4, - "reasoning_tokens": 3, - "cache_read_tokens": 10, - "cache_write_tokens": 0 - }, - "raw": { - "id": "resp_test", - "object": "response", - "model": "gpt-test", - "status": "completed", - "output": [ - { - "type": "message", - "role": "assistant", - "id": "msg_out", - "content": [ - { - "type": "output_text", - "text": "Hello back" - } - ] - } - ], - "usage": { - "input_tokens": 42, - "output_tokens": 7, - "input_tokens_details": { - "cached_tokens": 10 - }, - "output_tokens_details": { - "reasoning_tokens": 3 - } - } - }, - "warnings": [], - "rate_limit": null -} diff --git a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap b/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap deleted file mode 100644 index 165c53cc3..000000000 --- a/lib/components/fabro-llm/tests/it/wire/snapshots/it__wire__openai_responses__system_and_tools_encode.snap +++ /dev/null @@ -1,67 +0,0 @@ ---- -source: lib/components/fabro-llm/tests/it/wire/openai_responses.rs -expression: rendered ---- -{ - "method": "POST", - "path": "/responses", - "headers": [ - [ - "accept", - "*/*" - ], - [ - "authorization", - "Bearer test-key" - ], - [ - "content-length", - "385" - ], - [ - "content-type", - "application/json" - ], - [ - "host", - "[host]" - ] - ], - "body": { - "model": "gpt-test", - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello" - } - ] - } - ], - "instructions": "Be concise", - "temperature": 0.5, - "max_output_tokens": 128, - "tools": [ - { - "type": "function", - "name": "search", - "description": "Search files", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string" - } - } - } - } - ], - "store": false, - "include": [ - "reasoning.encrypted_content" - ] - } -}