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 @@ -79,6 +79,17 @@ file is the human-readable summary of what changed and why.

### Gateway

- **A gateway that refuses your account says so, once** — when the CoDev
gateway declines to issue a key for an account (today: an email domain
outside its allowlist, such as a sub-domain of a listed one), **Login with
SSO** used to retry the request
three times and then quit with a generic "setup failed" wrapped around a
nested 502/400 status chain. The refusal is a policy answer, not a flaky
network, so it is no longer retried; the TUI and the desktop app now land on
the manual API-key step with the gateway's own reason and a note that
retrying will not change it, so a key issued by hand gets you going. Needs
the matching codev-backend release, which reports the refusal as `403
key_refused`; against an older backend the previous behaviour stands.
- **The terminal app notices a dead gateway key at startup** — gateway keys
have a lifetime CoDev does not control, and until now only the desktop app
checked the stored one when it launched. In the TUI a key the gateway had
Expand Down
13 changes: 10 additions & 3 deletions packages/app/src/components/gateway/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const PROVIDER_POLL_MS = 250
type Step =
| { kind: "menu" }
| { kind: "sso" }
| { kind: "apikey" }
| { kind: "apikey"; refused?: string }
| { kind: "model"; models: string[] }
| { kind: "busy"; text: string }

Expand Down Expand Up @@ -77,7 +77,7 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) {
setStep({ kind: "busy", text: language.t("gateway.configure.status.setup") })
const setup = await sso.fetchSetup()
if (!setup.hasApiKey) {
setStep({ kind: "apikey" })
setStep({ kind: "apikey", refused: setup.keyRefused })
return
}
await toModels()
Expand Down Expand Up @@ -255,7 +255,14 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) {
<Match when={step().kind === "apikey"}>
<div class="flex flex-col gap-1">
<div class="text-16-medium text-text-strong">{language.t("gateway.configure.apikey.title")}</div>
<p class="text-14-regular text-text-base">{language.t("gateway.configure.apikey.description")}</p>
<p class="text-14-regular text-text-base">
{(() => {
const refused = (step() as Extract<Step, { kind: "apikey" }>).refused
return refused
? language.t("gateway.configure.apikey.refused", { reason: refused })
: language.t("gateway.configure.apikey.description")
})()}
</p>
</div>
<PasteField
busy={false}
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/gateway/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export type GatewaySetupResult = {
// False when the account has no provisioned key: the dialog must collect one
// manually via setManualApiKey before fetching models.
hasApiKey: boolean
// Set (with hasApiKey false) when the gateway declined to issue this account
// a key — e.g. an email domain outside its allowlist. The gateway's own
// sentence, for the manual-key step to show: the user should learn why they
// are being asked for a key by hand, and that retrying will not change it.
keyRefused?: string
}

export type GatewayCompleteResult = {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,8 @@ export const dict = {
"gateway.configure.sso.paste.placeholder": "Paste the callback URL or code if the browser can't reach this machine",
"gateway.configure.apikey.title": "Gateway API key",
"gateway.configure.apikey.description": "Your account has no gateway API key. Enter one manually.",
"gateway.configure.apikey.refused":
"{{reason}}. Retrying won't change this — enter an API key issued to you by hand.",
"gateway.configure.apikey.label": "API key",
"gateway.configure.model.title": "Choose default model",
"gateway.configure.status.setup": "Fetching gateway configuration...",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,8 @@ export const dict = {
"Pega la URL de callback o el código si el navegador no puede llegar a esta máquina",
"gateway.configure.apikey.title": "Clave API del gateway",
"gateway.configure.apikey.description": "Tu cuenta no tiene clave API del gateway. Introduce una manualmente.",
"gateway.configure.apikey.refused":
"{{reason}}. Reintentar no cambiará esto: introduce una clave API que te hayan emitido manualmente.",
"gateway.configure.apikey.label": "Clave API",
"gateway.configure.model.title": "Elige el modelo predeterminado",
"gateway.configure.status.setup": "Obteniendo la configuración del gateway...",
Expand Down
33 changes: 32 additions & 1 deletion packages/codev-gateway/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@ import type { CodevConfig } from "./auth-file"

interface ErrorResponse {
error?: string
// Machine-readable reason the backend attaches to some failures; see
// BackendError.
code?: string
}

// A non-2xx from the CoDev backend. Beyond the message every caller already
// prints, it keeps the backend's own `error` text (`reason`) and, when the
// backend sends one, its `code` — so a caller can branch on what kind of
// failure this was without pattern-matching prose. Hub parity: codev-hub's
// lib/backend.ts throws the same shape.
export class BackendError extends Error {
constructor(
message: string,
public status: number,
public reason: string,
public code?: string,
) {
super(message)
this.name = "BackendError"
}
}

// The gateway looked at this user and declined to issue a key — today, an
// email domain outside its allowlist. The backend answers 403 with
// `code: "key_refused"`. Nothing on this machine changes the answer: retrying
// is pointless, and the way forward is a key issued by hand (the manual-entry
// path) or a fix on the gateway side. The code string is a wire contract with
// codev-backend/src/litellm.ts.
export function isKeyRefusal(err: unknown): err is BackendError {
return err instanceof BackendError && err.code === "key_refused"
}

// Callers embedded in Electron pass the host-aware net.fetch so enterprise
Expand All @@ -26,7 +56,8 @@ export async function fetchApiKey(accessToken: string, opts?: FetchOptions): Pro
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as ErrorResponse
throw new Error(`Backend /auth/exchange failed (${res.status}): ${body.error || res.statusText}`)
const reason = body.error || res.statusText
throw new BackendError(`Backend /auth/exchange failed (${res.status}): ${reason}`, res.status, reason, body.code)
}
const data = (await res.json()) as { api_key?: string }
return data.api_key ?? ""
Expand Down
2 changes: 2 additions & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ export {
type CodevConfig,
} from "./auth-file"
export {
BackendError,
fetchApiKey,
fetchCodevConfig,
fetchModels,
fetchModelWindows,
isKeyRefusal,
supabaseExchange,
validateApiKey,
type AnalysisBackendSession,
Expand Down
35 changes: 34 additions & 1 deletion packages/codev-gateway/test/backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"
import { fetchModels, fetchModelWindows, validateApiKey } from "../src/backend"
import { BackendError, fetchApiKey, fetchModels, fetchModelWindows, isKeyRefusal, validateApiKey } from "../src/backend"

// A real loopback server (not a mock) that records the path + auth header of the
// last request, so tests assert the exact endpoint fetchModels/validateApiKey
Expand All @@ -18,6 +18,39 @@ const base = `http://127.0.0.1:${server.port}`

afterAll(() => server.stop(true))

describe("fetchApiKey", () => {
// fetchApiKey targets the baked-in BACKEND_URL, so these go through the
// fetchImpl seam rather than the loopback server.
const respond = (res: Response) => ({ fetchImpl: (async () => res) as unknown as typeof fetch })

test("returns the api_key on a 2xx", async () => {
expect(await fetchApiKey("t", respond(Response.json({ api_key: "sk-1" })))).toBe("sk-1")
})

test("a 403 key_refused becomes a BackendError carrying the code and reason", async () => {
const reason = "The gateway declined to issue a key for u@os.example.com: Only @example.com emails are supported"
const err = await fetchApiKey("t", respond(Response.json({ error: reason, code: "key_refused" }, { status: 403 })))
.then(() => undefined)
.catch((e: unknown) => e)
if (!(err instanceof BackendError)) throw new Error("expected a BackendError")
expect(err.status).toBe(403)
expect(err.code).toBe("key_refused")
expect(err.reason).toBe(reason)
expect(err.message).toBe(`Backend /auth/exchange failed (403): ${reason}`)
expect(isKeyRefusal(err)).toBe(true)
})

test("a plain 502 is a BackendError but not a refusal", async () => {
const err = await fetchApiKey("t", respond(Response.json({ error: "upstream" }, { status: 502 })))
.then(() => undefined)
.catch((e: unknown) => e)
if (!(err instanceof BackendError)) throw new Error("expected a BackendError")
expect(err.code).toBeUndefined()
expect(isKeyRefusal(err)).toBe(false)
expect(isKeyRefusal(new Error("x"))).toBe(false)
})
})

describe("fetchModels", () => {
test("returns the model ids from an OpenAI-shaped /v1/models response", async () => {
handler = () => Response.json({ data: [{ id: "a" }, { id: "b" }] })
Expand Down
24 changes: 23 additions & 1 deletion packages/desktop/src/main/gateway-sso/controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS, type AuthData } from "@codev/gateway"
import { BackendError, GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS, type AuthData } from "@codev/gateway"
import { createGatewaySsoController } from "./controller"

const auth: AuthData = {
Expand Down Expand Up @@ -162,6 +162,28 @@ describe("gateway sso controller", () => {
expect(setup).toEqual({ gatewayUrl: "https://gw.example.test", hasApiKey: false })
})

test("fetchSetup does not retry a key refusal and reports the gateway's reason", async () => {
let keyCalls = 0
const reason =
"The gateway declined to issue a key for user@os.example.test: Only @example.test emails are supported"
const controller = controllerWith({
silentSso: async () => auth,
fetchCodevConfig: async () => ({ supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" }),
fetchApiKey: async () => {
keyCalls++
throw new BackendError(`Backend /auth/exchange failed (403): ${reason}`, 403, reason, "key_refused")
},
saveCodevConfig: () => {},
})

await controller.startLogin()
const setup = await controller.fetchSetup()
// One attempt: the answer is a policy, not a flaky network.
expect(keyCalls).toBe(1)
expect(setup).toEqual({ gatewayUrl: "https://gw.example.test", hasApiKey: false, keyRefused: reason })
expect(controller.getState().phase).toBe("ready")
})

test("retry exhaustion surfaces the last error", async () => {
const controller = controllerWith({
silentSso: async () => auth,
Expand Down
27 changes: 24 additions & 3 deletions packages/desktop/src/main/gateway-sso/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
fetchModels,
fetchModelWindows,
GATEWAY_PROVIDER,
isKeyRefusal,
loadAccount,
loadApiKey,
loadAuth,
Expand Down Expand Up @@ -194,7 +195,10 @@ export function createGatewaySsoController(deps: Deps) {
try {
return await run()
} catch (error) {
if (attempt >= RETRIES) throw error
// A refusal is a policy answer, not a flaky network: the same request
// gets the same answer, so spending the budget on it only delays the
// renderer's fallback.
if (attempt >= RETRIES || isKeyRefusal(error)) throw error
setState({ status: `${label} failed — retrying (${attempt}/${RETRIES})...` })
}
}
Expand Down Expand Up @@ -272,10 +276,27 @@ export function createGatewaySsoController(deps: Deps) {
gateway.saveCodevConfig(config)
gatewayUrl = config.gatewayUrl
setState({ status: "Fetching gateway API key..." })
apiKey = await withRetries("Fetching gateway API key", () => gateway.fetchApiKey(auth.access_token, fetchOpts))
// A refusal — the gateway declined this account, e.g. an email domain
// outside its allowlist — is reported beside the no-key result rather
// than thrown: the renderer's manual-key step is the right place to land
// either way, and it shows the gateway's reason so the user knows why.
const minted = await withRetries("Fetching gateway API key", () =>
gateway.fetchApiKey(auth.access_token, fetchOpts),
).then(
(key) => ({ key, refusal: undefined as string | undefined }),
(error: unknown) => {
if (!isKeyRefusal(error)) throw error
return { key: "", refusal: error.reason }
},
)
apiKey = minted.key
manualKey = false
setState({ phase: "ready", status: "" })
return { gatewayUrl: config.gatewayUrl, hasApiKey: apiKey.length > 0 }
return {
gatewayUrl: config.gatewayUrl,
hasApiKey: apiKey.length > 0,
...(minted.refusal ? { keyRefused: minted.refusal } : {}),
}
},
// Startup pre-flight, the desktop twin of codev-hub's ensureFreshGatewayKey.
// Gateway keys have a lifetime CoDev does not control, and nothing here
Expand Down
26 changes: 21 additions & 5 deletions packages/tui/src/component/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
fetchModels,
fetchModelWindows,
GATEWAY_PROVIDER,
isKeyRefusal,
isLoginCancelled,
login,
saveApiKey,
Expand Down Expand Up @@ -206,13 +207,28 @@ async function runSsoFlow(ctx: FlowContext) {
saveCodevConfig(config)

setStatus("Fetching gateway API key...")
const apiKey = await withRetries("Fetching gateway API key", setStatus, () => fetchApiKey(session.access_token))
// A refusal — the gateway declined this account, e.g. an email domain
// outside its allowlist — is neither retryable nor a reason to quit: the
// manual path below works with a key issued by hand, so it lands there
// with the gateway's own reason instead of in failAndQuit's generic
// "setup failed". Same handling as codev-hub's install step.
const minted = await withRetries("Fetching gateway API key", setStatus, () =>
fetchApiKey(session.access_token),
).then(
(key) => ({ key, refusal: undefined as string | undefined }),
(err: unknown) => {
if (!isKeyRefusal(err)) throw err
return { key: "", refusal: err.reason }
},
)
// The backend returns an empty key for accounts without a provisioned
// gateway key — same as the hub, route those to manual entry.
if (!apiKey) {
if (!minted.key) {
ctx.toast.show({
variant: "info",
message: "Your account has no gateway API key. Enter it manually.",
variant: minted.refusal ? "warning" : "info",
message: minted.refusal
? `${minted.refusal}. Retrying won't change this — enter an API key issued to you by hand.`
: "Your account has no gateway API key. Enter it manually.",
})
const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-...", persistent: true })
if (!pasted?.trim()) return
Expand All @@ -224,7 +240,7 @@ async function runSsoFlow(ctx: FlowContext) {
return
}

await runModelFlow(ctx, { apiKey, gatewayBaseUrl: config.gatewayUrl })
await runModelFlow(ctx, { apiKey: minted.key, gatewayBaseUrl: config.gatewayUrl })
} catch (err) {
// The user backed out with escape: the dialog is already closed and the
// gate (if still unconfigured) has the entry chooser back up.
Expand Down
6 changes: 5 additions & 1 deletion packages/tui/src/component/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
buildCustomProviderConfig,
CUSTOM_FALLBACK_PROVIDER,
fetchModels,
isKeyRefusal,
providerFromName,
slugifyProviderName,
} from "@codev/gateway"
Expand Down Expand Up @@ -174,7 +175,10 @@ export async function withRetries<T>(
try {
return await op()
} catch (err) {
if (attempt >= RETRIES) throw err
// A refusal is a policy answer, not a flaky network: the same request
// gets the same answer, so spending the budget on it only delays the
// caller's fallback.
if (attempt >= RETRIES || isKeyRefusal(err)) throw err
onStatus(`${label} failed — retrying (${attempt + 1}/${RETRIES})...`)
}
}
Expand Down
Loading