rubino-agent 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Agent
|
|
5
|
+
# Stitches a response truncated by the output-token limit back together.
|
|
6
|
+
#
|
|
7
|
+
# Faithful port of the reference finish_reason=="length" continuation
|
|
8
|
+
# (the per-turn loop plus the boosted output budget). When a model call comes back with
|
|
9
|
+
# stop_reason==:length and NO tool calls, the answer was cut mid-sentence by
|
|
10
|
+
# max_tokens. Rather than surface the fragment as the final turn, we:
|
|
11
|
+
#
|
|
12
|
+
# 1. keep the interim partial as an assistant message in the history,
|
|
13
|
+
# 2. append a "[System: …continue exactly where you left off…]" user nudge,
|
|
14
|
+
# 3. re-issue the SAME request with a progressively BOOSTED output budget
|
|
15
|
+
# (base × (retry+1), capped at 32 768), and
|
|
16
|
+
# 4. concatenate the partial pieces into the final answer.
|
|
17
|
+
#
|
|
18
|
+
# Up to MAX_RETRIES (3, matching the reference `length_continue_retries < 3`)
|
|
19
|
+
# continuations are attempted; if it is still truncated after that, the
|
|
20
|
+
# stitched-together partial is returned as-is (the reference returns it with
|
|
21
|
+
# partial=True / "remained truncated after 3 continuation attempts").
|
|
22
|
+
#
|
|
23
|
+
# The class is transport-agnostic: it issues each continuation through a
|
|
24
|
+
# +boundary+ callable (`boundary.call(request) -> AdapterResponse`) so it
|
|
25
|
+
# unit-tests against fixtures with no network. The caller (Loop) builds the
|
|
26
|
+
# first request and passes the first response in.
|
|
27
|
+
class TruncationContinuation
|
|
28
|
+
# The `length_continue_retries < 3` ceiling.
|
|
29
|
+
MAX_RETRIES = 3
|
|
30
|
+
# Fallback base when agent.max_tokens is unset.
|
|
31
|
+
DEFAULT_BASE = 4096
|
|
32
|
+
# Boost cap.
|
|
33
|
+
BOOST_CAP = 32_768
|
|
34
|
+
|
|
35
|
+
# The continuation nudge for an ordinary output-length truncation
|
|
36
|
+
# (the `else` branch of the continuation-prompt builder). The
|
|
37
|
+
# partial-stream-stub variants don't apply here — a dropped stream surfaces
|
|
38
|
+
# as AdapterResponse#interrupted?, handled separately by the Loop.
|
|
39
|
+
CONTINUATION_NUDGE =
|
|
40
|
+
"[System: Your previous response was truncated by the output " \
|
|
41
|
+
"length limit. Continue exactly where you left off. Do not " \
|
|
42
|
+
"restart or repeat prior text. Finish the answer directly.]"
|
|
43
|
+
|
|
44
|
+
# +boundary+ : responds to #call(request, &block) → AdapterResponse.
|
|
45
|
+
# +base_tokens+ : the configured agent.max_tokens (nil ⇒ DEFAULT_BASE).
|
|
46
|
+
# +ui+ : optional, gets #note on each continuation attempt.
|
|
47
|
+
def initialize(boundary:, base_tokens: nil, ui: nil)
|
|
48
|
+
@boundary = boundary
|
|
49
|
+
@base_tokens = base_tokens
|
|
50
|
+
@ui = ui
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True iff +response+ is a length-truncated turn that warrants continuation:
|
|
54
|
+
# stopped on the output limit AND carries no tool calls (a truncated
|
|
55
|
+
# tool-call turn is a different repair path — out of scope here, as in
|
|
56
|
+
# the reference's separate truncated_tool_call branch).
|
|
57
|
+
def applicable?(response)
|
|
58
|
+
response&.stop_reason == :length && !response.has_tool_calls?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Drive the continuation loop. +request+ is the LLM::Request that produced
|
|
62
|
+
# +first_response+; +first_response+ is the truncated AdapterResponse.
|
|
63
|
+
# Re-issues with a boosted budget until the model stops cleanly or
|
|
64
|
+
# MAX_RETRIES is hit, then returns ONE AdapterResponse whose content is the
|
|
65
|
+
# stitched-together answer. A passed block forwards stream chunks straight
|
|
66
|
+
# through to the boundary on each continuation call.
|
|
67
|
+
#
|
|
68
|
+
# If +first_response+ is not applicable? this returns it untouched, so the
|
|
69
|
+
# Loop can call #continue unconditionally.
|
|
70
|
+
def continue(request, first_response, &)
|
|
71
|
+
return first_response unless applicable?(first_response)
|
|
72
|
+
|
|
73
|
+
parts = collect_part(first_response)
|
|
74
|
+
response = first_response
|
|
75
|
+
retries = 0
|
|
76
|
+
|
|
77
|
+
while applicable?(response) && retries < MAX_RETRIES
|
|
78
|
+
retries += 1
|
|
79
|
+
@ui&.note("↻ Requesting continuation (#{retries}/#{MAX_RETRIES})…")
|
|
80
|
+
|
|
81
|
+
# Keep the interim partial in history, then nudge the model to resume.
|
|
82
|
+
messages = request.messages.dup
|
|
83
|
+
messages << { role: "assistant", content: response.content.to_s }
|
|
84
|
+
messages << { role: "user", content: CONTINUATION_NUDGE }
|
|
85
|
+
|
|
86
|
+
request = reissue(request, messages, retries)
|
|
87
|
+
response = @boundary.call(request, &)
|
|
88
|
+
parts.concat(collect_part(response))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
stitch(response, parts)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Build the next request: same shape, continued history, boosted budget.
|
|
97
|
+
def reissue(request, messages, retries)
|
|
98
|
+
LLM::Request.new(
|
|
99
|
+
messages: messages,
|
|
100
|
+
tools: request.tools,
|
|
101
|
+
temperature: request.temperature,
|
|
102
|
+
max_tokens: boosted_max_tokens(retries),
|
|
103
|
+
thinking: request.thinking,
|
|
104
|
+
prefill: request.prefill,
|
|
105
|
+
image_paths: request.image_paths,
|
|
106
|
+
stream: request.stream?
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Progressive boost: base × (retries+1), capped. On the
|
|
111
|
+
# first continuation (retries==1) the budget is 2× base, then 3×, …
|
|
112
|
+
def boosted_max_tokens(retries)
|
|
113
|
+
base = @base_tokens && @base_tokens.positive? ? @base_tokens : DEFAULT_BASE
|
|
114
|
+
[base * (retries + 1), BOOST_CAP].min
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def collect_part(response)
|
|
118
|
+
text = response.content.to_s
|
|
119
|
+
text.empty? ? [] : [text]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Final stitched response: concatenated content, carrying the LAST call's
|
|
123
|
+
# token usage / model id / stop_reason (the spend already happened, and the
|
|
124
|
+
# final stop_reason tells the caller whether it ever completed cleanly).
|
|
125
|
+
def stitch(last_response, parts)
|
|
126
|
+
LLM::AdapterResponse.new(
|
|
127
|
+
content: parts.join,
|
|
128
|
+
tool_calls: last_response.tool_calls,
|
|
129
|
+
input_tokens: last_response.input_tokens,
|
|
130
|
+
output_tokens: last_response.output_tokens,
|
|
131
|
+
model_id: last_response.model_id,
|
|
132
|
+
stop_reason: last_response.stop_reason
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Middleware
|
|
8
|
+
# Bearer-token auth middleware. Sits between JsonParser and the router so
|
|
9
|
+
# unauthorized requests never reach an operation; raises UnauthorizedError
|
|
10
|
+
# which ErrorHandler (one layer up) maps to a 401 JSON response.
|
|
11
|
+
#
|
|
12
|
+
# Token comparison uses Rack::Utils.secure_compare to avoid timing leaks.
|
|
13
|
+
# SKIP_PATHS allows unauthenticated access to liveness/metrics endpoints
|
|
14
|
+
# so external probes don't need to carry the API key.
|
|
15
|
+
class Auth
|
|
16
|
+
SKIP_PATHS = %w[/v1/health /v1/metrics].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(app, api_key:)
|
|
19
|
+
@app = app
|
|
20
|
+
@api_key = api_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(env)
|
|
24
|
+
return @app.call(env) if SKIP_PATHS.include?(env["PATH_INFO"])
|
|
25
|
+
|
|
26
|
+
header = env["HTTP_AUTHORIZATION"].to_s
|
|
27
|
+
# RFC 6750: scheme is case-insensitive, separated from the token by a
|
|
28
|
+
# single space. Match explicitly so a raw token without the "Bearer "
|
|
29
|
+
# prefix is rejected instead of being silently accepted (which is what
|
|
30
|
+
# String#sub would do when the pattern doesn't match).
|
|
31
|
+
match = header.match(/\ABearer (.*)\z/i)
|
|
32
|
+
raise UnauthorizedError, "missing bearer scheme" if match.nil?
|
|
33
|
+
|
|
34
|
+
token = match[1]
|
|
35
|
+
raise UnauthorizedError, "missing bearer token" if token.empty?
|
|
36
|
+
raise UnauthorizedError, "invalid bearer token" unless Rack::Utils.secure_compare(token, @api_key)
|
|
37
|
+
|
|
38
|
+
@app.call(env)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Middleware
|
|
8
|
+
# Catches typed Rubino errors and renders them as JSON with the right
|
|
9
|
+
# HTTP status (see STATUS_MAP). Anything else becomes a generic 500 while
|
|
10
|
+
# the full class/message/backtrace are sent to the structured logger,
|
|
11
|
+
# so unhandled crashes never leak internals to clients.
|
|
12
|
+
#
|
|
13
|
+
# Stack position: second from outermost, just inside Observability, so
|
|
14
|
+
# Observability still sees the final status code on the way out.
|
|
15
|
+
class ErrorHandler
|
|
16
|
+
STATUS_MAP = {
|
|
17
|
+
Rubino::NotFoundError => 404,
|
|
18
|
+
Rubino::ValidationError => 422,
|
|
19
|
+
Rubino::UnauthorizedError => 401,
|
|
20
|
+
Rubino::ConflictError => 409,
|
|
21
|
+
Rubino::PayloadTooLargeError => 413,
|
|
22
|
+
Rubino::UpstreamError => 502
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(app, logger:)
|
|
26
|
+
@app = app
|
|
27
|
+
@logger = logger
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
@app.call(env)
|
|
32
|
+
rescue *STATUS_MAP.keys => e
|
|
33
|
+
base = STATUS_MAP.find { |klass, _| e.is_a?(klass) }
|
|
34
|
+
status = base.last
|
|
35
|
+
render(status, code(e, base.first), e.message, details(e))
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
@logger.error(event: "api.error.unhandled", error: e.class.name, message: e.message,
|
|
38
|
+
backtrace: e.backtrace&.first(10))
|
|
39
|
+
render(500, "internal_error", "internal server error")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def render(status, code, message, details = nil)
|
|
45
|
+
body = { error: { code: code, message: message } }
|
|
46
|
+
body[:error][:details] = details if details && !details.empty?
|
|
47
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Derives a snake_case error code. Subclasses of typed errors (e.g.
|
|
51
|
+
# Workspace::PathTraversal < ValidationError) collapse to the parent's
|
|
52
|
+
# code so clients see a stable enum keyed off STATUS_MAP, not internal
|
|
53
|
+
# subclass names.
|
|
54
|
+
def code(error, base_class = nil)
|
|
55
|
+
source = (base_class || error.class).name
|
|
56
|
+
source.split("::").last.sub(/Error\z/, "").gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def details(error)
|
|
60
|
+
error.respond_to?(:details) ? error.details : nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Middleware
|
|
8
|
+
# Parses JSON request bodies once and stashes the result on
|
|
9
|
+
# env["rubino.json"] for Request#body to read. Only POST/PUT/PATCH
|
|
10
|
+
# with an application/json content-type are parsed; everything else
|
|
11
|
+
# gets an empty Hash so operations can rely on the key always existing.
|
|
12
|
+
#
|
|
13
|
+
# Malformed JSON raises ValidationError, which ErrorHandler turns into 422.
|
|
14
|
+
#
|
|
15
|
+
# Body size is capped at api.max_body_bytes (default 5 MiB). Requests
|
|
16
|
+
# that advertise a larger Content-Length, or whose body turns out to
|
|
17
|
+
# exceed the cap mid-read (i.e. Content-Length lied or was absent),
|
|
18
|
+
# are short-circuited to 413 here — ErrorHandler is bypassed because
|
|
19
|
+
# 413 is not part of the typed-error map.
|
|
20
|
+
class JsonParser
|
|
21
|
+
APPLICABLE_METHODS = %w[POST PUT PATCH].freeze
|
|
22
|
+
DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
def initialize(app)
|
|
25
|
+
@app = app
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
if APPLICABLE_METHODS.include?(env["REQUEST_METHOD"]) && json_content?(env)
|
|
30
|
+
limit = max_body_bytes
|
|
31
|
+
return too_large(limit) if content_length_over_limit?(env, limit)
|
|
32
|
+
|
|
33
|
+
body, overflowed = read_capped(env, limit)
|
|
34
|
+
return too_large(limit) if overflowed
|
|
35
|
+
|
|
36
|
+
env["rubino.json"] = parse(body)
|
|
37
|
+
else
|
|
38
|
+
env["rubino.json"] = {}
|
|
39
|
+
end
|
|
40
|
+
@app.call(env)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def json_content?(env)
|
|
46
|
+
env["CONTENT_TYPE"].to_s.start_with?("application/json")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def content_length_over_limit?(env, limit)
|
|
50
|
+
declared = env["CONTENT_LENGTH"]
|
|
51
|
+
return false if declared.nil? || declared.empty?
|
|
52
|
+
|
|
53
|
+
declared.to_i > limit
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads up to limit+1 bytes so we can detect the case where the
|
|
57
|
+
# actual body is larger than Content-Length advertised (or there
|
|
58
|
+
# was no Content-Length at all). The +1 marker is dropped before
|
|
59
|
+
# parsing.
|
|
60
|
+
def read_capped(env, limit)
|
|
61
|
+
input = env["rack.input"]
|
|
62
|
+
return ["", false] if input.nil?
|
|
63
|
+
|
|
64
|
+
buf = input.read(limit + 1)
|
|
65
|
+
return ["", false] if buf.nil?
|
|
66
|
+
|
|
67
|
+
if buf.bytesize > limit
|
|
68
|
+
[nil, true]
|
|
69
|
+
else
|
|
70
|
+
[buf, false]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse(body)
|
|
75
|
+
return {} if body.nil? || body.empty?
|
|
76
|
+
|
|
77
|
+
JSON.parse(body)
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise ValidationError.new("malformed JSON body", details: { parse_error: e.message })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def max_body_bytes
|
|
83
|
+
value = Rubino.configuration.dig("api", "max_body_bytes")
|
|
84
|
+
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_BODY_BYTES
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def too_large(limit)
|
|
88
|
+
payload = {
|
|
89
|
+
error: {
|
|
90
|
+
code: "validation",
|
|
91
|
+
message: "request body too large (max #{limit} bytes)",
|
|
92
|
+
details: { max_bytes: limit }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
[413, { "content-type" => "application/json" }, [JSON.generate(payload)]]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Middleware
|
|
6
|
+
# Outermost middleware. Wraps every request to:
|
|
7
|
+
# - record http_requests_total{method,path,status} + http_request_duration_seconds
|
|
8
|
+
# - emit one JSON log line (event="api.request") with method, path, status, duration_ms
|
|
9
|
+
#
|
|
10
|
+
# Status comes from the response tuple after ErrorHandler has done its
|
|
11
|
+
# mapping; on a fully unhandled raise we still record status=500 and
|
|
12
|
+
# re-raise so Puma can render whatever it wants. The `path` metric label
|
|
13
|
+
# uses env["rubino.route"] (the matched pattern) when present, to
|
|
14
|
+
# keep Prometheus label cardinality bounded.
|
|
15
|
+
class Observability
|
|
16
|
+
def initialize(app, logger: nil)
|
|
17
|
+
@app = app
|
|
18
|
+
@logger = logger || Rubino.logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
|
+
status, headers, body = @app.call(env)
|
|
24
|
+
observe(env, status, start)
|
|
25
|
+
[status, headers, body]
|
|
26
|
+
rescue StandardError
|
|
27
|
+
observe(env, 500, start)
|
|
28
|
+
raise
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def observe(env, status, start)
|
|
34
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
35
|
+
method = env["REQUEST_METHOD"]
|
|
36
|
+
path = path_for(env)
|
|
37
|
+
|
|
38
|
+
Metrics.counter(:http_requests_total, method: method, path: path, status: status).increment
|
|
39
|
+
Metrics.histogram(:http_request_duration_seconds, method: method, path: path).observe(duration)
|
|
40
|
+
|
|
41
|
+
@logger.info(
|
|
42
|
+
event: "api.request",
|
|
43
|
+
method: method,
|
|
44
|
+
path: env["PATH_INFO"],
|
|
45
|
+
status: status,
|
|
46
|
+
duration_ms: (duration * 1000).round(2)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Use the matched route pattern when the router set it (low-cardinality);
|
|
51
|
+
# fall back to the raw path otherwise (might balloon labels, but is
|
|
52
|
+
# better than nothing for unmatched paths logged as 404).
|
|
53
|
+
def path_for(env)
|
|
54
|
+
env["rubino.route"] || env["PATH_INFO"]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Middleware
|
|
8
|
+
# Token-bucket rate limiter, applied BEFORE Auth so that the open
|
|
9
|
+
# endpoints (/v1/health, /v1/metrics) get their own per-IP ceiling and
|
|
10
|
+
# cannot be flooded by an unauthenticated client. Authenticated requests
|
|
11
|
+
# are keyed by the bearer token so a single API key cannot saturate the
|
|
12
|
+
# process by spraying connections from many IPs.
|
|
13
|
+
#
|
|
14
|
+
# Buckets refill linearly over a 60-second window. Storage is a single
|
|
15
|
+
# in-memory hash with monotonic timestamps; safe for a single-process
|
|
16
|
+
# deployment. Multi-process / multi-host needs a shared backend
|
|
17
|
+
# (Redis, etc.) — defer until we actually scale out.
|
|
18
|
+
#
|
|
19
|
+
# On exceed: 429 with the canonical error envelope
|
|
20
|
+
# { error: { code: "rate_limited", message: "...",
|
|
21
|
+
# details: { retry_after_seconds: N } } }
|
|
22
|
+
# and a Retry-After header so well-behaved clients can back off without
|
|
23
|
+
# parsing the body.
|
|
24
|
+
class RateLimit
|
|
25
|
+
DEFAULT_UNAUTH_PER_MINUTE = 60
|
|
26
|
+
DEFAULT_AUTH_PER_MINUTE = 600
|
|
27
|
+
WINDOW_SECONDS = 60.0
|
|
28
|
+
|
|
29
|
+
def initialize(app, clock: nil)
|
|
30
|
+
@app = app
|
|
31
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
32
|
+
@buckets = {}
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
return @app.call(env) unless enabled?
|
|
38
|
+
|
|
39
|
+
key, capacity = bucket_for(env)
|
|
40
|
+
allowed, retry_after = consume(key, capacity)
|
|
41
|
+
return too_many(retry_after) unless allowed
|
|
42
|
+
|
|
43
|
+
@app.call(env)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Authenticated buckets are keyed by the bearer token (so the same key
|
|
49
|
+
# used from many IPs still hits one ceiling); unauthenticated buckets
|
|
50
|
+
# are keyed by remote IP. The auth/unauth split is decided by whether
|
|
51
|
+
# the request advertised a Bearer token at all — Auth itself validates
|
|
52
|
+
# the token later, so an invalid token still gets the unauth bucket
|
|
53
|
+
# via REMOTE_ADDR if we cannot extract one.
|
|
54
|
+
def bucket_for(env)
|
|
55
|
+
token = bearer_token(env)
|
|
56
|
+
if token
|
|
57
|
+
["auth:#{token}", auth_limit]
|
|
58
|
+
else
|
|
59
|
+
["ip:#{env["REMOTE_ADDR"] || "unknown"}", unauth_limit]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bearer_token(env)
|
|
64
|
+
header = env["HTTP_AUTHORIZATION"].to_s
|
|
65
|
+
match = header.match(/\ABearer (.+)\z/i)
|
|
66
|
+
match && match[1]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Refill is continuous: tokens accumulate at capacity/window per second,
|
|
70
|
+
# capped at capacity. Each request costs 1 token. Returns
|
|
71
|
+
# [allowed?, retry_after_seconds_when_denied].
|
|
72
|
+
def consume(key, capacity)
|
|
73
|
+
now = @clock.call
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
bucket = @buckets[key] ||= { tokens: capacity.to_f, updated_at: now }
|
|
76
|
+
elapsed = now - bucket[:updated_at]
|
|
77
|
+
refill_rate = capacity / WINDOW_SECONDS
|
|
78
|
+
bucket[:tokens] = [bucket[:tokens] + (elapsed * refill_rate), capacity.to_f].min
|
|
79
|
+
bucket[:updated_at] = now
|
|
80
|
+
|
|
81
|
+
if bucket[:tokens] >= 1
|
|
82
|
+
bucket[:tokens] -= 1
|
|
83
|
+
[true, 0]
|
|
84
|
+
else
|
|
85
|
+
# Time until one full token is available again.
|
|
86
|
+
deficit = 1 - bucket[:tokens]
|
|
87
|
+
retry_after = (deficit / refill_rate).ceil
|
|
88
|
+
[false, [retry_after, 1].max]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def too_many(retry_after)
|
|
94
|
+
payload = {
|
|
95
|
+
error: {
|
|
96
|
+
code: "rate_limited",
|
|
97
|
+
message: "rate limit exceeded",
|
|
98
|
+
details: { retry_after_seconds: retry_after }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
[
|
|
102
|
+
429,
|
|
103
|
+
{
|
|
104
|
+
"content-type" => "application/json",
|
|
105
|
+
"retry-after" => retry_after.to_s
|
|
106
|
+
},
|
|
107
|
+
[JSON.generate(payload)]
|
|
108
|
+
]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enabled?
|
|
112
|
+
value = config_dig("api", "rate_limit_enabled")
|
|
113
|
+
value.nil? || value == true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unauth_limit
|
|
117
|
+
int_or(config_dig("api", "rate_limit_unauth_per_minute"), DEFAULT_UNAUTH_PER_MINUTE)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def auth_limit
|
|
121
|
+
int_or(config_dig("api", "rate_limit_auth_per_minute"), DEFAULT_AUTH_PER_MINUTE)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def int_or(value, fallback)
|
|
125
|
+
value.is_a?(Integer) && value.positive? ? value : fallback
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def config_dig(*keys)
|
|
129
|
+
Rubino.configuration.dig(*keys)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module Approvals
|
|
7
|
+
# POST /v1/runs/:run_id/approvals/:approval_id
|
|
8
|
+
# Resolves a pending approval gate on a paused run by posting the
|
|
9
|
+
# operator's decision through the in-process GateRegistry.
|
|
10
|
+
#
|
|
11
|
+
# @raise [Rubino::NotFoundError] when the run does not exist.
|
|
12
|
+
# @raise [Rubino::ValidationError] when the body fails Schemas::DecideApproval.
|
|
13
|
+
# @raise [Rubino::ConflictError] when the run has no pending gate (already decided or never opened).
|
|
14
|
+
class DecideOperation
|
|
15
|
+
def self.call(request)
|
|
16
|
+
new.call(request)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accepts an alternate run repository and gate registry for tests.
|
|
20
|
+
def initialize(repository: nil, registry: nil)
|
|
21
|
+
@repository = repository || ::Rubino::Run::Repository.new
|
|
22
|
+
@registry = registry || ::Rubino::Run::GateRegistry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(request)
|
|
26
|
+
run_id = request.params.fetch("run_id")
|
|
27
|
+
approval_id = request.params.fetch("approval_id")
|
|
28
|
+
|
|
29
|
+
raise NotFoundError.new("run", run_id) unless @repository.find(run_id)
|
|
30
|
+
|
|
31
|
+
attrs = request.validate!(Schemas::DecideApproval)
|
|
32
|
+
gate = @registry.fetch(run_id)
|
|
33
|
+
raise ConflictError, "no pending decisions for run #{run_id}" if gate.nil?
|
|
34
|
+
|
|
35
|
+
# Wrong-run (or replayed) approval_id: the gate never issued it,
|
|
36
|
+
# so refuse — never let an arbitrary id unblock an unrelated await.
|
|
37
|
+
# Duplicate posts return the originally-resolved decision so
|
|
38
|
+
# retries are idempotent (no double-unblock of the run loop).
|
|
39
|
+
status = gate.decide(approval_id, attrs[:decision])
|
|
40
|
+
raise NotFoundError.new("approval", approval_id) if status == :unknown
|
|
41
|
+
|
|
42
|
+
resolved = status == :duplicate ? gate.decision_for(approval_id) : attrs[:decision]
|
|
43
|
+
[200, { approval_id: approval_id, decision: resolved }]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module Clarifications
|
|
7
|
+
# POST /v1/runs/:run_id/clarifications/:clarify_id
|
|
8
|
+
# Delivers the user's response to a clarification gate that paused the run,
|
|
9
|
+
# using the same in-process GateRegistry as approvals.
|
|
10
|
+
#
|
|
11
|
+
# @raise [Rubino::NotFoundError] when the run does not exist.
|
|
12
|
+
# @raise [Rubino::ValidationError] when the body fails Schemas::DecideClarification.
|
|
13
|
+
# @raise [Rubino::ConflictError] when the run has no pending gate.
|
|
14
|
+
class DecideOperation
|
|
15
|
+
def self.call(request)
|
|
16
|
+
new.call(request)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accepts an alternate run repository and gate registry for tests.
|
|
20
|
+
def initialize(repository: nil, registry: nil)
|
|
21
|
+
@repository = repository || ::Rubino::Run::Repository.new
|
|
22
|
+
@registry = registry || ::Rubino::Run::GateRegistry
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(request)
|
|
26
|
+
run_id = request.params.fetch("run_id")
|
|
27
|
+
clarify_id = request.params.fetch("clarify_id")
|
|
28
|
+
|
|
29
|
+
raise NotFoundError.new("run", run_id) unless @repository.find(run_id)
|
|
30
|
+
|
|
31
|
+
attrs = request.validate!(Schemas::DecideClarification)
|
|
32
|
+
gate = @registry.fetch(run_id)
|
|
33
|
+
raise ConflictError, "no pending decisions for run #{run_id}" if gate.nil?
|
|
34
|
+
|
|
35
|
+
status = gate.decide(clarify_id, attrs[:response])
|
|
36
|
+
raise NotFoundError.new("clarification", clarify_id) if status == :unknown
|
|
37
|
+
|
|
38
|
+
[200, { clarify_id: clarify_id, accepted: true }]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# POST /v1/jobs
|
|
8
|
+
# Creates a cron job row and registers it with the in-process scheduler.
|
|
9
|
+
# New jobs default to deliver="local" when the client omits it.
|
|
10
|
+
#
|
|
11
|
+
# @return [[Integer, Hash]] 201 + serialized job.
|
|
12
|
+
# @raise [Rubino::ValidationError] when the body fails Schemas::CreateCronJob
|
|
13
|
+
# or carries a cron schedule Fugit cannot parse (#164).
|
|
14
|
+
class CreateOperation
|
|
15
|
+
include ScheduleValidation
|
|
16
|
+
|
|
17
|
+
def self.call(request)
|
|
18
|
+
new.call(request)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Accepts an alternate repository and scheduler for tests.
|
|
22
|
+
def initialize(repository: nil, scheduler: nil)
|
|
23
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
24
|
+
@scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(request)
|
|
28
|
+
attrs = request.validate!(Schemas::CreateCronJob)
|
|
29
|
+
validate_schedule!(attrs[:schedule])
|
|
30
|
+
job = @repository.create(
|
|
31
|
+
name: attrs[:name],
|
|
32
|
+
schedule: attrs[:schedule],
|
|
33
|
+
prompt: attrs[:prompt],
|
|
34
|
+
skills: attrs[:skills] || [],
|
|
35
|
+
model: attrs[:model],
|
|
36
|
+
provider: attrs[:provider],
|
|
37
|
+
deliver: attrs[:deliver] || "local"
|
|
38
|
+
)
|
|
39
|
+
@scheduler.schedule(job)
|
|
40
|
+
[201, Serializer.call(job)]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|