Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cmd/oneagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,13 @@ func resolveCLIKey(options installCLIFlags, stderr io.Writer) (string, error) {
}
registration := options.RegisterURL
if registration == "" {
if home, ok := catalog.ProviderByID(options.Provider); ok {
registration = home.Home
if meta, ok := catalog.ProviderByID(options.Provider); ok {
// Same precedence as the desktop OpenRegistration: the key page is
// where a key is actually created, Home is the fallback.
registration = meta.KeyManagementURL
if registration == "" {
registration = meta.Home
}
} else {
registration, _ = provider.ProviderHome("ppio")
}
Expand Down
14 changes: 14 additions & 0 deletions docs/public-site-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ Provider's `relationship`, `disclosure`, and `referral_url` are maintained here,
must not influence Agent rank, compatibility conclusions, default selection, or connection
tests. That boundary belongs to this repository; the site only displays the result.

**Those fields are not read by the application, and that is enforced by omission.**
`relationship`, `disclosure`, `referral_url`, `order`, and `protocols` have no counterpart
in `providerFileEntry` (`internal/catalog/providers.go`), so `json.Unmarshal` discards
them. Filling in `referral_url` therefore changes nothing in the desktop app or the CLI —
it is published for the site to render, and the "get a key" button will keep opening
`key_management_url` (falling back to `home`) regardless. Routing users through a referral
link would be a product decision requiring the field to be parsed and given an explicit
precedence, not a matter of setting a value here.

The reverse also holds: `key_management_url` and `default_model` are read by the app. They
are not disclosure fields and are not subject to the paragraph above; `default_model` is
the model pre-filled for a user who has not chosen one, so changing it changes what gets
written into a real Agent configuration.

**Changing a lock file does not change the site.** The site vendors `agents.lock.json`
and `providers.lock.json` into its own `data/` directory, refreshed from release tags
rather than tracking this repository's `main`. This is deliberate: the site describes what
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ export interface Mirror {
export interface Provider {
"name": string;
"home": string;

/**
* KeyManagementURL and DefaultModel are public on purpose: the frontend
* needs both to spare a first-time user from hunting for a key page and
* inventing a model ID. Contrast fallbackModel below.
*/
"key_management_url"?: string;
"base_url": string;
"anthropic_base_url"?: string;
"default_model"?: string;
"custom"?: boolean;
"has_key"?: boolean;
"created_at"?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export interface Entry {
"id": string;
"name": string;
"home": string;

/**
* Both derived from the built-in catalog, not user-editable: a user who adds
* a Provider supplies Home, and a custom endpoint has no model we can vouch
* for. Serialized so the frontend can pre-fill the model field and aim the
* key button at a key page instead of a marketing site.
*/
"key_management_url"?: string;
"default_model"?: string;
"base_url": string;
"anthropic_base_url": string;
"api_key": string;
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ const english = {
"连接模型服务": "Connect a model provider",
"Key 不会进入日志、URL 或前端持久化状态": "The key is never written to logs, URLs, or persistent frontend state",
"继续选择模型": "Continue to model selection",
"注册并获取 Key": "Register and get a key",
// The button opens the Provider's key management page, which needs an account
// the user may not have yet. "Register and get a key" promised the sign-up
// flow; this wording covers both arriving signed in and having to sign up.
"获取 API Key": "Get an API key",
"自定义模型名称(可选)": "Custom model name (optional)",
"填写后将用此模型测试连接;留空时自动选择": "When provided, this model is used for the connection test. Leave blank to select automatically",
"可选,仅用于测试连接;实际配置模型在下一步选择": "Optional; used only for the connection test. Choose the configured model in the next step",
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/pages/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,39 @@ describe("ProfilesPage", () => {
expect(save).not.toHaveBeenCalled();
});

it("pre-fills the model from the Provider and leaves it editable", () => {
// The whole point of the field being pre-filled is that a first-time user
// never has to invent a model ID, so this asserts the value is present
// before any typing -- and that typing still replaces it.
mockState = {
status: {
...statusWith([]),
providers: { ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai", default_model: "ppio/default-model" } },
},
statusState: "success",
};
dispatch.mockClear();
render(
<MemoryRouter initialEntries={["/profiles"]}>
<Routes>
<Route path="/profiles" element={<ProfilesPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "新增 Profile" }));
expect(screen.getByLabelText("模型")).toHaveValue("ppio/default-model");
fireEvent.change(screen.getByLabelText("模型"), { target: { value: "mine" } });
expect(screen.getByLabelText("模型")).toHaveValue("mine");
});

it("leaves the model empty for a Provider with no default", () => {
// A user-added Provider is an endpoint we know nothing about; guessing a
// model for it would write a config that fails on the first request.
renderPage([]);
fireEvent.click(screen.getByRole("button", { name: "新增 Profile" }));
expect(screen.getByLabelText("模型")).toHaveValue("");
});

it("applies one Profile to all of its Agents", async () => {
const install = vi.spyOn(api, "install").mockResolvedValue({
ok: true,
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export function ProfilesPage() {
id,
label: `${providerMeta?.name || provider} Profile`,
provider,
model: "",
// Pre-filled from the Provider so a first-time user is not asked to invent
// a model ID. Empty for a custom Provider, whose endpoint we know nothing
// about, which leaves the field required exactly as before.
model: providerMeta?.default_model || "",
protocol: "",
originalId: "",
});
Expand All @@ -86,6 +89,19 @@ export function ProfilesPage() {
return byProviderCreatedAt(status.providers).find(([, provider]) => protocol === "anthropic" ? provider.anthropic_base_url : provider.base_url)?.[0] || current;
};

// Switching Provider re-seeds the model, but only when the field still holds
// the old Provider's default or nothing at all. A model the user typed is
// theirs to keep -- silently replacing it would be the more annoying bug, and
// model IDs are not portable between Providers, so leaving a stale default
// behind would be equally wrong.
const changeProvider = (draft: ProfileDraft, provider: ProviderId): ProfileDraft => {
const previous = status.providers[draft.provider]?.default_model || "";
const model = draft.model.trim() && draft.model !== previous
? draft.model
: status.providers[provider]?.default_model || "";
return { ...draft, provider, model };
};

const save = async (event: FormEvent) => {
event.preventDefault();
if (!editor || !canSave) return;
Expand Down Expand Up @@ -242,7 +258,7 @@ export function ProfilesPage() {
id="profile-protocol"
label={t("API 类型")}
value={editor.protocol}
onChange={(protocol) => setEditor({ ...editor, protocol, provider: providerForProtocol(protocol, editor.provider) })}
onChange={(protocol) => setEditor({ ...changeProvider(editor, providerForProtocol(protocol, editor.provider)), protocol })}
options={[
{ value: "", label: t("请选择 API 类型") },
...(Object.keys(PROTOCOL_LABELS) as ProtocolId[]).map((protocol) => ({ value: protocol, label: PROTOCOL_LABELS[protocol] })),
Expand All @@ -255,7 +271,7 @@ export function ProfilesPage() {
value={editor.provider}
providers={status.providers}
onAdd={() => navigate(`/providers/new?returnTo=${encodeURIComponent("/profiles")}`)}
onChange={(provider) => setEditor({ ...editor, provider })}
onChange={(provider) => setEditor(changeProvider(editor, provider))}
protocol={editor.protocol as ProtocolId}
/>
</div>
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/pages/ProviderKeyPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const dispatch = vi.fn((action: WizardAction) => {
});

const status = {
providers: { ppio: { name: "PPIO", home: "", base_url: "https://api.ppinfra.com/openai", has_key: true } },
providers: { ppio: { name: "PPIO", home: "", base_url: "https://api.ppio.com/openai", has_key: true } },
catalog: [],
} as unknown as StatusResponse;

Expand Down Expand Up @@ -72,4 +72,32 @@ describe("ProviderKeyPage", () => {

expect(screen.getByRole("button", { name: "继续选择模型" })).not.toBeDisabled();
});

it("offers the key button for a Provider with only a key page", () => {
// No home URL, so the pre-change guard would have hidden the button. The
// backend picks the URL; the page only decides whether to offer the action.
state = {
...initialWizardState,
status: {
...status,
providers: { ppio: { ...status.providers.ppio, home: "", key_management_url: "https://ppio.com/settings/key-management" } },
} as unknown as StatusResponse,
statusState: "success",
hasApiKey: true,
};
const open = vi.spyOn(api, "openRegister").mockResolvedValue({ ok: true, url: "", message: "" });
render(<MemoryRouter><ProviderKeyPage /></MemoryRouter>);

fireEvent.click(screen.getByRole("button", { name: "获取 API Key" }));
// No URL is passed: the backend re-resolves it, so a tampered frontend
// cannot choose what gets opened.
expect(open).toHaveBeenCalledWith("ppio", expect.anything());
});

it("hides the key button when the Provider publishes neither URL", () => {
state = { ...initialWizardState, status, statusState: "success", hasApiKey: true };
render(<MemoryRouter><ProviderKeyPage /></MemoryRouter>);

expect(screen.queryByRole("button", { name: "获取 API Key" })).toBeNull();
});
});
8 changes: 5 additions & 3 deletions frontend/src/pages/ProviderKeyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export function ProviderKeyPage() {
};

const openRegistration = async () => {
if (!providerMeta?.home) return;
// Either URL is enough; the backend applies the same precedence and does the
// scheme validation, so no URL is passed from here.
if (!providerMeta?.key_management_url && !providerMeta?.home) return;
try {
await api.openRegister(state.provider, probeAgentIds);
} catch (error) {
Expand Down Expand Up @@ -115,10 +117,10 @@ export function ProviderKeyPage() {
<strong>{providerMeta?.name}</strong>
<span>{providerMeta?.base_url}</span>
</div>
{providerMeta?.home ? (
{providerMeta?.key_management_url || providerMeta?.home ? (
<button className="button button-secondary" type="button" onClick={() => void openRegistration()}>
<ExternalLink size={15} />
{t("注册并获取 Key")}
{t("获取 API Key")}
</button>
) : null}
</div>
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/state/wizardReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,74 @@ describe("wizardReducer", () => {
expect(after).toBe(before);
});
});

describe("provider default model", () => {
// Two Providers with different defaults, plus one with none, which is what a
// user-added Provider looks like.
const withDefaults = {
...status,
providers: {
ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai", default_model: "ppio/model-a" },
novita: { name: "Novita", home: "https://novita.ai/", base_url: "https://api.novita.ai/openai", default_model: "novita/model-b" },
acme: { name: "Acme", home: "https://acme.test/", base_url: "https://api.acme.test/openai", custom: true },
},
} satisfies StatusResponse;

it("seeds the model from the Provider and re-seeds when the Provider changes", () => {
let state = wizardReducer({ ...initialWizardState, status: withDefaults, provider: "ppio" }, { type: "SET_PROVIDER", value: "ppio" });
expect(state.model).toBe("ppio/model-a");
state = wizardReducer(state, { type: "SET_PROVIDER", value: "novita" });
// Not carried over: a model ID from one Provider is rarely valid at another.
expect(state.model).toBe("novita/model-b");
});

it("leaves the model empty for a Provider that publishes no default", () => {
// A custom endpoint gets no guess, so the field stays required as before.
const state = wizardReducer({ ...initialWizardState, status: withDefaults }, { type: "SET_PROVIDER", value: "acme" });
expect(state.model).toBe("");
});

it("keeps a probe model the user typed instead of the seeded default", () => {
const seeded = wizardReducer({ ...initialWizardState, status: withDefaults }, { type: "SET_PROVIDER", value: "ppio" });
const typed = wizardReducer(seeded, { type: "SET_PROBE_MODEL", value: "ppio/hand-picked" });
const state = wizardReducer(typed, {
type: "MODELS_RESULT",
result: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false, models: ["ppio/discovered"] } satisfies ModelsResponse,
});
expect(state.model).toBe("ppio/hand-picked");
});

it("keeps a model chosen on the model step when discovery finishes later", () => {
// The seeded default must not win over an explicit pick, which is the trap
// in resolving this with a plain `state.model ||` now that it is never empty.
const seeded = wizardReducer({ ...initialWizardState, status: withDefaults }, { type: "SET_PROVIDER", value: "ppio" });
const chosen = wizardReducer(seeded, { type: "SET_MODEL", value: "ppio/chosen" });
const state = wizardReducer(chosen, {
type: "MODELS_RESULT",
result: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false, models: ["ppio/discovered"] } satisfies ModelsResponse,
});
expect(state.model).toBe("ppio/chosen");
});

it("seeds on first status load and on starting a new run", () => {
// byProviderCreatedAt puts user-added Providers ahead of built-ins, so the
// initially selected Provider here is the one without a default. The point
// is that the seed follows whichever Provider was picked, not that it is
// always non-empty.
const builtInOnly = { ...withDefaults, providers: { ppio: withDefaults.providers.ppio, novita: withDefaults.providers.novita } } satisfies StatusResponse;
const loaded = wizardReducer(initialWizardState, { type: "STATUS_LOADED", status: builtInOnly });
expect(loaded.model).toBe(builtInOnly.providers[loaded.provider as "ppio" | "novita"].default_model);
// Starting over resets to the default rather than to an empty field.
const restarted = wizardReducer({ ...loaded, model: "leftover" }, { type: "START_SETUP" });
expect(restarted.model).toBe(loaded.model);
const fresh = wizardReducer({ ...loaded, model: "leftover" }, { type: "START_NEW_PROFILE" });
expect(fresh.model).toBe(loaded.model);
});

it("does not overwrite a model the user is editing when status refreshes", () => {
// A refresh re-enters STATUS_LOADED; seeding there must be first-load only.
const loaded = wizardReducer(initialWizardState, { type: "STATUS_LOADED", status: withDefaults });
const editing = wizardReducer({ ...loaded, model: "half-typed" }, { type: "STATUS_LOADED", status: withDefaults });
expect(editing.model).toBe("half-typed");
});
});
Loading
Loading