commit 16cc892b6126ee64191cb5341b24a39794d108ca Author: Amolith Date: Tue Nov 25 09:41:08 2025 -0700 feat(synu): add universal AI agent wrapper with Synthetic API quota tracking Implement a Fish shell utility that wraps AI agents while tracking usage through the Synthetic API. Features include: - Universal agent wrapper supporting passthrough execution - Quota tracking and display for Synthetic API calls - Claude Code integration with configurable model routing - Interactive model selection using gum for user-friendly experience - Shell completions for improved usability - Agent plugin architecture for extensibility Initial implementation includes core synu functionality, quota management, and Claude-specific configuration for routing through Synthetic API tiers. Closes: Assisted-by: Claude Sonnet 4.5 via OpenCode diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..cc6b124ec54b463543892c5d49f92153b82fbea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# agent dirs +.agents/ +.claude/ +.clavix/ +.opencode/ +.qwen/ diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..51f275da0cf6850c9d9a026f5e3d78ec547ef08b --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ + + +# synu + +A universal wrapper for AI agents that tracks quota usage for [Synthetic](https://synthetic.new) API calls. + +## Requirements + +- Fish +- `curl` - for API requests +- `jq` - for JSON parsing +- `gum` - for interactive model selection ([install](https://github.com/charmbracelet/gum)) +- `SYNTHETIC_API_KEY` environment variable (for quota tracking with Synthetic) + +## Installation + +### Using Fundle + +Add to your `~/.config/fish/config.fish`: + +```fish +fundle plugin 'synu' --url 'https://git.secluded.site/amolith/llm-projects' +fundle init +``` + +Then reload your shell or run `fundle install`. + +## Configuration + +Set your Synthetic API key in your `~/.config/fish/config.fish`: + +```fish +# For Synthetic API quota tracking +set -gx SYNTHETIC_API_KEY your_api_key_here +``` + +## Usage + +Use `synu` as a wrapper for any AI agent: + +```fish +# Check current quota +synu + +# Use with claude (auto-configured for Synthetic) +synu claude "What does functions/synu.fish do?" + +# Use with other agents (passthrough with quota tracking) +synu crush "Help me write code" + +# Any other agent or command +synu [agent-name] [agent-args...] +``` + +### Claude Code Configuration + +When using Claude Code through synu, the following models are configured by default: + +| Tier | Model | +|------|-------| +| Opus | `hf:moonshotai/Kimi-K2-Thinking` | +| Sonnet | `hf:MiniMaxAI/MiniMax-M2` | +| Haiku | `hf:deepseek-ai/DeepSeek-V3.1-Terminus` | +| Subagent | `hf:MiniMaxAI/MiniMax-M2` | + +#### Override Models with Flags + +```fish +# Override specific models +synu claude --opus hf:other/model "prompt" +synu claude --sonnet hf:other/model "prompt" +synu claude --haiku hf:other/model "prompt" +synu claude --agent hf:other/model "prompt" + +# Group overrides +synu claude --large hf:model "prompt" # Sets Opus, Sonnet, and Subagent +synu claude --light hf:model "prompt" # Sets Haiku +``` + +#### Interactive Model Selection + +Use `synu i ` to interactively select models using gum: + +```fish +synu i claude "prompt" +``` + +This will present a TUI to choose between group or individual model selection, then let you filter and select from available Synthetic models. + +### How it works + +`synu` works by: + +1. Loading agent-specific configuration if available (e.g., Claude Code model defaults) +2. Fetching initial quota from the Synthetic API before running the agent +3. Executing the specified agent with all provided arguments +4. Fetching final quota after the agent completes +5. Calculating and displaying session usage + +The quota tracking requires the `SYNTHETIC_API_KEY` environment variable to be set. Without it, `synu` will still work but will show a warning and skip quota tracking. + +### Adding Support for New Agents + +Agent definitions are stored in `functions/_synu_agents/`. Each agent file can define: + +- `_synu_agent__flags` - Returns argparse flag specification +- `_synu_agent__configure` - Sets environment variables before execution +- `_synu_agent__interactive` - Implements interactive model selection + +Agents without definitions work as passthrough with quota tracking. + +## Shell completions + +Synu includes fish shell completions for better usability. + +## Contributions + +Patch requests are in [amolith/llm-projects] on [pr.pico.sh]. You don't need a +new account to contribute, you don't need to fork this repo, you don't need to +fiddle with `git send-email`, you don't need to faff with your email client to +get `git request-pull` working... + +You just need: + +- Git +- SSH +- An SSH key + +```sh +# Clone this repo, make your changes, and commit them +# Create a new patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr create amolith/llm-projects +# After potential feedback, submit a revision to an existing patch request with +git format-patch origin/main --stdout | ssh pr.pico.sh pr add {prID} +# List patch requests +ssh pr.pico.sh pr ls amolith/llm-projects +``` + +See "How do Patch Requests work?" on [pr.pico.sh]'s home page for a more +complete example workflow. + +[amolith/llm-projects]: https://pr.pico.sh/r/amolith/llm-projects +[pr.pico.sh]: https://pr.pico.sh \ No newline at end of file diff --git a/completions/synu.fish b/completions/synu.fish new file mode 100644 index 0000000000000000000000000000000000000000..c737efc9b124e2c8a81851420534e13c269f8e47 --- /dev/null +++ b/completions/synu.fish @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: Unlicense + +# Provide basic usage information for the wrapper itself +# Suggest known agents and interactive mode for the first argument +complete -c synu -n "not __fish_seen_subcommand_from i claude crush amp octo codex" \ + -a "i" -d "Interactive model selection" +complete -c synu -n "not __fish_seen_subcommand_from i claude crush amp octo codex" \ + -a "claude crush amp octo codex" -d "AI agent to wrap" + +# After "i" subcommand, suggest agents +complete -c synu -n "__fish_seen_subcommand_from i; and not __fish_seen_subcommand_from claude crush amp octo codex" \ + -a "claude crush amp octo codex" -d "AI agent" + +# Claude-specific flags (when claude is the agent) +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s L -l large -r -d "Override Opus, Sonnet, and Sub-agent models" +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s l -l light -r -d "Override Haiku model" +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s o -l opus -r -d "Override Opus model" +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s s -l sonnet -r -d "Override Sonnet model" +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s H -l haiku -r -d "Override Haiku model" +complete -c synu -n "__fish_seen_subcommand_from claude" \ + -s a -l agent -r -d "Override Sub-agent model" + +# Inherit claude completions for claude subcommand +complete -c synu -n "__fish_seen_subcommand_from claude" -w claude diff --git a/functions/_synu_agents/claude.fish b/functions/_synu_agents/claude.fish new file mode 100644 index 0000000000000000000000000000000000000000..8dca740f58a70eff9c9fc1278287d43c48b1059d --- /dev/null +++ b/functions/_synu_agents/claude.fish @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: Unlicense + +# Claude Code agent definition for synu +# Provides model configuration for routing through Synthetic API + +# Default models +set -g _synu_claude_default_opus "hf:moonshotai/Kimi-K2-Thinking" +set -g _synu_claude_default_sonnet "hf:MiniMaxAI/MiniMax-M2" +set -g _synu_claude_default_haiku "hf:deepseek-ai/DeepSeek-V3.1-Terminus" +set -g _synu_claude_default_subagent "hf:MiniMaxAI/MiniMax-M2" + +function _synu_agent_claude_flags --description "Return argparse-compatible flag specification" + echo "L/large=" + echo "l/light=" + echo "o/opus=" + echo "s/sonnet=" + echo "H/haiku=" + echo "a/agent=" +end + +function _synu_agent_claude_configure --description "Configure Claude Code environment variables" + # Parse flags passed from main synu + argparse 'L/large=' 'l/light=' 'o/opus=' 's/sonnet=' 'H/haiku=' 'a/agent=' -- $argv + or return 1 + + # Start with defaults + set -l opus_model $_synu_claude_default_opus + set -l sonnet_model $_synu_claude_default_sonnet + set -l haiku_model $_synu_claude_default_haiku + set -l subagent_model $_synu_claude_default_subagent + + # Apply group overrides + if set -q _flag_large + set opus_model $_flag_large + set sonnet_model $_flag_large + set subagent_model $_flag_large + end + + if set -q _flag_light + set haiku_model $_flag_light + end + + # Apply specific overrides (take precedence over groups) + if set -q _flag_opus + set opus_model $_flag_opus + end + if set -q _flag_sonnet + set sonnet_model $_flag_sonnet + end + if set -q _flag_haiku + set haiku_model $_flag_haiku + end + if set -q _flag_agent + set subagent_model $_flag_agent + end + + # Export environment variables for Claude Code + set -gx ANTHROPIC_BASE_URL "https://api.synthetic.new/anthropic" + set -gx ANTHROPIC_AUTH_TOKEN $SYNTHETIC_API_KEY + set -gx ANTHROPIC_DEFAULT_OPUS_MODEL $opus_model + set -gx ANTHROPIC_DEFAULT_SONNET_MODEL $sonnet_model + set -gx ANTHROPIC_DEFAULT_HAIKU_MODEL $haiku_model + set -gx CLAUDE_CODE_SUBAGENT_MODEL $subagent_model + set -gx CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 1 +end + +function _synu_agent_claude_interactive --description "Interactive model selection using gum" + # Check for gum + if not command -q gum + echo "Error: gum is required for interactive mode. Install: https://github.com/charmbracelet/gum" >&2 + return 1 + end + + # Fetch available models + set -l models_json (gum spin --spinner dot --title "Fetching models..." -- \ + curl -s -H "Authorization: Bearer $SYNTHETIC_API_KEY" \ + "https://api.synthetic.new/openai/v1/models") + or return 1 + + set -l model_names (echo $models_json | jq -r '.data[].name') + or return 1 + + # Prompt for groups vs individual + set -l mode (gum choose --limit 1 --header "How do you want to select models?" \ + "Groups" "Individual models") + or return 1 + + # Build flags array + set -l flags + + if test "$mode" = "Groups" + # Select which groups to override + set -l groups (gum choose --no-limit \ + --header "Which group(s) do you want to override?" \ + "Large (Opus, Sonnet, Sub-agent)" "Light (Haiku)") + or return 1 + + for group in $groups + if test "$group" = "Large (Opus, Sonnet, Sub-agent)" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select model for Large group" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --large=$model_id + end + else if test "$group" = "Light (Haiku)" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select model for Light group" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --light=$model_id + end + end + end + else + # Select which individual models to override + set -l models (gum choose --no-limit \ + --header "Which model(s) do you want to override?" \ + "Opus" "Sonnet" "Haiku" "Sub-agent") + or return 1 + + for model_type in $models + switch $model_type + case "Opus" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select Opus model" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --opus=$model_id + end + case "Sonnet" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select Sonnet model" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --sonnet=$model_id + end + case "Haiku" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select Haiku model" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --haiku=$model_id + end + case "Sub-agent" + set -l model_name (printf "%s\n" $model_names | \ + gum filter --limit 1 --header "Select Sub-agent model" \ + --placeholder "Filter models...") + or return 1 + set -l model_id (echo $models_json | \ + jq -r --arg name "$model_name" '.data[] | select(.name == $name) | .id') + if test -n "$model_id" + set flags $flags --agent=$model_id + end + end + end + end + + # Output flags for caller to use + echo $flags +end diff --git a/functions/_synu_get_quota.fish b/functions/_synu_get_quota.fish new file mode 100644 index 0000000000000000000000000000000000000000..82cf65b45d893b750860d297acee28e133747467 --- /dev/null +++ b/functions/_synu_get_quota.fish @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: Unlicense + +# Private function to fetch quota from Synthetic API +function _synu_get_quota + # Check if API key is set + if not set -q SYNTHETIC_API_KEY + echo "Error: SYNTHETIC_API_KEY environment variable not set" >&2 + echo "Set it with: set -gx SYNTHETIC_API_KEY your_api_key" >&2 + return 1 + end + + # Fetch quota from API with fresh request (no caching) + # Use -f to fail silently on HTTP errors, outputting nothing + set -l response (curl -s -f -H "Authorization: Bearer $SYNTHETIC_API_KEY" \ + "https://api.synthetic.new/v2/quotas" 2>/dev/null) + + # Check if curl failed or response is empty + if test $status -ne 0 -o -z "$response" + return 1 + end + + # Parse JSON response safely with jq + # Use fallback to 0 if field is missing/empty + set -l requests (echo "$response" | jq -r '.subscription.requests // 0') + set -l limit (echo "$response" | jq -r '.subscription.limit // 0') + + # Return the values as space-separated string + echo "$requests $limit" +end diff --git a/functions/synu.fish b/functions/synu.fish new file mode 100644 index 0000000000000000000000000000000000000000..4432d8acc1c512bfc62309209aa47023a2b138ce --- /dev/null +++ b/functions/synu.fish @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: Unlicense + +function synu --description "Universal agent wrapper with Synthetic API quota tracking" + # If no arguments, just print the quota + if test (count $argv) -lt 1 + set -l quota (_synu_get_quota) + if test $status -ne 0 + echo "Error: Could not fetch quota" >&2 + return 1 + end + set -l requests (echo "$quota" | cut -d' ' -f1) + set -l limit (echo "$quota" | cut -d' ' -f2) + set -l remaining (math "$limit - $requests") + set -l percent_used (math "$requests * 100 / $limit") + printf "Remaining: " + if test $percent_used -lt 33 + set_color green + else if test $percent_used -lt 67 + set_color yellow + else + set_color red + end + printf "%s/%s" $remaining $limit + set_color normal + printf " requests\n" + return 0 + end + + # Check for interactive mode: synu i [args...] + if test "$argv[1]" = "i" + if test (count $argv) -lt 2 + echo "Error: Interactive mode requires an agent name" >&2 + echo "Usage: synu i [args...]" >&2 + return 1 + end + + set -l agent $argv[2] + set -l agent_args $argv[3..-1] + + # Source agent definition if it exists + set -l agent_file (status dirname)/_synu_agents/$agent.fish + if test -f "$agent_file" + source $agent_file + end + + # Check for interactive function + if not functions -q _synu_agent_{$agent}_interactive + echo "Error: Agent '$agent' does not support interactive mode" >&2 + return 1 + end + + # Get flags from interactive selection + set -l interactive_flags (_synu_agent_{$agent}_interactive) + set -l interactive_status $status + if test $interactive_status -ne 0 + return $interactive_status + end + + # Recursively call synu with selected flags + synu $agent $interactive_flags $agent_args + return $status + end + + # Extract agent name (first argument) and remaining args + set -l agent $argv[1] + set -l agent_args $argv[2..-1] + + # Source agent definition if it exists + set -l agent_file (status dirname)/_synu_agents/$agent.fish + if test -f "$agent_file" + source $agent_file + end + + # Check if agent has a configuration function + if functions -q _synu_agent_{$agent}_configure + # Get flag specification + set -l flag_spec (_synu_agent_{$agent}_flags) + + # Parse flags using agent's spec, ignoring unknown flags for passthrough + # We need to capture which flags were set to pass to configure + set -l parsed_args + if test -n "$flag_spec" + # Parse with --ignore-unknown so agent-native flags pass through + argparse --ignore-unknown $flag_spec -- $agent_args + or return 1 + + # argv now contains non-flag args after argparse + set agent_args $argv + + # Rebuild the flag arguments to pass to configure + # Check each possible flag and add if set + for flag in L/large l/light o/opus s/sonnet H/haiku a/agent + set -l short_flag (string split '/' $flag)[1] + set -l long_flag (string split '/' $flag)[2] + set -l var_name _flag_$long_flag + if set -q $var_name + set parsed_args $parsed_args --$long_flag=$$var_name + end + end + end + + # Configure the agent environment + _synu_agent_{$agent}_configure $parsed_args + or return 1 + end + + # Fetch quota before agent execution + set -l quota_before (_synu_get_quota) + if test $status -ne 0 + # If quota fetch fails, still execute the agent but warn + echo "Warning: Could not fetch quota before execution" >&2 + # Set default values if quota fetch fails + set -l quota_before "0 0" + end + + # Parse pre-execution quota values + set -l requests_before (echo "$quota_before" | cut -d' ' -f1) + set -l limit (echo "$quota_before" | cut -d' ' -f2) + + # Execute the agent with all arguments passed through unchanged + # Use 'command' to bypass function recursion and call the actual binary + command $agent $agent_args + set -l exit_status $status + + # Fetch quota after agent execution + set -l quota_after (_synu_get_quota) + if test $status -ne 0 + # If quota fetch fails, still exit but warn + echo "Warning: Could not fetch quota after execution" >&2 + return $exit_status + end + + # Parse post-execution quota values + set -l requests_after (echo "$quota_after" | cut -d' ' -f1) + # Note: limit is the same before and after, using pre-execution value + + # Calculate session usage: final_requests - initial_requests + set -l session_usage (math "$requests_after - $requests_before") + + # Calculate remaining quota: limit - requests_after + set -l remaining (math "$limit - $requests_after") + + # Calculate percentage used: (requests_after * 100) / limit + set -l percent_used (math "$requests_after * 100 / $limit") + + # Display session usage and remaining quota with color + # Force a newline first for proper separation from agent output + printf "\n" + printf "Session usage: %s requests\n" $session_usage + printf "Remaining: " + + # Determine color based on usage percentage + # Green: <33%, Yellow: 33-66%, Red: >66% + if test $percent_used -lt 33 + set_color green + else if test $percent_used -lt 67 + set_color yellow + else + set_color red + end + printf "%s/%s" $remaining $limit + set_color normal + printf " requests\n" + + return $exit_status +end