Skip to content

fix(*): sign in to an oauth provider from /model - #279

Merged
0xKT merged 46 commits into
mainfrom
fix/model_picker_oauth_signin
Aug 6, 2026
Merged

fix(*): sign in to an oauth provider from /model#279
0xKT merged 46 commits into
mainfrom
fix/model_picker_oauth_signin

Conversation

@0xKT

@0xKT 0xKT commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

/model listed the four OAuth providers and did nothing when Enter was pressed on
one. Enter now hands the terminal to raven provider login <family> and lands on
that provider's model list when the sign-in succeeds. Everything the fix walked
through on the way is here too, because a working key was not enough to reach a
model:

Where a credential lives. Each OAuth family resolved its own path -- Codex
through oauth_cli_kit, Copilot under ~/.config/litellm, MiniMax under
platformdirs -- and the code that wrote one was not always the code that read it,
so a completed sign-in could report itself unauthenticated forever. One function
now answers where a credential lives, under ~/.raven/oauth, and "configured"
means the file parses as a credential rather than that a path exists. LiteLLM's
driver owns the Codex flow end to end, which retires the oauth-cli-kit
dependency.

Signing in. provider login is now the driver's own flow, which is what makes
the credential it writes the one the request path reads. Two hazards come with
handing the flow over, and both are closed here rather than left for a user to
find: asking LiteLLM for a token falls through to starting a device flow when a
refresh fails, so a model.options RPC or a provider test could print a device
code and poll for fifteen minutes with nobody waiting to type it -- both entry
points are refused on the instance that only reads. And the driver treats its own
unfinished-attempt timestamp as a flow in progress, so an interrupted sign-in would
make the next one wait five silent minutes; the login path discards it. Signing in
while already signed in says so instead of announcing a flow it skips, a revoked
credential falls through to a real sign-in, and the failure text names both reasons
a refresh can fail -- LiteLLM wraps a revoked token and an unreachable network in
one error, so claiming either one would send half the users to fix the wrong thing.

Which Codex models exist. Two ids were hard-coded, in the registry and in the
provider constructor, and a live account refused both with "not supported when
using Codex with a ChatGPT account". Which slugs an account may use is only
knowable by asking it, so the account's own catalogue answers -- cached per
credential, with failures cached too so being offline costs one timeout rather than
one per keystroke, and never asked while signed out.

Probes. provider test fetched a ChatGPT token for every non-MiniMax OAuth
family, so Copilot was probed with somebody else's credential. Each family is
dispatched explicitly now -- and one with no check written yet says so, rather than
borrowing the nearest one. Codex and Copilot each get a probe that asks the way
their backend accepts being asked: Codex's account catalogue, and for Copilot the
endpoint out of its own API key file with the editor headers its driver sends,
because the generic {api_base}/v1/models request could reach neither -- for
Copilot it answered "api_base is empty" without asking, and asking would have had
a valid seat refused. A refused credential reads as a credential problem and an
unreachable host as a network fault.

Not verified: that GitHub answers 200 to the Copilot request. That needs a live
seat; what is pinned by test is the request that goes out.

Cost. A call on a subscription was costed at $0.00, indistinguishable from a
free model. Plan billing is declared in the registry, "no per-token price" is a
distinct answer from zero, the boot banner no longer opens a plan-billed session at
$0.00, and the invented subscription prices are gone.

The wizard and the terminal. The wizard's model step defaults to the newest
model the account offers, and says which one it used when the answer was empty --
Codex has no static default to fall back on any more, and a substitution nobody
mentions is how a test message ends up sent with a model the user did not pick.
Three more: importing LiteLLM printed over the TUI's rendering,
RAVEN_TUI_DISABLE_MOUSE was turned back on a moment after start, and the TUI
spawned whatever raven came first on PATH rather than the one that started the
session -- which is how a sign-in writes a credential the running process will
never read.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

make lint-python   ruff check: all checks passed; ruff format --check: clean
make lint-tui      eslint + lint:rpc + tsc --noEmit: exit 0 (pre-existing warnings only)
make test-tui      Test Files 85 passed (85), Tests 969 passed (969)
make test-python   5525 passed, 48 skipped, 13 deselected, 2 warnings in 187.32s

The suite was reading the developer's real OAuth credentials before this branch:
import_litellm publishes the token-directory variables process-wide and they
outlive the test that triggered the import, so a later test that faked $HOME
still resolved a live credential -- which made providers report themselves
configured inside a sandboxed home and sent the Codex catalogue lookup to the
network. On a signed-in machine that was a red suite; it is green now, and the
variables point at a temp directory for every test.

Review on this branch kept turning up tests that passed either way, so the guards
were re-checked by mutation: break the implementation in an isolated worktree, run
the test, confirm it goes red.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed -- nothing under
    docs/ or README.md names a credential location or the wizard's startup
    condition, so there was nothing to update

Risk

Five changes are visible to someone who upgrades:

  1. Existing OAuth sign-ins are not migrated. Credentials are read from
    ~/.raven/oauth now, so a signed-in user signs in once more. The old files are
    left where they are, untouched.
  2. ~/.raven/oauth is created 0700, and an existing one is tightened to it. A
    user who widened that directory on purpose will find it narrowed; the files in it
    are credentials, and the drivers that write them do not set a mode themselves.
  3. provider login over a credential that already works reports and exits
    instead of re-running the browser flow. raven provider reset <name> is the way
    to sign in as somebody else, and the command says so.
  4. Startup now notices a default model whose provider has no credentials, which
    it did not before: it asks the config which provider serves the model rather than
    accepting any configured key as proof. The answer is a notice naming the model
    and pointing at /model -- not the wizard, which would restart at the language
    screen to fix one line. The wizard still runs for a config with nothing usable.
  5. Plan-billed providers stop reporting $0.00 and are left out of the session
    total rather than adding zero to it.

Removed: a five-second config.get {key: 'mtime'} poll in the TUI. config.get
answers with its four whitelisted keys whatever it is handed, so mtime was never
in the response, the poll always took its early return, and the MCP reload it
guarded never ran -- while costing a round trip on the same stdio pipe that carries
prompt.submit. /reload-mcp is unaffected.

Rollback is a revert of the merge: the credential move is a read-path change, so
reverting reads the old locations again and a user signed in before the upgrade is
signed in after the revert.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

Fixes #263, #212, #218, #269, #275, #276, #277, #278

0xKT and others added 30 commits August 6, 2026 19:37
Enter on an OAuth row ran no setter and sent no request, so nothing on
screen changed and the row's own warning read the same before and after.
The screen above it says "Enter to set one up".

Enter now opens a sign-in screen that names the provider and the command,
and Enter there hands the terminal to `raven provider login <slug>` for
the browser flow, refetches the options when the child exits, and stays
put so a failure has somewhere to be reported.

Three keyboard holes came with it:

- the new stage needs its own guard in useInput, or Enter falls through to
  the list handler and silently returns to the list -- the very symptom
  being fixed;
- bare `q` closes the overlay, which on the key screen meant an API key
  containing a `q` could never be typed;
- the handoff shares this process group, so Ctrl-C reached the parent's
  signal handler and ended the session instead of the sign-in that was
  waiting on a browser. Signals are deferred while a child owns the
  terminal, and the three hand-off sites go through one helper.

`/setup` spawned `raven setup`, which is not a registered command, so it
could only ever reach the non-zero exit path; it launches `raven onboard`
now and keeps its place out of the palette.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A credential was written by one client and read by another's guess at
where it went: the kit names Codex's file for the Codex CLI while we read
it under raven's slug, and Copilot's token belongs to LiteLLM. A provider
the user had signed in to reported itself unauthenticated forever, and
`d` deleted a file nothing had written.

Every family now derives one path, and the one that writes the file is the
one asked where it is -- the kit's storage object answers for Codex rather
than a second derivation beside it, which is what disagreed in the first
place. Credentials outside that directory are not read: the Codex CLI's
token stays the Codex CLI's, so refreshing ours cannot invalidate the copy
it still holds, and a disconnect can remove everything it reports. That
costs one sign-in per provider after upgrading.

Copilot keeps two files, and the API key outlived the access token it came
from, so a disconnected provider went on answering until it expired.

The suite could not have caught any of this: neither lookup goes through
the patched home, so whether a provider reported itself authenticated
depended on whether the developer running the tests had signed in.

Also opens GitHub's device page for the Copilot login. LiteLLM owns that
flow and only prints the code, so it was the one family with no browser
hand-off.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…ir vendor files them

Two things LiteLLM was asked for came back wrong.

Its logs went to the terminal. It installs a stderr handler on its own
loggers at import, and every import of it in raven is deferred -- so the
handler lands after the CLI has stripped terminal handlers, and nothing
removes it. With the stdlib intercept setting the root level to 0, every
record including DEBUG was written over the Ink screen; a model LiteLLM
cannot map produced enough of them to make the picker unusable. The
handler is detached on import, and the records still reach the log file.

Its metadata table is keyed by the vendor's own spelling, and we asked
with the routing id. A provider reached by region or by subscription
therefore missed every time, and each miss cost a second guess plus the
live catalogue fetch behind it -- Codex and both MiniMax OAuth regions had
no context window at all. The spec now names the entry that holds a
model's price and window, and one function answers where it is filed.

The picker no longer offers a provider's spec default when nothing else
lists a model: for Codex that id is rejected by the account API, so
offering it turned an honest dead end into a row that fails on use.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Two ways the reported figures were not the user's.

A model routed straight to its vendor was asked about under an
`openrouter/` alias first, and OpenRouter prices what it fronts under its
own prefix: `deepseek/deepseek-chat` reported a 65,536-token window at
$0.14 per million where the vendor's own row says 131,072 at $0.28. The id
is asked as it routes now, with the alias behind it, so a model LiteLLM
lists nowhere else is still covered.

A plan is not billed per token. LiteLLM files those models at zero, which
the tiers below read as "unknown" and answered with the pay-as-you-go rate
the plan holder is not paying -- $2.50 per million for a Copilot seat,
$1.75 for a ChatGPT one. The spec states what a provider is billed on and
the estimate is absent for a plan; tokens and window occupancy, which do
mean something on a subscription, are unaffected.

Billing is declared rather than read off `is_oauth`: OAuth is how you
authenticate, not how you are charged.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…-out

`RAVEN_TUI_DISABLE_MOUSE` sets the store's default, and the hydrate then
normalized an absent `mouse_tracking` to `true` -- so the opt-out came back
on a moment after start. It is the one field here whose default does not
come from this function, so it is the one field that has to be left alone
when the config says nothing.

The five-second `mtime` poll went with it. `config.get` answers with the
four whitelisted dotted keys whatever params it is handed, so `mtime` was
never in the response, the poll always took its early return, and the MCP
reload it guarded never ran -- while still costing a round trip on the same
stdio pipe that carries `prompt.submit`.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`configured` was `exists()`, so a truncated write, a hand-edited token or an
empty file all reported the provider as ready -- to the picker, to `provider
list`, and to the startup gate -- and then failed on the first request.

Each family's own reader decides now: the kit's storage parses Codex's JSON,
MiniMax already parsed its own, and Copilot's device token has to hold
something. Expiry stays out of it: a token with a refresh token is usable
and refreshing is the client's job.

Only presence is claimed. Whether a credential is accepted takes a request --
Copilot can hold a valid device token and still be refused the API key
exchange -- and that is what `provider test` is for.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
LiteLLM ships a ChatGPT driver for the same backend raven talks to, with the
same OAuth client id, and it already implements the device flow, the refresh
and the account-id derivation. Raven was carrying a second implementation of
all three through `oauth-cli-kit`, which is now dropped as a dependency.

`provider login openai-codex` asks that driver for a token, which is the
whole flow: it requests the device code, prints the page and the code, polls,
and writes the credential where the request path will look for it. The
directory is still raven's -- `CHATGPT_TOKEN_DIR` points at
`~/.raven/oauth/chatgpt` alongside Copilot's -- so one place still holds
every credential and a disconnect still removes them.

Reading it goes through one module, because the driver's own accessor opens a
device flow when it finds no token: correct for a login, wrong for a status
report or a request that should fail with something the user can act on.

The Responses call path stays raven's. The driver's chat transformation
inherits OpenAI's URL and would post to `{api_base}/chat/completions`, while
this backend serves `/responses` -- the endpoint its own metadata declares.
Moving the request path across is a separate change with a live-traffic
verification of its own.

Credentials written by the old client are not read: sign in once more.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The question was answered in three places with three rules. The config
resolves it -- honoring an explicit `agents.defaults.provider`, then prefix
over keyword, and declining to fall back to an OAuth provider. The startup
gate re-derived it from the model-id prefix, with a MiniMax special case
standing in for the rule the resolver already states, which is how the gate
came to disagree with `raven status` about a signed-in provider. The factory
compared the resolved name against a chain of strings, plus one extra check
for a model id spelled the old way.

The gate asks the resolver and then asks whether that provider's credentials
are on disk -- the one thing the resolver takes on trust for the OAuth
families. Credentials for some other provider no longer make a model
reachable: a key for one vendor used to let the session start on a model only
another vendor could answer.

The factory reads which client serves a provider off the spec, so a family is
added by declaring it rather than by editing the chain.

Resolving through the loader rather than validating the raw file: the config
on disk carries sections the base model does not declare, and it forbids
extras, so validating it directly fails on every real config.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`estimate_cost_usd` answers None for a plan-billed provider, and the caller
collapsed it to 0.0 before anything downstream saw it -- so a Copilot seat
reported a spend of zero rather than no spend, and the daily roll-up summed
those zeros as if the calls had been free.

The field is optional the whole way now: the snapshot carries None, the
accumulator adds only the calls that had a price, and the status bar already
renders the figure only when it is a number. Tokens and window occupancy --
the measures that mean something on a subscription -- are unchanged.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The key was a hash of the whole transcript, which grows every turn, so it was
different on every request -- and grouping requests that share a cached prefix
onto one cache is the only thing the field does. Keyed on the instructions
now, which is what the shared prefix actually is, and omitted when there are
none: one key across requests that share no prefix is worse than no key.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
LiteLLM reaches this backend and owns the credential, so moving the request
there looks free. Its Responses transformation filters the body through an
allow-list, and two of the fields this provider sends are not in it -- a
migration would keep every test green while quietly changing what is sent.

The guard reads that allow-list out of the installed LiteLLM and fails when it
stops dropping them, with the follow-up action in the failure message. Nothing
polls upstream: the signal arrives when the dependency is bumped, and until
then the guard is what a reader finds when they wonder why this provider is
still here.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The picker offered this provider nothing and `provider test` reported a
signed-in account as having a bad key. Both came from asking sources that
cannot answer for it: the registry default is refused by the backend, the
slugs LiteLLM's table carries are not the ones an account is entitled to,
and the generic probe requests `{api_base}/v1/models`, which this backend
does not serve.

Its own catalogue endpoint answers both questions, so ask that. Entries
marked `visibility: "hide"` are dropped -- reachable, but not meant to be
offered. The answer is cached briefly because the picker rebuilds its list
on every refresh, and a failure is an empty list rather than an error: a
provider list that cannot reach the network is still worth showing.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Letting LiteLLM own the codex credential routed both driver-owned families
through the same reader, and only one of them stores its token there. So a
copilot probe read the ChatGPT credential: with neither signed in it named
the wrong provider to log into, and with the account authenticator reached
at all it would have opened a GitHub device flow underneath `provider
test`, printing a code and waiting for someone to paste it.

Check the credential this provider stores, then ask the authenticator that
owns it.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Signing in from the picker spawns `raven provider login`, resolved through
PATH. That names whatever install comes first, which need not be the one
running: a second install writes the credential where its own version
expects it, so the login reports success and the picker still shows the
provider as unauthenticated. Before this the key was dead and the screen
only printed the command for the user to run, where their shell picked the
same raven they had just launched.

Name the entry point in the child environment instead. An explicitly set
RAVEN_BIN is left alone, and a session that cannot name its own entry sets
nothing rather than an empty value the TUI would read as "fall back to
PATH" while looking answered.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`import_litellm` publishes the credential directories so LiteLLM's drivers
and raven agree on one location, and those variables outlive the test that
triggered the import. A later test that fakes the home directory still
reads whatever the first one resolved -- on a developer machine, a real
signed-in credential. Providers then report themselves configured inside a
sandboxed home, and the codex catalogue lookup goes to the network.

Point them at a temp directory for every test.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…osts

The guard said this provider exists because LiteLLM drops
``parallel_tool_calls`` and ``text``. Both are the backend's own defaults, so
losing them changes nothing -- and the two failures cited alongside them are
version-bound: 1.95.0 rebuilds the empty ``response.completed`` output its
bridge used to choke on, and a ``responses/`` prefix forces the bridge for a
model its table has never heard of.

What survives is ``prompt_cache_key``, filtered out by the same allow-list in
every version through 1.97.0.dev1. Guard that instead, and say plainly that
keeping this file is a decision rather than a necessity.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The all-platforms case falls back to a foreground run, and nothing stubbed
it: the step then read the real config for a memory backend and imported
into whatever workspace the developer has configured. Without one it exited
1 and the test failed on that instead of on its own assertions, which is why
this file had a failure on every machine that had not onboarded.

Stub the runner, the same way `_memory_enabled` above it is stubbed and for
the same reason. What the test asserts -- that the step does not detach, and
says why -- is unchanged.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Checking that a credential file exists only covers having none. A stored
refresh token the server has since revoked gets the driver a logged warning
and then its own fallback: a device code printed to stdout and fifteen
minutes of polling -- underneath an agent request, the `model.options` RPC,
or `provider test`, none of which have anybody waiting to type a code. An
interrupted attempt is worse: the driver treats its own timestamp as a flow
in progress and waits five silent minutes for it.

Stub both entry points on the instance so refresh is the only way the call
can succeed, and give the login path a way to discard the leftover stamp.
The stamp is also no longer mistaken for a credential -- alone in a file it
is a provider that looks connected and cannot authenticate.

None of this was pinned on this side. The same guard for copilot had a test
and this one did not, so deleting the refusal kept every test green.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Asking the driver for a token hands back the stored one when it is still
good, so signing in while already signed in did nothing -- after announcing
a device flow and opening a browser tab, then reporting success. Somebody
switching accounts had no way to tell it had not happened.

Say so instead, and name what does work. When there is no credential, drop
an unfinished attempt's timestamp first: the driver would otherwise wait for
that attempt to land rather than start this one, printing nothing for as
long as five minutes with the terminal handed over.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Backing out asked "did the set-up happen?" of the row under the cursor. A
successful sign-in moves that provider off the unconfigured list, so the row
answers for whoever slid into its index: the picker returned to the add list
pointing at a provider the user never touched, and the re-anchor meant for
exactly this case never ran. The screen title was already immune -- it reads
its target from state -- and this is the other consumer of the same list.

The screen also told the user Ctrl+C would end the session, which is what
the same commit's `deferSignalExit` exists to prevent.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The probe dispatched on minimax and let everything else fall through to
copilot's reader, which is how copilot came to read the ChatGPT credential.
Naming each family closes the shape rather than the instance: a fifth one
arriving without a check of its own now says so instead of quietly using a
neighbour's, and a sweep over the registry fails when that happens.

The suite-wide credential isolation had the same gap in the other direction:
it named the two families LiteLLM reads by variable and left the two that
derive their path from the home directory pointing at the real one.

Also drops a docstring's promise to clean up credentials in pre-`~/.raven`
locations and to guard against a read fallback picking them back up. Neither
exists; both belong to an approach that was ruled out.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Every picker refresh rebuilds the candidate list, so an account that cannot
be reached was asked again on each one, paying the full timeout every time.
A failure is cached for a shorter window than an answer: being offline is a
state that changes, and an account's entitlements are not.

Two comments went with it. The RPC's own docstring still promised to touch
no network, which this batch had just made false; and the client version
carried a claim about what the endpoint does with an unrecognised one, which
was never measured -- what a live account answered for the value we send is.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Two answers written an hour apart pointed at each other. A revoked refresh
token is still a stored credential, so the request path raised "no longer
valid -- run provider login", and provider login read the same file, called
it present, and reported "already signed in". The only way out was a command
neither message named.

Ask whether the credential still works instead of whether it is there; that
call refreshes but cannot start a login, so a dead one falls through to the
flow the user came for. Signing in now also forgets a catalogue fetched while
there was nobody to fetch it for -- otherwise the picker offers this provider
no models for the first half-minute after signing in to it.

The probe told a missing credential from an unreachable one by catching an
exception the catalogue never raises, so both read as the same thing. What is
on disk answers it for free, and "sign in" is a different instruction from
"the account returned nothing".

Also compresses two docstrings in the same file: presence-versus-validity is
stated once rather than three times, and the sentinel branch says what a new
family must do without narrating what the old one did.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A review pass over every comment this branch added. Three kinds came out:

Wrong -- a docstring promising cleanup of pre-`~/.raven` credentials that
neither exists nor was ever going to, a module claiming `model.options` and
its neighbour cache touch no network after this branch gave them a lookup and
a failure cache, and two claims about how a token is fetched that missed the
path where the stored one is simply still good.

Redundant -- a field comment retelling the pricing incident that the test
docstring and the commit that fixed it already tell, and a factory comment
retelling what the field it dispatches on already says. Field comments state
the contract; the story lives where it happened.

Dead -- two fixtures clearing an environment variable no reader is left for,
while the variables that do jump out of a patched home went unnamed. Named
them, and dropped the one that does nothing.

The rename is the same edit in code: two names for one derivation, the newer
one reading as the opposite of what it means.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…resh fails

The hint for someone already signed in told them to run `provider remove`,
which is not a command -- the subcommand is `reset`. A test pinned the wrong
spelling while the message printed it, so the one thing that would have
caught it was asserting it instead.

The renewal failure said the credential was no longer valid. LiteLLM's
refresh wraps every failure in one error type, an unreachable network
included, so this side cannot tell those apart and must not claim to: being
told a credential is gone sends the user to re-authorise something that never
expired. Say both, and let the user pick which one they are in.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Every picker row was built by asking, signed in or not. Nobody signed in has
nothing to report, so the request could only fail -- and the failure is cached,
which is what left this provider empty for the half-minute after signing in to
it. The clearing added for that ran in the `provider login` child process,
where it could not reach the cache the gateway holds; gating the question on
the credential removes the failure instead of clearing it, and the clearing
goes with it.

The probe told a reachable-but-empty account from an unreachable one by
catching an exception the forgiving reader never raises. It now asks strictly,
because the two are not fixed by the same thing: a credential the account no
longer honours is fixed by signing in again, and the recovery menu offers that
on one branch and only Retry on the other.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A sign-in that took left the user on the confirmation screen, and Esc from
there went to the list -- while the provider had just moved off the list it was
selected from. The key screen next door already solves this: on success it
points the selection at the provider it just set up and opens its models. OAuth
stayed put instead, which was a way of avoiding the index that moved rather
than following it.

Follow it. A sign-in that leaves no credentials behind, and a login that
failed, still report on the screen they happened on.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Two were hard-coded, and both came back "not supported when using Codex with
a ChatGPT account": the registry's default and the provider constructor's.
Which slugs an account may use is only knowable by asking the account, which
is now what the picker and the probe do.

So neither place carries one. The wizard asks for a model instead of writing
one that cannot answer, and the constructor requires it rather than handing a
caller an id that will be refused at request time.

The invariant that every curated provider carries a default excludes this one
now, with the reason stated where it is excluded and a guard that fails if a
static default comes back.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Three things the model step got wrong once its list came from the account,
each found by running it:

The id went in bare. A route prefix is what LiteLLM needs and codex needs
none, so the wizard wrote "gpt-5.6-sol" -- which OpenAI's keywords claim, so
the test message went to a provider that does not serve it. The prefix that
names a provider is not always the one that goes on the wire, and it lives in
the registry now, with the picker reading the same answer. Azure stays absent:
it uses the id verbatim as a deployment name in a URL path.

Enter tore the wizard down. The prompt fell back to a default on an empty
submit and its comment said the no-default branch validated non-empty, which
held only while every provider carried a static default. The list is
newest-first, so its head is the default -- a better one than a hard-coded id
could be.

Clearing the prefill and pressing Enter substituted silently. The prompt has
already echoed an empty answer and a test message follows, so the model it was
sent with appeared nowhere.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
… for

Signing in happens in another process, so there is no call the gateway can
make to invalidate what it cached. Time alone was the only key, which left two
ways to serve the wrong answer: the previous account's models after switching
accounts, and an empty list for the rest of the failure window after signing
in to a provider that had been signed out. The file the driver writes is the
fact both processes share, so the cache belongs to it.

A strict fetch also no longer records a failure. It raises for a caller whose
job is to report on this moment; the picker asking next should not inherit a
verdict from a report it did not ask for.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
0xKT and others added 11 commits August 6, 2026 19:37
…eaches

The comment said azure was deliberately left out of the providers that get a
public prefix. It is left out, but that is not what keeps a prefix off its
deployment name: an endpoint provider is locked in as custom and its model is
persisted directly, so this function is never asked. Explaining a mechanism
that does not run is worse than saying nothing -- the next reader trusts it.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Both fixtures put the unconfigured providers after the sign-in target, so the
index into the configured list and the index into the whole response agreed --
and the two are the same number for every case the tests covered. Mutating the
line they exist to hold down left all fifteen passing.

One unconfigured provider before the target is enough to tell them apart, and
the mutation now fails.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…rk fault

A token that cannot be produced any more and a catalogue that cannot be reached
were reported identically, and the recovery menu branches on that: one status
offers signing in again, the other offers Retry. Somebody whose credential had
been revoked landed on the screen that could only retry it.

The two are already distinguishable by what raised them -- the credential
lookup, or the request. Say which.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…ting to it

Resetting a provider clears its credential and leaves the model id behind, so
the config still names a provider that cannot answer. Neither door that does it
said so -- and then the startup check read the result as "not set up" and ran
the six-step wizard from the language screen, over a session with other
providers ready.

Both halves now. The two doors say which model they are about to strand and how
to fix it. And the startup check separates the two things it was conflating: a
config with nothing usable is a first run and the wizard is the answer, while a
default naming a provider that has gone unusable is one wrong line -- so name
the line. That decision lives in one place, because both entry points were
asking the same question and only one answer can be right.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
The add-model screen takes free text and the account's catalogue offers bare
slugs, so "gpt-5.6-sol" is what a user types -- and stored bare it resolves to
OpenAI, which does not serve it. The listed models already carried the prefix;
the typed ones went in verbatim. Same hole, third door.

Which providers need their own name in front is the registry's answer now, not a
set of names kept in the wizard, and both surfaces spell an id through one
function. The guard is a sweep over every provider rather than the three cases
this batch happened to think of -- the same shape found the one that got away
last time.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A token the endpoint refuses and a catalogue that cannot be reached were both
reported as a network fault, and the recovery menu branches on that: one status
offers signing in again, the other offers Retry. Somebody whose credential had
been revoked landed on the screen that could only retry it, and the HTTP status
the endpoint had returned was dropped on the way.

Sorted by what would fix it: the credential lookup raising, or the request
coming back 401/403, both mean sign in; anything else means retry. The split is
not clean and does not claim to be -- the driver wraps a network failure during
refresh in the same error as a revoked token -- so both messages name both
causes.

Also translates a docstring that had been left in Chinese.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
…bout

Resetting a provider clears its credential and leaves the model id behind, so
the config still names a provider that cannot answer. The notice said so but
told everyone to run `provider login`, which exits 1 for anyone who is not one
of the four OAuth families -- and a local deployment has no key to give, so the
key flag it suggested instead would have done nothing. Each credential kind is
set up by its own command and its own field; say that one.

The recovery menu for a failed verify also offers Retry now on the branch that
takes the failures which cannot be sorted: a credential the account refused and
a refresh that could not reach the network arrive as the same thing, and only
one of them is fixed by signing in again.

And the startup notice says what was found rather than guessing why: the model
may resolve to a provider with no credentials, or to one that never served it.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Its docstring promised to stay in sync with the branches of `make_provider`,
and the version that compared provider names drifted the moment the factory
stopped comparing them. Both ask `spec.client` now, so they stay in sync by
construction rather than by a comment.

The guard for it was a source scan, which passed while the branch it guarded was
dead. Two behaviours replace it: an OAuth provider with no key in config is
accepted, and an Azure endpoint missing half its pair is refused.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Each of these passed with the thing it was written to hold down deleted.

`deferSignalExit`'s release-once protection: the test asserted that a function
was a function. Removing the protection left it green. It now emits a signal
with a deferral outstanding and asserts the session survives.

`/setup`: the test read the help text while the argv beside it was the thing
that reached a shell. Changing the argv back to `raven setup`, a command that
has never existed, left four tests passing.

`OpenAICodexProvider`: the test named itself after the model being the caller's
to supply and then only checked the getter echoed it back.

A comment goes with them: the display block `useConfigSync` normalizes is never
served, because the request asks `config.get {key:'full'}` and the handler reads
`keys`. The conditional above it is what makes the mouse-tracking opt-out safe,
not the fetch -- and the mismatch predates this branch.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
A comment ended in "see the PR", which a reader of the file cannot follow
and which AGENTS.md rules out. The sentence it pointed at was going to say
the `config.get {key:'full'}` / `keys` mismatch is older than this change;
that is context for review, not for the file, so the pointer goes and the
part that constrains the code stays.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`ensure_ready_to_start` replaced `ensure_configured_or_onboard` at both
entry points, and the old function stayed behind with only its own tests
as callers. Four guards for the paths that must not reach the gate were
patching that name, so `gate_called == []` had become a fact about a
function nobody called: retarget them and they fail again when the caller
drops the condition they guard.

Two comments still described the gate as "wizard when a provider key or
default model is missing". It runs the wizard only for a config with
nothing usable at all, and answers a stale default model with a notice.

The stale-model test parametrized over `tui` and `agent` and then called
the gate directly in both, so it never showed either entry point wired to
it. Invoke the commands.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Whether `provider login` opens the device page depends on having somewhere to
open it. CI runs headless Linux, so the runner was answering these assertions
instead of the code: the two expecting a page opened failed there while
passing on a developer's machine, and the one expecting no page passed for
the wrong reason. Declare a display in the fixture; the two headless cases
drop it again.

Verified by making every platform take the Linux branch in a throwaway
worktree: without the declaration the two failures reproduce, with it the
file is green, and opening a tab on the already-signed-in path turns the
third assertion red.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
@0xKT
0xKT requested a review from gloryfromca August 6, 2026 12:35
@gloryfromca

Copy link
Copy Markdown
Contributor

Adversarial review (verified against the branch, not just the diff)

Method: fetched refs/pull/279/head (11521ed), read the pinned litellm 1.85 authenticator sources, executed the copilot probe scenario against branch code, and ran the touched Python test files locally (648 passed, 48 skipped). Findings are ordered by severity: items 1-2 are worth fixing before merge, 3-6 are cheap to fix here or fine as follow-ups. A "verified non-issues" list is at the end so nothing gets re-litigated.

1. OAuth credential files lose the 0600 permission the removed stack applied (security regression)

  • The removed oauth_cli_kit chmods token files to 0600 (oauth_cli_kit/storage.py:70).
  • Its replacements do not: litellm's ChatGPT authenticator writes auth.json with a plain open(..., "w") (litellm/llms/chatgpt/authenticator.py, _write_auth_file), and the Copilot authenticator has no chmod anywhere. Under the default umask 022 that leaves ~/.raven/oauth/chatgpt/auth.json, github_copilot/access-token, and github_copilot/api-key.json world-readable.
  • raven's own MiniMax writer does 0600 + fsync (raven/providers/minimax_oauth.py:142), so the same directory now holds credentials at two permission levels.
  • clear_abandoned_device_code() (raven/providers/chatgpt_token.py:83-91) rewrites auth.json via tmp.write_text + os.replace; os.replace keeps the tmp file's mode, so this resets a user-tightened 0600 back to 0644.

Suggested fix: create get_oauth_dir() with mode=0o700 in _point_oauth_tokens_at_raven() (it runs before litellm's makedirs, so the parent mode shields the files regardless of their own mode), and chmod the credential files to 0600 after a successful login and after the tmp-file rewrite.

2. provider test github-copilot still cannot succeed for a validly signed-in seat

Executed against this branch: with a valid access-token and a fresh (unexpired) api-key.json on disk,

test_provider("github_copilot", ...) ->
{'ok': False, 'status': 'not_configured',
 'error': 'api_base is empty and provider has no default'}

The new copilot branch fetches a real API key via Authenticator().get_api_key() (a network refresh when the key is expired), then falls into the generic probe, which dies on the if not api_base check because the registry declares default_api_base="" for github_copilot (raven/providers/registry.py:358). The key just fetched is discarded. litellm's api-key.json even carries the endpoint (Authenticator().get_api_base() reads endpoints.api).

The failure mode predates this PR, but the PR summary says each family is dispatched explicitly now -- for copilot only the bad-credential half is fixed; the happy path is unreachable, and no test pins it because it cannot pass. Suggested fix: a copilot-specific probe like _probe_codex_catalog (hit {get_api_base()}/models with the fetched key), or at minimum fill api_base from get_api_base() before the generic probe.

For the record (verified in the litellm 1.85 sources, so nobody needs to re-check): the device-flow hazard IS closed on this path -- get_access_token returns the stored token without validating it, and a revoked token surfaces as RefreshAPIKeyError from _refresh_api_key, not as a _login() call.

3. Duplicate test definition

test_resetting_an_unrelated_provider_stays_quiet is defined twice in tests/test_cli_provider_commands.py (lines 540 and 601, identical bodies). The second shadows the first; ruff is silent because tests/** ignores F811. Delete one copy.

4. Ctrl-C during the TUI sign-in reports "exited with code null"

The oauthLogin screen promises "Ctrl+C while it runs cancels the sign-in and comes back here", but a child killed by a signal resolves {code: null} (ui-tui/src/lib/externalCli.ts, the exit handler), and signIn's result.code !== 0 branch renders that as an error: `raven provider login minimax-global` exited with code null (ui-tui/src/components/modelPicker.tsx). Special-case code === null with no error as a cancel (return to idle, no error text), or at least word it as cancelled.

5. Boot banner still injects cost_usd: 0.0 for plan-billed sessions

raven/tui_rpc/methods/session.py:121 hard-codes "cost_usd": 0.0 in the session-create usage baseline, so a plan-billed session shows $0.0000 in the status bar until the first message.complete overwrites it with null -- the exact "subscription reads as free" this PR removes elsewhere. Make the baseline None (or omit the key) when the default model's provider is plan-billed.

6. Stale docstring in raven/tui_rpc/methods/reload.py

The module docstring justifies the no-op handler with "useConfigSync.ts:202 polls reload.mcp every 5 seconds, hard-coded... the hermes call frequency itself cannot be touched (it's pulled from the fork)". This PR deleted that poller. The handler is still reachable from /reload-mcp, but the stated rationale is now false; rewrite it.

Verified non-issues (checked; no changes needed)

  • estimated_cost_usd: None threads safely end to end: raven/tui_rpc/models.py already declares cost_usd: float | None, appChrome.tsx:355 gates on typeof === 'number', session.ts:648 on != null, and _add_into guards the sum.
  • The mtime-poll removal claim is accurate: the tui_rpc config.get whitelist never served mtime.
  • The codex catalogue cache invalidates across processes via the auth-file fingerprint (mtime_ns + size), so provider login not calling reset_cache() is correct.
  • The per-instance stubbing of _login_device_code / _wait_for_access_token is brittle by design but pinned by test_the_refusal_is_wired_to_the_methods_the_driver_actually_falls_back_to.
  • Branch commits: all Conventional-Commits compliant, all English, zero non-ASCII characters.

Minor observations, no action expected: codex_catalog's module-level cache is unsynchronized across asyncio.to_thread callers (benign duplicate fetch at worst); signals arriving during a terminal handoff are dropped rather than re-delivered after restore (a SIGHUP mid-device-flow can leave a headless TUI); own_entry_point() matches any argv[0] basename starting with "raven".

0xKT and others added 4 commits August 6, 2026 22:01
Moving these under `~/.raven/oauth` dropped the mode the old stack set. It
chmodded its token file to 0600 (`oauth_cli_kit/storage.py:70`); the drivers
that replaced it write with a plain `open()`, so on a 022 umask `auth.json`,
`access-token` and `api-key.json` landed world-readable, in the same
directory where raven's own MiniMax writer keeps 0600.

Three parts, because only some of the writers are ours. The directory is
created 0700 and tightened if it is not, which holds for a file raven never
sees -- including one a family added later writes. A sign-in then restricts
what it left behind, once for every family rather than in each handler: a
later write from the same driver truncates in place and keeps the mode, so
this holds across every refresh after it. And the one rewrite that replaces
the file instead of truncating it sets the mode on the replacement, where
there is no mode left to keep.

Which files a sign-in leaves behind was already answered once, for
disconnect, so both callers ask it. That list derived MiniMax's path a second
time rather than asking the module that writes it, which is wrong exactly
when a user has set `MINIMAX_OAUTH_TOKEN_DIR` -- so it asks, like the other
three families already did.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`provider test github-copilot` could not succeed. It fetched a real API key
and then fell into the generic probe, which returned "api_base is empty and
provider has no default" -- the endpoint is not in the registry, it arrives
inside the API key file -- and the key was discarded. Only the
bad-credential half of this family's check was reachable, and no test pinned
the other half because it could not pass.

Asking its own way would not have worked either: the backend refuses a
request carrying only an `Authorization` header. Its driver sends a set of
editor headers on every call, so a valid seat would have come back refused
and been reported as a bad credential.

So this family gets its own probe, like Codex, taking both the endpoint and
the headers from the driver rather than guessing them. The request and how
its answer is reported are shared with the generic path -- one vocabulary,
two callers deciding what to ask.

Unverified: that GitHub answers 200 to this shape. It needs a live seat, and
what is pinned here is the request that goes out.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Ctrl+C during a sign-in showed `exited with code null`. A child killed by a
signal reports no exit code, and the screen promises that Ctrl+C cancels and
comes back -- so it told the user their own key press was a fault. Cancelling
is now what it looks like.

The boot banner still opened at $0.00 for a plan-billed session. Every other
counter there is genuinely zero before the first turn; this one was reporting
a price for a session whose every turn answers "no per-token price". The
predicate that decides is now asked wherever a dollar figure is reported
rather than only inside the estimate.

Also: `reload.mcp`'s docstring justified the handler by a five-second poll
this branch deleted, in the module and again in its test file. The handler is
still reached by `/reload-mcp`, so what changes is the reason. And a
duplicate copy of `test_resetting_an_unrelated_provider_stays_quiet` goes --
identical body, shadowing the first, invisible to ruff because tests ignore
F811.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
`test_provider` no longer times anything itself, and deleting the duplicate
test left two blank lines at the end of its file. Both from the pre-commit
ruff hooks, which see every changed file rather than the lint targets.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
@0xKT

0xKT commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thank you -- all six verified independently, all six real, nothing waved away. Fixed
in 9182f3c, 8ccad09, 13bb188 (plus c54b644 for what the ruff hooks took with them).
Point by point, including where I read the severity differently.

1. 0600 -- confirmed, and fixed as a class rather than at the three named sites

Every citation checked out. I pulled the 0.1.6 wheel to read the removed
dependency rather than take it on trust: oauth_cli_kit/storage.py:70 is
os.chmod(path, 0o600), exactly as you said.

One correction to the attribution: this is a regression for openai_codex only.
Copilot's files were already 0644 on main under ~/.config/litellm -- this PR
relocated them rather than widening them. The remedy is the same for both, so the
finding stands as written; the account differs.

Fixed in three parts, because only some of these writers are ours:

  • get_oauth_dir() creates 0700 and tightens an existing directory that is not.
    That is the part that holds for a file raven never sees -- including one a family
    added later writes.
  • A sign-in restricts what it left behind, at one call site in provider_login
    rather than in each handler, over the list that already answered "every file a
    sign-in can leave behind" for disconnect. A driver rewriting its own credential
    opens the path for truncation and keeps the mode, so once per sign-in holds
    across every refresh after it.
  • clear_abandoned_device_code sets the mode on the replacement, since
    os.replace leaves the old inode's mode with the old inode.

Your suggestion to do it in _point_oauth_tokens_at_raven would also have worked;
I put it in get_oauth_dir() because every caller derives the path through it, and
paths.py already creates the directories it names.

While in there: that shared list derived MiniMax's path a second time instead of
asking the module that writes it, so it pointed at the wrong file whenever
MINIMAX_OAUTH_TOKEN_DIR was set -- disconnect would have missed the credential it
means to delete. It asks now, like the other three families.

2. Copilot probe -- confirmed, and it needed more than filling api_base

Reproduced your execution. But filling api_base from get_api_base() and
falling into the generic probe would have swapped one wrong answer for another:
get_copilot_default_headers (litellm/llms/github_copilot/common_utils.py:60)
sends copilot-integration-id, editor-version and five more on every call, and
the backend refuses a request carrying only Authorization. A valid seat would
have come back 401 and been reported as a bad credential. The generic URL is also
wrong for it -- "/v1" not in api_base turns api.githubcopilot.com into
/v1/models.

So Copilot gets its own probe alongside Codex's, taking both the endpoint and the
headers from the driver. The request and how its answer is reported are shared with
the generic path, so there is one vocabulary and two callers deciding what to ask.

Two tests that could not exist before now do: the happy path asserts the URL, the
bearer and the editor headers on the outgoing request, and a credential with no
endpoint reports a credential problem rather than "provider has no default". What
is still unverified is that GitHub answers 200 to that shape -- it needs a live
seat, and the PR says so rather than implying otherwise.

You were right that the summary overclaimed. It now says which families get their
own probe and why, and carries the unverified part.

3. Duplicate test -- confirmed, deleted

Mine, from restoring a file I had clobbered by appending to it. F811 is ignored
under tests/**, so nothing was going to say so.

4. Ctrl+C -- confirmed, and worse than cosmetic

The screen promises Ctrl+C cancels, so exited with code null told the user their
own key press was a fault. code === null with no error is now a cancel, pinned by
a test that mounts the picker with {code: null}.

5. Boot banner -- confirmed

session.py opened a plan-billed session at $0.00, which is the same "a
subscription reads as free" this PR removes from the estimate: an incomplete sweep,
not a separate bug. The predicate that decides is now public and asked wherever a
dollar figure is reported.

6. reload.py docstring -- confirmed, and there were two copies

The module docstring and tests/test_tui_rpc_reload.py's both justified the
handler by the poll this PR deleted. Both rewritten; the handler stays, since
/reload-mcp reaches it.

Verification

Each fix was mutation-checked in a throwaway worktree -- directory not tightened,
sign-in not restricting, tmp.chmod removed, banner back to 0.0, Copilot dispatch
removed, Copilot headers reduced to Authorization -- and the matching test goes
red for each. Full suites after: 5525 passed / 48 skipped (was 5516), 969 TS tests
(was 968). All eight CI checks green.

On your minor observations: agreed on all three, and none touched here. The
unsynchronized catalogue cache costs a duplicate fetch; a signal during a terminal
handoff is dropped rather than re-delivered; own_entry_point() matches any argv[0]
basename starting with "raven". Left alone deliberately rather than overlooked.

Your "verified non-issues" section saved a round of re-litigation -- I spot-checked
the cost_usd: float | None thread and the _add_into guard and found them as you
described. Worth the space it took.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/model provider picker does not respond when selecting MiniMax OAuth provider

2 participants