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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ already in use` and no way forward short of finding the file in a temporary

### Provider setup

- **Signing in after "Disconnect provider" works again** — disconnecting a
provider in Settings marks it disabled in the global config, and that mark
outranked everything the sign-in flow wrote: logging in again rewrote the
provider setup but never cleared the mark, so no provider ever came back,
and the setup dialog reopened right over its own "CoDev configured" toast —
every time, surviving restarts, until something rewrote the config file
wholesale (which is why `codevhub config` fixed it). Completing a sign-in —
SSO or custom provider, desktop app or terminal — now clears that provider's
disabled mark, and only its own: other providers you disconnected on purpose
stay disconnected.
([#325](https://github.com/quickbeard/codev-code/pull/325))
- **The gateway API key no longer sits in codev.json** — every configure flow
(the TUI and desktop SSO login, the manual-key fallback) wrote the key
in plain sight into the global `codev.json`, the same file people open to
Expand Down
15 changes: 14 additions & 1 deletion packages/app/src/components/gateway/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,24 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) {
// — codev.json must not carry the credential. Auth first: a keyless
// config with no auth entry is a provider that 401s on the first chat.
await client.auth.set({ providerID: result.providerID, auth: { type: "api", key: result.apiKey } })
// A provider the user disconnected earlier sits in disabled_providers,
// which outranks everything this flow writes: the registry skips it, the
// gate sees nothing connected, and this dialog reopens right over the
// success toast — forever, since each retry rewrites the block but never
// the flag. Same guard as the custom-provider dialog's save.
const disabled = serverSync().data.config.disabled_providers ?? []
// Raw update instead of serverSync().updateConfig: its onSuccess refetch
// races the forked server-side instance reload. The explicit dispose
// below emits global.disposed, which refreshes every window (mirrors the
// TUI's update -> dispose -> bootstrap ordering).
await client.global.config.update({ config: result.config as Config })
await client.global.config.update({
config: {
...result.config,
...(disabled.includes(result.providerID)
? { disabled_providers: disabled.filter((id) => id !== result.providerID) }
: {}),
} as Config,
})
await client.global.dispose()
const arrived = await waitForModel(result.providerID, model)
if (arrived) {
Expand Down
20 changes: 15 additions & 5 deletions packages/tui/src/component/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,22 @@ async function runModelFlow(ctx: FlowContext, creds: GatewayCreds) {
// codev.json must not carry the credential. Auth first: a keyless config
// with no auth entry is a provider that 401s on the first chat.
await ctx.sdk.client.auth.set({ providerID: GATEWAY_PROVIDER.id, auth: { type: "api", key: creds.apiKey } })
// A provider the user disconnected earlier sits in disabled_providers,
// which outranks everything this flow writes: the registry skips it, no
// provider connects, and the gate reopens right over the success toast —
// forever, since each retry rewrites the block but never the flag.
const disabled = ctx.sync.data.config.disabled_providers ?? []
const result = await ctx.sdk.client.global.config.update({
config: buildGatewayConfig({
baseUrl: creds.gatewayBaseUrl,
model,
models,
}),
config: {
...buildGatewayConfig({
baseUrl: creds.gatewayBaseUrl,
model,
models,
}),
...(disabled.includes(GATEWAY_PROVIDER.id)
? { disabled_providers: disabled.filter((id) => id !== GATEWAY_PROVIDER.id) }
: {}),
},
})
if (result.error) throw new Error(JSON.stringify(result.error))
})
Expand Down
19 changes: 13 additions & 6 deletions packages/tui/src/component/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,20 @@ export async function runCustomProviderFlow(ctx: CustomProviderFlowContext) {

if (models) {
setStatus(`Configuring ${provider.id}...`)
// Reconnecting a provider the user disconnected earlier must clear it from
// disabled_providers, or the flag keeps the rewritten block dead (the app's
// custom-provider dialog does the same on save).
const disabled = ctx.sync.data.config.disabled_providers ?? []
const result = await ctx.sdk.client.global.config.update({
config: buildCustomProviderConfig({
providerID: provider.id,
name: provider.name,
baseUrl: baseUrl.trim(),
models,
}),
config: {
...buildCustomProviderConfig({
providerID: provider.id,
name: provider.name,
baseUrl: baseUrl.trim(),
models,
}),
...(disabled.includes(provider.id) ? { disabled_providers: disabled.filter((id) => id !== provider.id) } : {}),
},
})
if (result.error) {
ctx.toast.show({ variant: "error", message: JSON.stringify(result.error) })
Expand Down
49 changes: 49 additions & 0 deletions packages/tui/test/custom-provider.view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,55 @@ test("Custom provider with a base URL discovers models and writes the codev.json
}
}, 60_000)

// A provider the user disconnected earlier sits in disabled_providers, which
// outranks the rewritten block: the registry skips it and the gate reopens
// over the success toast, forever. Reconnecting must clear the flag — and only
// its own id, not another provider's opt-out.
test("reconnecting a disconnected provider clears it from disabled_providers", async () => {
const models = Bun.serve({
port: 0,
fetch: () => Response.json({ data: [{ id: "mock-small" }] }),
})
const modelsBase = `http://127.0.0.1:${models.port}`
let configured = false
const provider = { id: "mockgw", name: "mockgw", models: { "mock-small": { name: "mock-small" } } }
const boot_ = await boot(
(url) => {
if (url.pathname === "/config") return json({ disabled_providers: ["mockgw", "othergw"] })
if (url.pathname === "/config/providers")
return json(
configured ? { providers: [provider], default: { mockgw: "mock-small" } } : { providers: [], default: {} },
)
if (url.pathname === "/provider")
return json(
configured
? { all: [provider], default: {}, connected: ["mockgw"] }
: { all: [], default: {}, connected: [] },
)
return undefined
},
() => (configured = true),
)
try {
await waitFrame(boot_.setup, (f) => f.includes(GATE))
boot_.setup.mockInput.pressArrow("down") // Custom provider
boot_.setup.mockInput.pressEnter()

await waitFrame(boot_.setup, (f) => f.includes("Provider name"))
await answer(boot_.setup, "mockgw")
await waitFrame(boot_.setup, (f) => f.includes("API key"))
await answer(boot_.setup, "sk-mock")
await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint"))
await answer(boot_.setup, modelsBase)

await waitUntil(boot_.setup, () => boot_.captured.config.length > 0)
expect(boot_.captured.config[0].disabled_providers).toEqual(["othergw"])
} finally {
models.stop(true)
await teardown(boot_)
}
}, 60_000)

// The Windows path: ctrl+v arrives as a key event (no bracketed paste), so the
// dialog's paste command must read the clipboard itself. CR/CRLF from the
// Windows clipboard must not survive into the field.
Expand Down
Loading