Skip to content

feat: pre-fill a Provider's default model and open its key page - #79

Merged
yujiezhang-ops merged 1 commit into
chore/default-model-deepseek-v4-profrom
feat/provider-default-model-and-key-management
Aug 6, 2026
Merged

feat: pre-fill a Provider's default model and open its key page#79
yujiezhang-ops merged 1 commit into
chore/default-model-deepseek-v4-profrom
feat/provider-default-model-and-key-management

Conversation

@yujiezhang-ops

Copy link
Copy Markdown
Collaborator

Implements #76. Stacked on #77 — review that one first; this branch contains its commit, so the base should be switched to main once #77 merges.

A first-time user on PPIO or Novita had to supply a model ID before anything worked, with no basis for choosing between deepseek-v3.2 and deepseek-v4-flash. Onboarding is now sign in → paste key, with the model pre-filled and still editable.

Two fields, not one

The value already existed as fallback_probe_model and already reached config writing via resolveProviderModel. It was unreadable from the frontend by design: PublicProviders zeroes it and a test asserted the public projection contained no "deepseek" at all.

Rather than un-hide it, this adds default_model and keeps the probe model internal, because they answer different questions. default_model is a recommendation the user sees and edits; the probe model is the cheapest way to prove a key works. Conflating them means either probing with the expensive model on every connection test or recommending the cheap one for daily coding.

So they now hold different values — deepseek-v4-pro for the default, deepseek-v4-flash for probing. That difference is also what makes a leak detectable: with identical values, no substring assertion could tell whether the probe model had escaped into the public projection. TestPublicProjectionDoesNotExposeFallbackModel now compares against the probe model's actual value instead of the string "deepseek", which would otherwise fail on correct output while still passing if the probe model were renamed.

default_model is required per built-in Provider and deliberately has no global fallback, unlike FallbackProbeModel. A custom endpoint gets no guessed model — a guess produces a config that fails on the user's first request. key_management_url is optional and falls back to home, so user-added Providers keep working unchanged.

Where the seeding lives

In the wizard reducer, next to the Provider selection it follows, rather than duplicated per page. Every path that changes the Provider re-seeds (SET_PROVIDER, SELECT_AGENT, START_SETUP, START_DESKTOP_SETUP, START_NEW_PROFILE, and first STATUS_LOADED), because a model ID from one Provider is rarely valid at another.

Two precedence traps surfaced while doing this, both now covered by tests:

MODELS_RESULT resolved the model as state.model || state.probeModel.trim() || models[0]. That was correct when state.model was usually empty; now that it is seeded, the default would silently outrank a probe model the user typed by hand. The check is now against the seeded value, so an explicit pick wins and the default is only a starting point.

STATUS_LOADED had to stay first-load-only. A status refresh re-enters that branch, and seeding there unconditionally would overwrite a model the user was midway through typing.

In ProfilesPage, switching Provider only replaces the model when the field still holds the previous Provider's default or nothing — a model the user typed is theirs to keep.

A bug found on the way

Store.Public rebuilt the overlay record from scratch for any Provider with a saved user entry. Since a built-in Provider gets an entry the moment you save a key against it, PPIO would have lost its default model exactly when it started being useful. Fixed by re-reading both catalog fields in the overlay.

Key affordance

OpenRegistration prefers key_management_url, falls back to home, and keeps the existing scheme/host/userinfo validation. The frontend still passes no URL — the backend re-resolves it, so a tampered frontend cannot choose what opens. The CLI's promptForKey uses the same precedence.

The button is relabelled from "Register and get a key" to "Get an API key": a key-management page needs an existing account, so the old wording promised a sign-up flow it no longer starts. Its render guard was providerMeta?.home, which would have hidden the button for a Provider that publishes only a key page.

ProvidersPage's plain <a href> is left as-is. I flagged it in #76 as the one place a Provider URL reaches the browser unvalidated. It is not exploitable: validateEntry runs ValidateBaseURL on home at save time, and I verified it rejects javascript:, data:, file: and embedded credentials. It is a "Website" link, so home is the right target. Routing it through the opener would be churn for no security gain.

Lock files

Audited all three. agents.lock.json and runtimes.lock.json have no unparsed fields. providers.lock.json has five that Go never reads — relationship, disclosure, order, protocols, referral_url — which docs/public-site-operations.md says are for the public site that vendors this file.

That is now documented explicitly, including the consequence: filling in referral_url changes nothing, because there is no such field on providerFileEntry and json.Unmarshal discards it. Routing users through a referral link would need the field parsed and given an explicit precedence, not just a value set. TestSiteOnlyProviderFieldsAreNotParsed pins this — the guarantee currently rests on a struct omitting fields, which is invisible to anyone adding one.

Verification

go test ./..., go test -race ./..., go vet ./... all pass. pnpm run test is 215 passing, up from 205. pnpm run typecheck and pnpm run build pass. python3 scripts/check-docs.py reports 50 files, links resolve, language split holds. The desktop target compiles with CGO_ENABLED=1 go build -tags wails.

Bindings regenerated with the pinned Wails CLI (v3.0.0-beta.4, same flags as build/Taskfile.yml); the diff is confined to the two model files. frontend/src/types/api.ts needed no change — it re-exports CatalogModels.Provider and ProviderModels.Entry directly.

The frozen status fixture gained exactly the two new provider fields and nothing else drifted.

Three new tests were mutation-checked rather than assumed: reverting the PublicProviders zeroing, making DefaultModel fall back for unknown Providers, and wiring referral_url into providerFileEntry each fail the intended test and only that test.

Not in scope

Loading the model list before a key is entered (part C of #76). Both /v1/models endpoints do answer anonymously, but the pre-fill makes the default visible without it, and depending on an unauthenticated endpoint a Provider is free to lock down deserves its own change. Also unchanged: ModelPicker's <small>OpenAI-compatible model</small>, which is wrong for the Anthropic protocol, and any per-Agent model recommendation for Codex/Responses.

🤖 Generated with Claude Code

A first-time user on PPIO or Novita had to supply a model ID before
anything worked, with no basis for choosing one. The value they needed
already existed as fallback_probe_model, but the frontend could not read
it: PublicProviders zeroes that field, and a test asserted the public
projection contained no "deepseek" at all.

Adds two manifest fields rather than exposing the probe model, because
they answer different questions. default_model is a recommendation a user
sees and can edit; the probe model is the cheapest way to prove a key
works. Conflating them would have meant either probing with the expensive
model or recommending the cheap one. They now hold different values,
which also makes a probe-model leak detectable -- with identical values,
no substring test could see one.

default_model is required per built-in Provider and deliberately has no
global fallback: a custom endpoint gets no guessed model, since a guess
produces a config that fails on first use. key_management_url is optional
and falls back to home, so user-added Providers keep working.

Seeding lives in the wizard reducer, next to the Provider selection it
follows, rather than in each page. Every path that changes the Provider
re-seeds, since model IDs are not portable between Providers. Two
precedence traps came out of this: MODELS_RESULT used `state.model ||`,
which would now let the seeded default outrank a hand-typed probe model,
and STATUS_LOADED had to stay first-load-only so a refresh cannot
overwrite a half-typed field.

Store.Public rebuilt its overlay from scratch, so saving a key against a
built-in Provider would have silently dropped its default model.

Relabels the button: it opens a key page that needs an existing account,
so "Register and get a key" promised a sign-up flow it no longer starts.

Documents in public-site-operations.md that referral_url, relationship,
disclosure, order and protocols are site-only and discarded by
json.Unmarshal, with a test pinning it -- the guarantee rests on
providerFileEntry omitting them, which is invisible at the call site.

Leaves ProvidersPage's plain anchor alone: home is validated against
javascript:, data:, file: and embedded credentials when saved, so it can
only ever hold an http(s) URL.
@yujiezhang-ops
yujiezhang-ops merged commit cf12d28 into chore/default-model-deepseek-v4-pro Aug 6, 2026
4 checks passed
@yujiezhang-ops
yujiezhang-ops deleted the feat/provider-default-model-and-key-management branch August 7, 2026 09:52
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.

1 participant