feat(synu): add universal AI agent wrapper with Synthetic API quota tracking

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

Change summary

.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(+)

Detailed changes

.gitignore 🔗

@@ -0,0 +1,6 @@
+# agent dirs
+.agents/
+.claude/
+.clavix/
+.opencode/
+.qwen/

README.md 🔗

@@ -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

completions/synu.fish 🔗

@@ -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

functions/_synu_agents/claude.fish 🔗

@@ -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

functions/_synu_get_quota.fish 🔗

@@ -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

functions/synu.fish 🔗

@@ -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