feat(menubar): change a route's algorithm and models from the menu bar - #875
Draft
elyasmnvidian wants to merge 3 commits into
Draft
elyasmnvidian wants to merge 3 commits into
elyasmnvidian wants to merge 3 commits into
Conversation
Signed-off-by: Greg Clark <grclark@nvidia.com> chore: cleanup Signed-off-by: Greg Clark <grclark@nvidia.com> chore: cleanup Signed-off-by: Greg Clark <grclark@nvidia.com>
|
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
elyasmnvidian
force-pushed
the
emehtabuddin/switch-1633-menubar-route-picker
branch
from
September 30, 2026 17:33
2ad6add to
a0c6783
Compare
… check keys before saving them Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
messiaen
force-pushed
the
grclark/mac-deamon
branch
3 times, most recently
from
October 2, 2026 00:02
c871c35 to
042211d
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Moving a route to other models meant editing the server config by hand. This PR adds a Change routing… item to the menu bar app. It opens a window where you pick a route, its algorithm, and a model for each role the algorithm needs. For
composite, the roles are Judge, Capable, and Efficient. Apply checks the new config withswitchyard-server --dry-run, saves it with a backup, and restarts the server.Each model box lists the models from the LLM client's
GET /modelsendpoint. Type to filter the list, or type any model ID. The app caches each model list inmodel-lists.json. After that, it fetches the list again only when you click Refresh models.Why
Today the menu bar app can open the server config and restart the server, but it cannot change the config. To move a route to other models, you look up exact model IDs in the provider's model list, edit the TOML by hand, run
switchyard-server --config <file> --dry-run, and restart the LaunchAgent withlaunchctl kickstart -k. Some mistakes show up only as a--dry-runerror. For example, two targets in one route cannot use the same model ID on different LLM clients.Example: a
compositeroute uses a GPT judge and Claude for Capable and Efficient. To move it to GPT models, pick the route in the window, typesolin the Capable box, and pickgpt-5.6-sol. Do the same for Efficient, then click Apply.Not in this PR: the feature request asked Apply to add prices for the new models when the prices are known. The app has no price source besides
menubar.toml, so the result names each chosen model that has no price there, instead of guessing one. Savings stay hidden until you add the price and restart the menu bar app.Notes for reviewers
Start with
server_config.rs.ServerConfig::editandchoose_targetdecide which targets to keep, change, or copy. Then readserver.rs::save_checked(check, backup, rename) andmodels.rs::load(cache and API keys).picker.rsis the AppKit window. It only collects choices, and it runs model lists, Keychain calls, and Apply on worker threads. The README describes the same behavior for users.Existing menu items, the
--printmode, and the server's routing do not change.restartandcommandmoved fromtray.rstoserver.rs, so the window can use them too.How it works
server_config.rs). The edit goes throughtoml_edit, so comments and untouched tables stay as they were. For each role, the app keeps the route's target when it already names the chosen model. Otherwise it uses another target with that model and client, if the route would get the samesystem_prompt,reasoning_effort,extra_body, andomit_body_fieldsfrom it, or if the last three differ, because the server rejects two targets for one model on one client with different values for them. Otherwise the app changes the route's own target in place, or copies it when another route or an earlier role in the same Apply uses it.--dry-rundoes not catch a setting that the new model rejects. So when a changed or copied target moves to another model family (for example fromgpt-…toclaude-…) or to a client with anotherformat, the result lists theomit_body_fields,reasoning_effort, andextra_bodythat the target kept, and the app leaves them as they are. The result also lists the settings that an algorithm switch removed, and says when a role now shares a target with another route.server.rs). The app checks a temporary file next to the real file withswitchyard-server --dry-run. It saves only when the check passes and the file did not change during the check. It then writes a backup that never replaces an older one, renames the temporary file over the real file, runslaunchctl kickstart -k, and waits up to 10 seconds for/health.models.rs). The app never writes a key to a file, andmodel-lists.jsonholds only model IDs and fetch times. To list a client's models, the app uses the key you just typed, then the client'sapi_key_envvariable, then the login Keychain item for the client'sbase_url. Save key saves a key only when the models endpoint did not reject it. I chose the Keychain over reading your login shell's environment, because that runs your whole shell profile from the app and hangs if the profile waits for input.curlgets the key on stdin and runs with-q, so averboseline in~/.curlrccannot print the key in the window.Costs and limits
toml_edit0.25. The app now usessecurity-framework, which was already in the lock file. The crate gains a test-only dependency onswitchyard-runner, so tests can parse edited configs with the server's own parser.api_key_envneeds that variable in the menu bar app's LaunchAgent, which puts the key in that plist file as plain text. The window names a missing variable under each role that needs it. Aforward_authclient avoids this, because the server sends each caller's own key upstream.llm_classifierroute, or to a route whosetypeis not in the Algorithm list, replaces its settings with the chosen algorithm.gpt,claude, and so on). A provider that names models differently can get a note it does not need, or miss one.Evidence
All runs used a debug build of this branch under temporary LaunchAgents, with a config in
/tmp, clients on an OpenAI-compatible LiteLLM gateway that useapi_key_env, and a local/v1/modelsstub for the API key cases. The gateway appears ashttps://gateway.example.com/v1, and its model IDs are replaced with public IDs. Automation could not click the status item on macOS 26, so a temporary code change, not in this PR, calledPicker::show()at launch, which is the same call the menu item makes.Apply on the final code
The test config had 14 routes, including a
compositeroute (judgegpt-5.6-terra, Capablegpt-5.6-sol, Efficientgpt-5.6-luna, all on anopenai_chatclient) and aclaude-opus-5-5target withomit_body_fields = ["reasoning_effort"].GPT pair to Claude pair. I set Capable to
claude-opus-5-5and Efficient toclaude-sonnet-5and clicked Apply. The server's PID changed, and the result area showed:In the config diff, the
idof[targets.efficient]changed toclaude-sonnet-5, andcapable_targetchanged to the existingclaude-opus-5-5target. A chat request to the route was answered byclaude-sonnet-5("pong") after a judge call togpt-5.6-terra.Back to the GPT pair. The result had the same kind of note for
[targets.efficient]and said the check passed for 14 routes.cmpfound the config byte-for-byte equal to the original, with the same permissions, and a chat request was answered bygpt-5.6-lunaafter a judge call.A failed check. With a client whose
api_key_envvariable the app does not have, Apply showed the server's error, then "The app's environment has no , which Apply needs: add it to the menu bar app's LaunchAgent." The config's checksum, the backup count, and the server's PID stayed the same, and no temporary file was left.Missing
switchyard-server. The code gives "Not saved. Could not run …/switchyard-server: No such file or directory (os error 2). Apply needs switchyard-server next to the menu bar app. Run make install-macos from the Switchyard repository to install it." Only an earlier build was run withoutswitchyard-server, and that Apply changed nothing.Window behavior on the final code: a long role list, keyboard, Save key, a slow Refresh, and accessibility labels
randomroute with 10 models: the window was 986 points tall on a screen whose visible area is about that height. Models 1–7 showed, the rest scrolled, and Refresh models, Close, and Apply stayed on the screen. Tab from Model 7 to Model 10 scrolled Model 10 into view.security find-generic-passwordfound no item. With the right key, the app listed 5 models and saved the key. I deleted that item afterwards.AXPopUpButton (Capable LLM client),AXComboBox (Capable model), andAXTextField (API key for http://127.0.0.1:…/v1).Model lists on an earlier build: Save key under launchd, the dropdown, Refresh, a failed refresh, restart, and the real gateway
An earlier build had a bug. When a refresh failed and
model-lists.jsondid not have the list, the role note stayed at "Refreshing…" until the app restarted. The final code numbers each load, ignores a result from an older load, and never lets an older list replace a newer one inmodel-lists.json.That run used clients
stub(Responses) andstub_chat(Chat Completions) at a local/v1/modelsstub that counts requests and answers only one test key, plus a client for the gateway.base_url, and the stub counted 0 requests.model-lists.jsonheld only model IDs and the fetch time.SOL, the note said "2 of 10 models match."model-lists.jsonshowed the new list.launchctl kickstart -k, the window showed the cached lists, the stub still counted 2 requests, andmodel-lists.jsonwas unchanged.sonnet 5gave "5 of 249 models match.", and Refresh fetched the list again.Save key's order changed after that run: the final code lists the models first and saves only a key that the endpoint did not reject (see above). After the run I booted out the test LaunchAgent and deleted the Keychain items I had created.
Tests
cargo test -p switchyard-menubarruns 50 tests on macOS, and all pass. The new tests call the production functions with a localGET /modelsstub and a shell script in place ofswitchyard-server. They cover the model-list cache and API keys, the models-URL rules, the target rules and their notes, every algorithm's output against the server's own parser, and saving. Each new rule's test fails when that rule is removed.