.gitignore 🔗
@@ -0,0 +1,6 @@
+# agent dirs
+.agents/
+.claude/
+.clavix/
+.opencode/
+.qwen/
Amolith created
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
.gitignore | 6 +
README.md | 147 ++++++++++++++++++++++++++
completions/synu.fish | 31 +++++
functions/_synu_agents/claude.fish | 178 ++++++++++++++++++++++++++++++++
functions/_synu_get_quota.fish | 31 +++++
functions/synu.fish | 168 ++++++++++++++++++++++++++++++
6 files changed, 561 insertions(+)
@@ -0,0 +1,6 @@
+# agent dirs
+.agents/
+.claude/
+.clavix/
+.opencode/
+.qwen/
@@ -0,0 +1,147 @@
+<!--
+SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+
+SPDX-License-Identifier: Unlicense
+-->
+
+# 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 <agent>` 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_<name>_flags` - Returns argparse flag specification
+- `_synu_agent_<name>_configure` - Sets environment variables before execution
+- `_synu_agent_<name>_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
@@ -0,0 +1,31 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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
@@ -0,0 +1,178 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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
@@ -0,0 +1,31 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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
@@ -0,0 +1,168 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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 <agent> [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 <agent> [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